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:
2026-05-15 00:34:38 +01:00
parent 81a8ac2ba0
commit 05f92a3ed0
7 changed files with 827 additions and 23 deletions

View File

@@ -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 re-typing. `close_process` (and the palette's close action) drops
the entry, and rename / "relaunch on exit" toggles are mirrored as the entry, and rename / "relaunch on exit" toggles are mirrored as
they happen. Agents and terminals stay ephemeral by design. 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 - `patterm --version` prints the build version, git commit, and build
date (e.g. `patterm v0.0.1 (commit abc1234, built 2026-05-14)`). The 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 version string is injected by the build (`make patterm` derives it

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"strings" "strings"
"sync" "sync"
@@ -305,7 +306,8 @@ type uiState struct {
// focusedPad names the scratchpad currently rendered in the main // focusedPad names the scratchpad currently rendered in the main
// viewport. When non-empty, focusedID is "" and the host renders // viewport. When non-empty, focusedID is "" and the host renders
// pad content instead of forwarding child PTY output. Mutually // 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 focusedPad string
// padOffset is the index of the top-most rendered row in the // padOffset is the index of the top-most rendered row in the
// markdown-formatted view of focusedPad. Reset when focus moves to // markdown-formatted view of focusedPad. Reset when focus moves to
@@ -317,6 +319,7 @@ type uiState struct {
// switch resets the offset cleanly. // switch resets the offset cleanly.
padOffsetName string padOffsetName string
// activeAgentID tracks which top-level agent tab "owns" the agent // activeAgentID tracks which top-level agent tab "owns" the agent
// tree section of the sidebar. It only updates when focus lands on // 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 // 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() { 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 // Push a "no kitty flags" entry onto the host terminal's keyboard
// stack so palette input arrives in plain legacy form regardless of // stack so palette input arrives in plain legacy form regardless of
// what the focused child pushed. Codex/ratatui enables kitty mode // what the focused child pushed. Codex/ratatui enables kitty mode
@@ -1674,9 +1677,207 @@ func (st *uiState) closePalette(action paletteAction) {
case "quit": case "quit":
st.requestExit() 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 // flashError surfaces a spawn/etc. failure in the status line until the
// next attention update overwrites it. stderr is hidden under the alt // next attention update overwrites it. stderr is hidden under the alt
// screen so we can't rely on Fprintln(os.Stderr). // screen so we can't rely on Fprintln(os.Stderr).

View File

@@ -11,7 +11,12 @@ import (
// paletteAction is what the palette returns when the user picks an item. // paletteAction is what the palette returns when the user picks an item.
type paletteAction struct { type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" | // 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 kind string
// For spawn-agent / spawn-process, the preset to launch. // 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. // typed and the relaunch-on-exit flag they ticked.
command string command string
relaunch bool 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 { type paletteItem struct {
@@ -41,6 +52,7 @@ type paletteMode int
const ( const (
paletteModePicker paletteMode = iota paletteModePicker paletteMode = iota
paletteModeSpawnForm paletteModeSpawnForm
paletteModeRenameForm
) )
// spawnProcessForm is the state for the "Spawn process…" two-field // spawnProcessForm is the state for the "Spawn process…" two-field
@@ -52,19 +64,33 @@ type spawnProcessForm struct {
field int // 0 = command, 1 = relaunch checkbox 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 // paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus. // single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct { type paletteState struct {
query []rune query []rune
cursor int cursor int
children []*Child children []*Child
focused string focused string
presets preset.Set focusedPad string
presets preset.Set
items []paletteItem items []paletteItem
mode paletteMode mode paletteMode
form *spawnProcessForm form *spawnProcessForm
renameForm *renameForm
} }
// macroPrefixes maps the palette macro prefix (without trailing space) // macroPrefixes maps the palette macro prefix (without trailing space)
@@ -90,8 +116,20 @@ func detectMacro(q string) (macro, rest string) {
return "", q return "", q
} }
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState { func findChildByID(children []*Child, id string) *Child {
p := &paletteState{children: children, focused: focused, presets: presets} 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() p.rebuild()
return p return p
} }
@@ -135,6 +173,68 @@ func (p *paletteState) rebuild() {
func (p *paletteState) allItems() []paletteItem { func (p *paletteState) allItems() []paletteItem {
var out []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 // Switch entries first — existing open agents/processes should
// surface above options to spawn new ones. Hide non-running agents // surface above options to spawn new ones. Hide non-running agents
// (e.g. killed ones) so the list doesn't accumulate corpses. Command // (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 { if p.mode == paletteModeSpawnForm {
return p.handleFormInput(chunk, i) return p.handleFormInput(chunk, i)
} }
if p.mode == paletteModeRenameForm {
return p.handleRenameInput(chunk, i)
}
b := chunk[i] b := chunk[i]
if b == 0x1b { if b == 0x1b {
if n := csiLen(chunk, i); n > 0 { 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 // acceptOrEnterForm wraps accept(): if the chosen item opens the
// spawn-process form, transition into form mode instead of returning // spawn-process form or one of the rename forms, transition into form
// done=true. The advance count is what the caller already consumed for // mode instead of returning done=true. The advance count is what the
// the Enter keystroke. // caller already consumed for the Enter keystroke.
func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
a := p.accept() a := p.accept()
if a.kind == "spawn-process-form" { switch a.kind {
case "spawn-process-form":
p.mode = paletteModeSpawnForm p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{} p.form = &spawnProcessForm{}
return paletteAction{}, false, adv 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 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) { func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final { switch final {
case 'A': case 'A':
@@ -445,6 +575,92 @@ func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteA
return paletteAction{}, false, n 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() { func (p *paletteState) cycleFormField() {
p.form.field++ p.form.field++
if p.form.field > 1 { if p.form.field > 1 {
@@ -512,6 +728,10 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
p.renderForm(out, cols, rows) p.renderForm(out, cols, rows)
return return
} }
if p.mode == paletteModeRenameForm {
p.renderRename(out, cols, rows)
return
}
if cols < 32 { if cols < 32 {
cols = 32 cols = 32
} }
@@ -794,6 +1014,96 @@ func (p *paletteState) renderForm(out writeFlusher, cols, rows int) {
_ = out.Flush() _ = 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 { func clipRunes(s string, n int) string {
if n <= 0 { if n <= 0 {
return "" return ""

View 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)
}
}

View File

@@ -7,7 +7,7 @@ import (
) )
func newTestPalette() *paletteState { func newTestPalette() *paletteState {
return newPalette(nil, "", preset.Set{}) return newPalette(nil, "", "", preset.Set{})
} }
func TestPaletteIgnoresKittyReleaseEvent(t *testing.T) { func TestPaletteIgnoresKittyReleaseEvent(t *testing.T) {
@@ -49,7 +49,7 @@ func TestPaletteBareEscCancels(t *testing.T) {
func TestPaletteKittyArrowsNavigate(t *testing.T) { func TestPaletteKittyArrowsNavigate(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}} 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 { if p.cursor != 0 {
t.Fatalf("initial cursor %d", p.cursor) t.Fatalf("initial cursor %d", p.cursor)
} }
@@ -70,7 +70,7 @@ func TestPaletteKittyArrowsNavigate(t *testing.T) {
func TestPaletteLegacyArrowsStillWork(t *testing.T) { func TestPaletteLegacyArrowsStillWork(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} 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) _, _, adv := p.handleInput([]byte("\x1b[B"), 0)
if adv != 3 { if adv != 3 {
t.Fatalf("advance %d", adv) t.Fatalf("advance %d", adv)
@@ -82,7 +82,7 @@ func TestPaletteLegacyArrowsStillWork(t *testing.T) {
func TestPaletteKittyEnterAccepts(t *testing.T) { func TestPaletteKittyEnterAccepts(t *testing.T) {
pr := []*preset.Preset{{Name: "x"}} 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) action, done, _ := p.handleInput([]byte("\x1b[13u"), 0)
if !done || action.kind != "spawn-agent" { if !done || action.kind != "spawn-agent" {
t.Fatalf("Enter via CSI u didn't accept: action=%+v done=%v", action, done) 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 // non-empty command line emits the submit action with relaunch reflecting
// the checkbox state. // the checkbox state.
func TestPaletteSpawnProcessFormFlow(t *testing.T) { 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 // The "Spawn process…" entry is the only non-Quit item with an
// empty preset list. Locate its index by scanning items. // empty preset list. Locate its index by scanning items.
idx := -1 idx := -1
@@ -165,7 +165,7 @@ func TestPaletteSpawnProcessFormFlow(t *testing.T) {
} }
func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) { func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{}) p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{} p.form = &spawnProcessForm{}
action, done, _ := p.handleInput([]byte("\r"), 0) action, done, _ := p.handleInput([]byte("\r"), 0)
@@ -175,7 +175,7 @@ func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
} }
func TestPaletteSpawnProcessFormEscCancels(t *testing.T) { func TestPaletteSpawnProcessFormEscCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{}) p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{cmd: []rune("x")} p.form = &spawnProcessForm{cmd: []rune("x")}
action, done, _ := p.handleInput([]byte{0x1b}, 0) action, done, _ := p.handleInput([]byte{0x1b}, 0)

View File

@@ -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"
}
]
}

View File

@@ -148,6 +148,52 @@ func (s *Store) Append(name, content string) error {
return err 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) { func (s *Store) safePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
return "", errors.New("scratchpad: invalid name") return "", errors.New("scratchpad: invalid name")