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:
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
31
internal/harness/scenarios/rename_process_via_palette.json
Normal file
31
internal/harness/scenarios/rename_process_via_palette.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user