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

@@ -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).