From 05f92a3ed0dde5bb7cb5c75693761014f400d818 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Fri, 15 May 2026 00:34:38 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 8 + internal/app/app.go | 205 ++++++++++- internal/app/palette.go | 338 +++++++++++++++++- internal/app/palette_context_test.go | 208 +++++++++++ internal/app/palette_input_test.go | 14 +- .../scenarios/rename_process_via_palette.json | 31 ++ internal/scratchpad/scratchpad.go | 46 +++ 7 files changed, 827 insertions(+), 23 deletions(-) create mode 100644 internal/app/palette_context_test.go create mode 100644 internal/harness/scenarios/rename_process_via_palette.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4877272..e1d556f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). re-typing. `close_process` (and the palette's close action) drops the entry, and rename / "relaunch on exit" toggles are mirrored as they happen. Agents and terminals stay ephemeral by design. +- The command palette (Ctrl-K) now surfaces context-aware actions at + the top of the list, based on what's currently focused: + - Scratchpad in focus: `Delete`, `Rename` (inline form), and `Edit` + (fire-and-forget launch of `zed` against the pad file). + - Agent in focus: `Rename agent` (inline form) and `Close agent`. + - Process in focus: `Rename process`, `Delete process` (drops the + entry; SIGKILLs if alive), `Stop process` (SIGTERM, keep entry), + and `Restart process` (same argv). - `patterm --version` prints the build version, git commit, and build date (e.g. `patterm v0.0.1 (commit abc1234, built 2026-05-14)`). The version string is injected by the build (`make patterm` derives it diff --git a/internal/app/app.go b/internal/app/app.go index 4b70acf..8bf515d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "os/exec" "os/signal" "strings" "sync" @@ -305,7 +306,8 @@ type uiState struct { // focusedPad names the scratchpad currently rendered in the main // viewport. When non-empty, focusedID is "" and the host renders // pad content instead of forwarding child PTY output. Mutually - // exclusive with focusedID. + // exclusive with focusedID. The palette also reads this to surface + // scratchpad-specific actions at the top of the command list. focusedPad string // padOffset is the index of the top-most rendered row in the // markdown-formatted view of focusedPad. Reset when focus moves to @@ -317,6 +319,7 @@ type uiState struct { // switch resets the offset cleanly. padOffsetName string + // activeAgentID tracks which top-level agent tab "owns" the agent // tree section of the sidebar. It only updates when focus lands on // an agent (or one of its sub-agents), so the agent tree stays @@ -1529,7 +1532,7 @@ func (st *uiState) scrollFocusedViewportToBottom() { } func (st *uiState) openPaletteLocked() { - st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets) + st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets) // Push a "no kitty flags" entry onto the host terminal's keyboard // stack so palette input arrives in plain legacy form regardless of // what the focused child pushed. Codex/ratatui enables kitty mode @@ -1674,9 +1677,207 @@ func (st *uiState) closePalette(action paletteAction) { case "quit": st.requestExit() + + case "pad-delete": + st.handlePadDelete(action.padName) + + case "pad-rename-submit": + st.handlePadRename(action.padName, action.newName) + + case "pad-edit": + st.handlePadEdit(action.padName) + + case "agent-rename-submit", "proc-rename-submit": + st.handleChildRename(action.childID, action.newName) + + case "agent-close", "proc-delete": + st.handleChildClose(action.childID, action.kind == "proc-delete") + + case "proc-stop": + st.handleProcStop(action.childID) + + case "proc-restart": + st.handleProcRestart(action.childID) } } +func (st *uiState) handlePadDelete(name string) { + if name == "" || st.pads == nil { + st.repaintFocused() + return + } + if err := st.pads.Delete(name); err != nil { + st.flashError(fmt.Sprintf("delete %s: %v", name, err)) + return + } + st.mu.Lock() + if st.focusedPad == name { + st.focusedPad = "" + } + st.mu.Unlock() + st.scratchpadsChanged() + st.repaintFocused() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + +func (st *uiState) handlePadRename(oldName, newName string) { + if oldName == "" || newName == "" || st.pads == nil { + st.repaintFocused() + return + } + if oldName == newName { + st.repaintFocused() + return + } + if err := st.pads.Rename(oldName, newName); err != nil { + st.flashError(fmt.Sprintf("rename %s: %v", oldName, err)) + return + } + st.mu.Lock() + if st.focusedPad == oldName { + st.focusedPad = newName + } + st.mu.Unlock() + st.scratchpadsChanged() + st.repaintFocused() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + +// handlePadEdit launches an external editor (zed) on the focused +// scratchpad file. Fire-and-forget: we Start() the editor with +// stdin/stdout/stderr redirected to /dev/null and call Process.Release() +// so the patterm process doesn't accumulate zombies. The editor opens +// in its own window without suspending the TUI. +func (st *uiState) handlePadEdit(name string) { + if name == "" || st.pads == nil { + st.repaintFocused() + return + } + path, err := st.pads.Path(name) + if err != nil { + st.flashError(fmt.Sprintf("edit %s: %v", name, err)) + return + } + null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + st.flashError(fmt.Sprintf("edit %s: open /dev/null: %v", name, err)) + return + } + cmd := exec.Command("zed", path) + cmd.Stdin = null + cmd.Stdout = null + cmd.Stderr = null + if err := cmd.Start(); err != nil { + _ = null.Close() + st.flashError(fmt.Sprintf("edit %s: %v", name, err)) + return + } + if cmd.Process != nil { + _ = cmd.Process.Release() + } + _ = null.Close() + st.repaintFocused() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + +func (st *uiState) handleChildRename(childID, newName string) { + if childID == "" || newName == "" { + st.repaintFocused() + return + } + c := st.sess.FindChild(childID) + if c == nil { + st.repaintFocused() + return + } + c.SetName(newName) + st.mu.Lock() + if st.focusedID == childID { + st.focusedName = newName + } + st.mu.Unlock() + st.chromeCacheMu.Lock() + st.tabBarCache = "" + st.sidebarCache = "" + st.chromeCacheMu.Unlock() + st.repaintFocused() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + +// handleChildClose removes a child entry entirely. For agents this is +// equivalent to a SIGTERM kill (the entry is ephemeral and disappears +// from the session once the PTY exits). For command processes it's +// equivalent to the MCP close_process tool: SIGKILL if alive, then +// drop the entry so it stops appearing in the switch/restart lists. +func (st *uiState) handleChildClose(childID string, kill bool) { + if childID == "" { + st.repaintFocused() + return + } + c := st.sess.FindChild(childID) + if c == nil { + st.repaintFocused() + return + } + c.SetAutoRestart(false) + if kill { + _ = st.sess.Close(childID, syscall.SIGKILL) + } else { + _ = st.sess.Kill(childID, syscall.SIGTERM) + } + st.repaintFocused() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + +func (st *uiState) handleProcStop(childID string) { + if childID == "" { + st.repaintFocused() + return + } + c := st.sess.FindChild(childID) + if c == nil { + st.repaintFocused() + return + } + c.SetAutoRestart(false) + _ = st.sess.Kill(childID, syscall.SIGTERM) + st.repaintFocused() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + +func (st *uiState) handleProcRestart(childID string) { + if childID == "" { + st.repaintFocused() + return + } + c := st.sess.FindChild(childID) + if c == nil { + st.repaintFocused() + return + } + layout := st.layoutSnapshot() + if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil { + st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err)) + return + } + st.repaintFocused() + st.drawTabBar() + st.drawSidebar() + st.drawStatusLine() +} + // flashError surfaces a spawn/etc. failure in the status line until the // next attention update overwrites it. stderr is hidden under the alt // screen so we can't rely on Fprintln(os.Stderr). diff --git a/internal/app/palette.go b/internal/app/palette.go index 025c71f..a3c1311 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -11,7 +11,12 @@ import ( // paletteAction is what the palette returns when the user picks an item. type paletteAction struct { // kind: "spawn-agent" | "spawn-process" | "spawn-process-form" | - // "spawn-process-submit" | "switch" | "kill" | "quit" | "cancel" + // "spawn-process-submit" | "switch" | "kill" | "quit" | + // "cancel" | "pad-delete" | "pad-rename" | "pad-rename-form" | + // "pad-rename-submit" | "pad-edit" | "agent-rename" | + // "agent-rename-form" | "agent-rename-submit" | "agent-close" | + // "proc-rename" | "proc-rename-form" | "proc-rename-submit" | + // "proc-delete" | "proc-stop" | "proc-restart" kind string // For spawn-agent / spawn-process, the preset to launch. @@ -24,6 +29,12 @@ type paletteAction struct { // typed and the relaunch-on-exit flag they ticked. command string relaunch bool + + // For pad-* actions, the scratchpad name to operate on. + padName string + + // For *-rename-submit actions, the user-typed new name. + newName string } type paletteItem struct { @@ -41,6 +52,7 @@ type paletteMode int const ( paletteModePicker paletteMode = iota paletteModeSpawnForm + paletteModeRenameForm ) // spawnProcessForm is the state for the "Spawn process…" two-field @@ -52,19 +64,33 @@ type spawnProcessForm struct { field int // 0 = command, 1 = relaunch checkbox } +// renameForm is a one-field inline form used by the "Rename scratchpad / +// agent / process" context palette entries. The submit action kind +// determines what gets renamed; the target name (pad name or child id) +// is carried alongside so closePalette knows what to apply the new +// name to. +type renameForm struct { + name []rune + subject string // "pad" | "agent" | "proc" + target string // padName for "pad"; childID for "agent"/"proc" + title string // e.g. "Rename scratchpad: notes.md" +} + // paletteState is the in-memory model for the overlay. SPEC §4: a // single fuzzy-searchable list of commands scoped to the current focus. type paletteState struct { - query []rune - cursor int - children []*Child - focused string - presets preset.Set + query []rune + cursor int + children []*Child + focused string + focusedPad string + presets preset.Set items []paletteItem - mode paletteMode - form *spawnProcessForm + mode paletteMode + form *spawnProcessForm + renameForm *renameForm } // macroPrefixes maps the palette macro prefix (without trailing space) @@ -90,8 +116,20 @@ func detectMacro(q string) (macro, rest string) { return "", q } -func newPalette(children []*Child, focused string, presets preset.Set) *paletteState { - p := &paletteState{children: children, focused: focused, presets: presets} +func findChildByID(children []*Child, id string) *Child { + if id == "" { + return nil + } + for _, c := range children { + if c.ID == id { + return c + } + } + return nil +} + +func newPalette(children []*Child, focused, focusedPad string, presets preset.Set) *paletteState { + p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets} p.rebuild() return p } @@ -135,6 +173,68 @@ func (p *paletteState) rebuild() { func (p *paletteState) allItems() []paletteItem { var out []paletteItem + // Context-aware entries come first so the most relevant actions for + // whatever is currently focused are one or two keystrokes away. + // Order matters: a focused scratchpad shadows any focused child + // (focus owns one or the other at a time). + 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}, + }) + out = append(out, paletteItem{ + label: "Rename scratchpad: " + name, + hint: "inline rename · enter to commit", + action: paletteAction{kind: "pad-rename-form", padName: name}, + }) + out = append(out, paletteItem{ + label: "Edit scratchpad: " + name, + hint: "open in external editor (zed)", + action: paletteAction{kind: "pad-edit", padName: name}, + }) + case p.focused != "": + if c := findChildByID(p.children, p.focused); c != nil { + name := c.DisplayName() + switch c.Kind { + case KindAgent: + out = append(out, paletteItem{ + label: "Rename agent: " + name, + hint: "inline rename · enter to commit", + action: paletteAction{kind: "agent-rename-form", childID: c.ID}, + }) + out = append(out, paletteItem{ + label: "Close agent: " + name, + hint: "SIGTERM " + strings.Join(c.Argv, " "), + action: paletteAction{kind: "agent-close", childID: c.ID}, + }) + default: + out = append(out, paletteItem{ + label: "Rename process: " + name, + hint: "inline rename · enter to commit", + action: paletteAction{kind: "proc-rename-form", childID: c.ID}, + }) + out = append(out, paletteItem{ + label: "Delete process: " + name, + hint: "remove entry; SIGKILL if alive", + action: paletteAction{kind: "proc-delete", childID: c.ID}, + }) + out = append(out, paletteItem{ + label: "Stop process: " + name, + hint: "SIGTERM · keep entry for restart", + action: paletteAction{kind: "proc-stop", childID: c.ID}, + }) + out = append(out, paletteItem{ + label: "Restart process: " + name, + hint: "SIGTERM then start with same argv", + action: paletteAction{kind: "proc-restart", childID: c.ID}, + }) + } + } + } + // Switch entries first — existing open agents/processes should // surface above options to spawn new ones. Hide non-running agents // (e.g. killed ones) so the list doesn't accumulate corpses. Command @@ -283,6 +383,9 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d if p.mode == paletteModeSpawnForm { return p.handleFormInput(chunk, i) } + if p.mode == paletteModeRenameForm { + return p.handleRenameInput(chunk, i) + } b := chunk[i] if b == 0x1b { if n := csiLen(chunk, i); n > 0 { @@ -314,19 +417,46 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d } // acceptOrEnterForm wraps accept(): if the chosen item opens the -// spawn-process form, transition into form mode instead of returning -// done=true. The advance count is what the caller already consumed for -// the Enter keystroke. +// spawn-process form or one of the rename forms, transition into form +// mode instead of returning done=true. The advance count is what the +// caller already consumed for the Enter keystroke. func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { a := p.accept() - if a.kind == "spawn-process-form" { + switch a.kind { + case "spawn-process-form": p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} return paletteAction{}, false, adv + case "pad-rename-form": + p.enterRenameForm("pad", a.padName, a.padName, "Rename scratchpad: "+a.padName) + return paletteAction{}, false, adv + case "agent-rename-form", "proc-rename-form": + subject := "agent" + title := "Rename agent: " + if a.kind == "proc-rename-form" { + subject = "proc" + title = "Rename process: " + } + current := "" + if c := findChildByID(p.children, a.childID); c != nil { + current = c.DisplayName() + } + p.enterRenameForm(subject, a.childID, current, title+current) + return paletteAction{}, false, adv } return a, true, adv } +func (p *paletteState) enterRenameForm(subject, target, current, title string) { + p.mode = paletteModeRenameForm + p.renameForm = &renameForm{ + name: []rune(current), + subject: subject, + target: target, + title: title, + } +} + func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) { switch final { case 'A': @@ -445,6 +575,92 @@ func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteA return paletteAction{}, false, n } +// handleRenameInput drives the single-field rename form. Enter commits +// the typed name, Esc cancels back out of the palette entirely (same +// semantics as the spawn form so the user has one mental model). +func (p *paletteState) handleRenameInput(chunk []byte, i int) (paletteAction, bool, int) { + b := chunk[i] + if b == 0x1b { + if n := csiLen(chunk, i); n > 0 { + return p.handleRenameCSI(chunk[i+2:i+n-1], chunk[i+n-1], n) + } + return paletteAction{kind: "cancel"}, true, 1 + } + switch b { + case '\r', '\n': + return p.submitRename(), true, 1 + case 0x7f, 0x08: + p.renameBackspace() + case 0x15: // Ctrl-U + if p.renameForm != nil { + p.renameForm.name = p.renameForm.name[:0] + } + default: + if b >= 0x20 && b < 0x7f && p.renameForm != nil { + p.renameForm.name = append(p.renameForm.name, rune(b)) + } + } + return paletteAction{}, false, 1 +} + +func (p *paletteState) handleRenameCSI(params []byte, final byte, n int) (paletteAction, bool, int) { + switch final { + case 'u': + k, ok := decodeCSIu(string(params)) + if !ok || k.event != 1 { + return paletteAction{}, false, n + } + switch k.key { + case 13: + return p.submitRename(), true, n + case 27: + return paletteAction{kind: "cancel"}, true, n + case 127, 8: + p.renameBackspace() + default: + if k.mods == 5 && k.key == 'u' { + if p.renameForm != nil { + p.renameForm.name = p.renameForm.name[:0] + } + return paletteAction{}, false, n + } + if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f && p.renameForm != nil { + p.renameForm.name = append(p.renameForm.name, rune(k.key)) + } + } + } + return paletteAction{}, false, n +} + +func (p *paletteState) renameBackspace() { + if p.renameForm != nil && len(p.renameForm.name) > 0 { + p.renameForm.name = p.renameForm.name[:len(p.renameForm.name)-1] + } +} + +func (p *paletteState) submitRename() paletteAction { + if p.renameForm == nil { + return paletteAction{kind: "cancel"} + } + newName := strings.TrimSpace(string(p.renameForm.name)) + if newName == "" { + return paletteAction{kind: "cancel"} + } + var kind string + switch p.renameForm.subject { + case "pad": + kind = "pad-rename-submit" + return paletteAction{kind: kind, padName: p.renameForm.target, newName: newName} + case "agent": + kind = "agent-rename-submit" + case "proc": + kind = "proc-rename-submit" + default: + return paletteAction{kind: "cancel"} + } + return paletteAction{kind: kind, childID: p.renameForm.target, newName: newName} +} + func (p *paletteState) cycleFormField() { p.form.field++ if p.form.field > 1 { @@ -512,6 +728,10 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { p.renderForm(out, cols, rows) return } + if p.mode == paletteModeRenameForm { + p.renderRename(out, cols, rows) + return + } if cols < 32 { cols = 32 } @@ -794,6 +1014,96 @@ func (p *paletteState) renderForm(out writeFlusher, cols, rows int) { _ = out.Flush() } +// renderRename paints the single-field rename form. Layout mirrors the +// spawn form so the user keeps the same mental model. +func (p *paletteState) renderRename(out writeFlusher, cols, rows int) { + if p.renameForm == nil { + p.renameForm = &renameForm{} + } + if cols < 32 { + cols = 32 + } + if rows < 10 { + rows = 10 + } + width := cols - 8 + if width > 72 { + width = 72 + } + if width < 40 { + width = cols - 2 + } + if width < 32 { + width = 32 + } + leftPad := (cols - width) / 2 + if leftPad < 1 { + leftPad = 1 + } + content := width - 4 + + var b strings.Builder + b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J") + + row := 2 + title := p.renameForm.title + if title == "" { + title = "Rename" + } + hint := "esc cancel" + titleLen := utf8.RuneCountInString(title) + if titleLen > width-12 { + title = clipRunes(title, width-13) + "…" + titleLen = utf8.RuneCountInString(title) + } + dashes := width - 3 - titleLen - 1 - 1 - len(hint) - 3 + if dashes < 2 { + dashes = 2 + } + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + + strings.Repeat("─", dashes) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset) + row++ + + nameStr := string(p.renameForm.name) + nameLen := utf8.RuneCountInString(nameStr) + pad := content - 2 - nameLen + if pad < 0 { + pad = 0 + nameStr = clipRunes(nameStr, content-2) + nameLen = utf8.RuneCountInString(nameStr) + } + nameRow := row + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "❯" + styleReset + " " + nameStr + + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset) + row++ + + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) + row++ + + footer := "↵ commit · esc cancel · ⌃u clear" + fLen := utf8.RuneCountInString(footer) + fpad := content - fLen + if fpad < 0 { + fpad = 0 + } + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset + + strings.Repeat(" ", fpad) + " " + styleBorder + "│" + styleReset) + row++ + + moveTo(&b, row, leftPad) + b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset) + + moveTo(&b, nameRow, leftPad+4+nameLen) + b.WriteString("\x1b[?25h") + + _, _ = out.Write([]byte(b.String())) + _ = out.Flush() +} + func clipRunes(s string, n int) string { if n <= 0 { return "" diff --git a/internal/app/palette_context_test.go b/internal/app/palette_context_test.go new file mode 100644 index 0000000..bb141ed --- /dev/null +++ b/internal/app/palette_context_test.go @@ -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) + } +} diff --git a/internal/app/palette_input_test.go b/internal/app/palette_input_test.go index 8958cbf..2f379d7 100644 --- a/internal/app/palette_input_test.go +++ b/internal/app/palette_input_test.go @@ -7,7 +7,7 @@ import ( ) func newTestPalette() *paletteState { - return newPalette(nil, "", preset.Set{}) + return newPalette(nil, "", "", preset.Set{}) } func TestPaletteIgnoresKittyReleaseEvent(t *testing.T) { @@ -49,7 +49,7 @@ func TestPaletteBareEscCancels(t *testing.T) { func TestPaletteKittyArrowsNavigate(t *testing.T) { pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}} - p := newPalette(nil, "", preset.Set{Agents: pr}) + p := newPalette(nil, "", "", preset.Set{Agents: pr}) if p.cursor != 0 { t.Fatalf("initial cursor %d", p.cursor) } @@ -70,7 +70,7 @@ func TestPaletteKittyArrowsNavigate(t *testing.T) { func TestPaletteLegacyArrowsStillWork(t *testing.T) { pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} - p := newPalette(nil, "", preset.Set{Agents: pr}) + p := newPalette(nil, "", "", preset.Set{Agents: pr}) _, _, adv := p.handleInput([]byte("\x1b[B"), 0) if adv != 3 { t.Fatalf("advance %d", adv) @@ -82,7 +82,7 @@ func TestPaletteLegacyArrowsStillWork(t *testing.T) { func TestPaletteKittyEnterAccepts(t *testing.T) { pr := []*preset.Preset{{Name: "x"}} - p := newPalette(nil, "", preset.Set{Agents: pr}) + p := newPalette(nil, "", "", preset.Set{Agents: pr}) action, done, _ := p.handleInput([]byte("\x1b[13u"), 0) if !done || action.kind != "spawn-agent" { t.Fatalf("Enter via CSI u didn't accept: action=%+v done=%v", action, done) @@ -112,7 +112,7 @@ func TestPaletteLegacyPrintableTypes(t *testing.T) { // non-empty command line emits the submit action with relaunch reflecting // the checkbox state. func TestPaletteSpawnProcessFormFlow(t *testing.T) { - p := newPalette(nil, "", preset.Set{}) + p := newPalette(nil, "", "", preset.Set{}) // The "Spawn process…" entry is the only non-Quit item with an // empty preset list. Locate its index by scanning items. idx := -1 @@ -165,7 +165,7 @@ func TestPaletteSpawnProcessFormFlow(t *testing.T) { } func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) { - p := newPalette(nil, "", preset.Set{}) + p := newPalette(nil, "", "", preset.Set{}) p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} action, done, _ := p.handleInput([]byte("\r"), 0) @@ -175,7 +175,7 @@ func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) { } func TestPaletteSpawnProcessFormEscCancels(t *testing.T) { - p := newPalette(nil, "", preset.Set{}) + p := newPalette(nil, "", "", preset.Set{}) p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{cmd: []rune("x")} action, done, _ := p.handleInput([]byte{0x1b}, 0) diff --git a/internal/harness/scenarios/rename_process_via_palette.json b/internal/harness/scenarios/rename_process_via_palette.json new file mode 100644 index 0000000..f33e029 --- /dev/null +++ b/internal/harness/scenarios/rename_process_via_palette.json @@ -0,0 +1,31 @@ +{ + "name": "rename_process_via_palette", + "scripts": [ + { + "name": "renamed-loop", + "body": "#!/bin/sh\necho RENAMED READY\nsleep 5\n" + } + ], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": { "kind": "command", "argv": ["renamed-loop"], "name": "original" } + }, + { "type": "wait_text", "contains": "RENAMED READY", "timeout_ms": 5000 }, + { "type": "send_chord", "chord": "ctrl-k" }, + { "type": "send_text", "text": "Rename process" }, + { "type": "send_chord", "chord": "enter" }, + { "type": "wait_text", "contains": "Rename process", "timeout_ms": 3000 }, + { "type": "send_chord", "chord": "ctrl-u" }, + { "type": "send_text", "text": "renamed-pane" }, + { "type": "send_chord", "chord": "enter" }, + { "type": "wait_stable", "timeout_ms": 2000 }, + { + "type": "assert_mcp", + "method": "get_project_status", + "path": "processes.0.name", + "equals": "renamed-pane" + } + ] +} diff --git a/internal/scratchpad/scratchpad.go b/internal/scratchpad/scratchpad.go index 77f5564..83994af 100644 --- a/internal/scratchpad/scratchpad.go +++ b/internal/scratchpad/scratchpad.go @@ -148,6 +148,52 @@ func (s *Store) Append(name, content string) error { return err } +// Delete removes the scratchpad file. Missing files are reported as +// errors; callers that want "delete if exists" can ignore os.ErrNotExist. +func (s *Store) Delete(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + p, err := s.safePath(name) + if err != nil { + return err + } + return os.Remove(p) +} + +// Rename moves a scratchpad file to a new name within the same project +// directory. Returns os.ErrExist if newName already exists; the caller +// is expected to surface that to the user rather than clobber. +func (s *Store) Rename(oldName, newName string) error { + s.mu.Lock() + defer s.mu.Unlock() + src, err := s.safePath(oldName) + if err != nil { + return err + } + dst, err := s.safePath(newName) + if err != nil { + return err + } + if src == dst { + return nil + } + if _, err := os.Stat(dst); err == nil { + return fmt.Errorf("scratchpad: %q already exists", newName) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return os.Rename(src, dst) +} + +// Path returns the absolute path of a scratchpad file. The file does +// not need to exist; callers like "Edit scratchpad" rely on this to +// hand the path to an external editor. +func (s *Store) Path(name string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.safePath(name) +} + func (s *Store) safePath(name string) (string, error) { if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { return "", errors.New("scratchpad: invalid name")