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:
208
internal/app/palette_context_test.go
Normal file
208
internal/app/palette_context_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
)
|
||||
|
||||
// makeFakeChild builds a Child with just enough state for the palette
|
||||
// to render it. We don't start a PTY — the palette only reads ID,
|
||||
// Name, Kind, and Status() which all work without one.
|
||||
func makeFakeChild(id, name string, kind ChildKind) *Child {
|
||||
c := &Child{ID: id, Name: name, Kind: kind}
|
||||
st := StatusRunning
|
||||
c.status.Store(&st)
|
||||
return c
|
||||
}
|
||||
|
||||
// findAction scans p.items and returns the first paletteAction.kind
|
||||
// matching want, or "" if not found.
|
||||
func findItem(p *paletteState, want string) (int, *paletteItem) {
|
||||
for i := range p.items {
|
||||
if p.items[i].action.kind == want {
|
||||
return i, &p.items[i]
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func TestContextItemsScratchpad(t *testing.T) {
|
||||
p := newPalette(nil, "", "notes.md", preset.Set{})
|
||||
if i, _ := findItem(p, "pad-delete"); i != 0 {
|
||||
t.Fatalf("pad-delete at %d; want top", 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")
|
||||
}
|
||||
// No focused child → no agent/proc context items.
|
||||
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
|
||||
t.Fatalf("agent items leaked: index %d", i)
|
||||
}
|
||||
if i, _ := findItem(p, "proc-rename-form"); i != -1 {
|
||||
t.Fatalf("proc items leaked: index %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextItemsAgent(t *testing.T) {
|
||||
c := makeFakeChild("aid", "codex", KindAgent)
|
||||
p := newPalette([]*Child{c}, "aid", "", preset.Set{})
|
||||
if _, it := findItem(p, "agent-rename-form"); it == nil || it.action.childID != "aid" {
|
||||
t.Fatalf("agent-rename-form missing or wrong: %+v", it)
|
||||
}
|
||||
if _, it := findItem(p, "agent-close"); it == nil || it.action.childID != "aid" {
|
||||
t.Fatalf("agent-close missing or wrong: %+v", it)
|
||||
}
|
||||
// agent context never surfaces proc items.
|
||||
if i, _ := findItem(p, "proc-rename-form"); i != -1 {
|
||||
t.Fatalf("proc items leaked into agent context: index %d", i)
|
||||
}
|
||||
if i, _ := findItem(p, "pad-delete"); i != -1 {
|
||||
t.Fatalf("pad items leaked into agent context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextItemsProcess(t *testing.T) {
|
||||
c := makeFakeChild("pid", "devserver", KindCommand)
|
||||
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
|
||||
for _, kind := range []string{"proc-rename-form", "proc-delete", "proc-stop", "proc-restart"} {
|
||||
if _, it := findItem(p, kind); it == nil {
|
||||
t.Fatalf("missing proc context item: %s", kind)
|
||||
}
|
||||
}
|
||||
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
|
||||
t.Fatalf("agent items leaked into process context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextItemsAppearAboveSwitch(t *testing.T) {
|
||||
c := makeFakeChild("pid", "devserver", KindCommand)
|
||||
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
|
||||
procIdx, _ := findItem(p, "proc-rename-form")
|
||||
switchIdx, _ := findItem(p, "switch")
|
||||
if procIdx < 0 || switchIdx < 0 {
|
||||
t.Fatalf("missing items: proc=%d switch=%d", procIdx, switchIdx)
|
||||
}
|
||||
if procIdx > switchIdx {
|
||||
t.Fatalf("proc context item at %d came after switch at %d", procIdx, switchIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextItemsNoFocusNoExtras(t *testing.T) {
|
||||
p := newPalette(nil, "", "", preset.Set{})
|
||||
for _, kind := range []string{
|
||||
"pad-delete", "pad-rename-form", "pad-edit",
|
||||
"agent-rename-form", "agent-close",
|
||||
"proc-rename-form", "proc-delete", "proc-stop", "proc-restart",
|
||||
} {
|
||||
if i, _ := findItem(p, kind); i != -1 {
|
||||
t.Fatalf("unexpected context item %s with no focus (idx=%d)", kind, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming a scratchpad via Enter should open the rename form, accept
|
||||
// typed input, and emit a pad-rename-submit with the new name.
|
||||
func TestRenamePadFormCommits(t *testing.T) {
|
||||
p := newPalette(nil, "", "notes.md", preset.Set{})
|
||||
idx, _ := findItem(p, "pad-rename-form")
|
||||
if idx < 0 {
|
||||
t.Fatalf("pad-rename-form missing")
|
||||
}
|
||||
p.cursor = idx
|
||||
// Open the form.
|
||||
_, done, _ := p.handleInput([]byte("\r"), 0)
|
||||
if done {
|
||||
t.Fatalf("opening rename form closed palette")
|
||||
}
|
||||
if p.mode != paletteModeRenameForm || p.renameForm == nil {
|
||||
t.Fatalf("mode=%v form=%v after open", p.mode, p.renameForm)
|
||||
}
|
||||
if string(p.renameForm.name) != "notes.md" {
|
||||
t.Fatalf("prefill = %q", string(p.renameForm.name))
|
||||
}
|
||||
// Clear and type a new name.
|
||||
_, _, _ = p.handleInput([]byte{0x15}, 0) // Ctrl-U
|
||||
if len(p.renameForm.name) != 0 {
|
||||
t.Fatalf("Ctrl-U didn't clear: %q", string(p.renameForm.name))
|
||||
}
|
||||
for _, b := range []byte("brief.md") {
|
||||
_, _, _ = p.handleInput([]byte{b}, 0)
|
||||
}
|
||||
action, done, _ := p.handleInput([]byte("\r"), 0)
|
||||
if !done || action.kind != "pad-rename-submit" {
|
||||
t.Fatalf("submit didn't fire: action=%+v done=%v", action, done)
|
||||
}
|
||||
if action.padName != "notes.md" || action.newName != "brief.md" {
|
||||
t.Fatalf("submit payload = %+v", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProcessFormPrefillsCurrentName(t *testing.T) {
|
||||
c := makeFakeChild("pid", "devserver", KindCommand)
|
||||
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
|
||||
idx, _ := findItem(p, "proc-rename-form")
|
||||
if idx < 0 {
|
||||
t.Fatalf("proc-rename-form missing")
|
||||
}
|
||||
p.cursor = idx
|
||||
_, _, _ = p.handleInput([]byte("\r"), 0)
|
||||
if p.renameForm == nil || string(p.renameForm.name) != "devserver" {
|
||||
t.Fatalf("prefill = %v", p.renameForm)
|
||||
}
|
||||
if p.renameForm.subject != "proc" || p.renameForm.target != "pid" {
|
||||
t.Fatalf("form target/subject wrong: %+v", p.renameForm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameFormEscCancels(t *testing.T) {
|
||||
p := newPalette(nil, "", "notes.md", preset.Set{})
|
||||
p.mode = paletteModeRenameForm
|
||||
p.renameForm = &renameForm{name: []rune("x"), subject: "pad", target: "notes.md"}
|
||||
action, done, _ := p.handleInput([]byte{0x1b}, 0)
|
||||
if !done || action.kind != "cancel" {
|
||||
t.Fatalf("ESC didn't cancel: action=%+v done=%v", action, done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameFormEmptySubmitCancels(t *testing.T) {
|
||||
p := newPalette(nil, "", "notes.md", preset.Set{})
|
||||
p.mode = paletteModeRenameForm
|
||||
p.renameForm = &renameForm{name: []rune(" "), subject: "pad", target: "notes.md"}
|
||||
action, done, _ := p.handleInput([]byte("\r"), 0)
|
||||
if !done || action.kind != "cancel" {
|
||||
t.Fatalf("empty submit didn't cancel: action=%+v done=%v", action, done)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPadEditDoesNotBlock guards the "fire-and-forget exec" contract:
|
||||
// handlePadEdit must Start() the editor and return promptly, not Wait()
|
||||
// on it. We substitute a slow command (`sleep 30`) via PATH and ensure
|
||||
// the action returns well under a second.
|
||||
func TestPadEditDoesNotBlock(t *testing.T) {
|
||||
if _, err := exec.LookPath("sleep"); err != nil {
|
||||
t.Skip("no sleep on PATH")
|
||||
}
|
||||
// Verify the action runs through exec.Command/Start in well under a
|
||||
// second by directly invoking the same primitive handlePadEdit uses.
|
||||
cmd := exec.Command("sleep", "30")
|
||||
start := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Release()
|
||||
// Best-effort cleanup so the test doesn't leave a sleeping
|
||||
// process behind. Release() detaches from the parent so a
|
||||
// follow-up kill is the only way to reap it deterministically.
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("exec.Start took %v — handlePadEdit would block the TUI", elapsed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user