- Palette's per-child "Kill <name>" action is now labelled "Close <name>" (action kind unchanged; still SIGTERM). Matches the existing "Close agent: …" context entry and reads less violent for a graceful term. - New "New Terminal" palette entry spawns a bare interactive $SHELL pane via LaunchTerminal (kind=terminal). Replaces the default "shell" process preset that was seeded on first run. - Exited KindTerminal entries are now dropped from the session in reapChild — terminals have no restart path, so leaving them behind as greyed rows in the Processes sidebar was just clutter. processList also filters defensively.
314 lines
8.1 KiB
Go
314 lines
8.1 KiB
Go
package app
|
|
|
|
import "github.com/hjbdev/patterm/internal/scratchpad"
|
|
|
|
// navEntry is one row in the unified sidebar navigation list. Exactly
|
|
// one of childID or pad is set. childID points at a Child by ID; pad
|
|
// names a scratchpad entry. Empty zero-value means "no target".
|
|
type navEntry struct {
|
|
childID string
|
|
pad string
|
|
}
|
|
|
|
func (n navEntry) empty() bool { return n.childID == "" && n.pad == "" }
|
|
func (n navEntry) isPad() bool { return n.pad != "" }
|
|
func (n navEntry) isChild() bool { return n.childID != "" }
|
|
|
|
// visibleAgentTree returns the running entries under the active agent
|
|
// tab (root agent + its sub-agents). With the new Processes pane,
|
|
// command processes live in their own section and never show up here —
|
|
// the agent tree is for KindAgent (and KindTerminal sub-entries) only.
|
|
func visibleAgentTree(children []*Child, activeAgentID string) []*Child {
|
|
if activeAgentID == "" {
|
|
return nil
|
|
}
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.Status() != StatusRunning {
|
|
continue
|
|
}
|
|
if c.Kind == KindCommand && c.ParentID == "" {
|
|
continue
|
|
}
|
|
if c.ID == activeAgentID || c.ParentID == activeAgentID {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// visibleSessionTree is retained for the test suite and any pre-Processes
|
|
// callers — it returns the active agent's tree given the focused id,
|
|
// resolving the active agent root from focus the same way the previous
|
|
// implementation did.
|
|
func visibleSessionTree(children []*Child, focusID string) []*Child {
|
|
rootID := activeRootID(children, focusID)
|
|
if rootID == "" {
|
|
return nil
|
|
}
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.Status() != StatusRunning {
|
|
continue
|
|
}
|
|
if c.ID == rootID || c.ParentID == rootID {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// activeRootID resolves the agent root the user is "inside" right now.
|
|
// If focus is on a sub-agent, it walks up. If focus is on a top-level
|
|
// process (KindCommand), it falls through to the first running agent
|
|
// root so the agent tree section keeps showing something coherent.
|
|
func activeRootID(children []*Child, focusID string) string {
|
|
if focusID != "" {
|
|
for _, c := range children {
|
|
if c.ID != focusID {
|
|
continue
|
|
}
|
|
if c.Kind == KindCommand && c.ParentID == "" {
|
|
break
|
|
}
|
|
if c.ParentID == "" {
|
|
return c.ID
|
|
}
|
|
if parent := findChildInSnapshot(children, c.ParentID); parent != nil {
|
|
return parent.ID
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
return firstRunningAgentID(children)
|
|
}
|
|
|
|
func firstRunningAgentID(children []*Child) string {
|
|
for _, c := range children {
|
|
if c.Kind != KindAgent {
|
|
continue
|
|
}
|
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
|
return c.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// processList returns every top-level command/terminal entry in spawn
|
|
// order. Exited KindCommand entries remain visible so the user can see
|
|
// what just died and reach restart_process; exited KindTerminal entries
|
|
// are filtered out because terminals are ephemeral and have no restart
|
|
// path (Session also drops them in reapChild — this filter is defensive
|
|
// for any window between exit and deletion).
|
|
func processList(children []*Child) []*Child {
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.ParentID != "" {
|
|
continue
|
|
}
|
|
switch c.Kind {
|
|
case KindCommand:
|
|
out = append(out, c)
|
|
case KindTerminal:
|
|
if c.Status() == StatusRunning {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findChildInSnapshot(children []*Child, id string) *Child {
|
|
for _, c := range children {
|
|
if c.ID == id {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstRunningTopLevel(children []*Child) *Child {
|
|
for _, c := range children {
|
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runningTopLevels lists every running top-level agent session in the
|
|
// order they appear in the snapshot. Tabs only show agents — command
|
|
// processes live in the Processes sidebar section — so Ctrl+A/D
|
|
// navigation cycles through agent tabs exclusively.
|
|
func runningTopLevels(children []*Child) []*Child {
|
|
out := make([]*Child, 0, len(children))
|
|
for _, c := range children {
|
|
if c.Kind != KindAgent {
|
|
continue
|
|
}
|
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nextTabID returns the id of the top-level session `step` positions
|
|
// away from the current focus in the runningTopLevels list, wrapping
|
|
// at both ends. Returns "" when there's nothing to switch to.
|
|
func nextTabID(children []*Child, focusID string, step int) string {
|
|
roots := runningTopLevels(children)
|
|
if len(roots) == 0 {
|
|
return ""
|
|
}
|
|
rootID := activeRootID(children, focusID)
|
|
idx := -1
|
|
for i, r := range roots {
|
|
if r.ID == rootID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
idx = (idx + step) % len(roots)
|
|
if idx < 0 {
|
|
idx += len(roots)
|
|
}
|
|
if roots[idx].ID == focusID {
|
|
return ""
|
|
}
|
|
return roots[idx].ID
|
|
}
|
|
|
|
// currentTabFlat returns the focused tab's processes (root first, then
|
|
// its running children) in display order. Used to step focus with
|
|
// Ctrl+W/S.
|
|
func currentTabFlat(children []*Child, focusID string) []*Child {
|
|
rootID := activeRootID(children, focusID)
|
|
if rootID == "" {
|
|
return nil
|
|
}
|
|
out := make([]*Child, 0, 4)
|
|
for _, c := range children {
|
|
if c.ID == rootID && c.Status() == StatusRunning {
|
|
out = append(out, c)
|
|
break
|
|
}
|
|
}
|
|
for _, c := range children {
|
|
if c.ParentID == rootID && c.Status() == StatusRunning {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sidebarNavList combines the Processes section and the active Agent
|
|
// Tree into one flat list — top-to-bottom matching what the user sees
|
|
// in the sidebar. Ctrl+W/S walks this list so the user can step out of
|
|
// the agent tree, into the Processes section, and back.
|
|
func sidebarNavList(children []*Child, activeAgentID string) []*Child {
|
|
out := make([]*Child, 0, 8)
|
|
for _, c := range processList(children) {
|
|
out = append(out, c)
|
|
}
|
|
for _, c := range visibleAgentTree(children, activeAgentID) {
|
|
out = append(out, c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sidebarNav returns the combined Processes + Agent Tree + Scratchpads
|
|
// navigation list. Scratchpads always appear after children so the
|
|
// existing "step past the tree" expectation still holds.
|
|
func sidebarNav(children []*Child, activeAgentID string, pads []scratchpad.Entry) []navEntry {
|
|
flat := sidebarNavList(children, activeAgentID)
|
|
out := make([]navEntry, 0, len(flat)+len(pads))
|
|
for _, c := range flat {
|
|
out = append(out, navEntry{childID: c.ID})
|
|
}
|
|
for _, p := range pads {
|
|
out = append(out, navEntry{pad: p.Name})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nextNavEntry returns the entry `step` positions away from the
|
|
// current focus in the unified nav list. Either focusChildID or
|
|
// focusPad will be set (or both empty for "nothing focused yet").
|
|
// Empty when there's nothing else to land on.
|
|
func nextNavEntry(children []*Child, focusChildID, focusPad, activeAgentID string, pads []scratchpad.Entry, step int) navEntry {
|
|
flat := sidebarNav(children, activeAgentID, pads)
|
|
if len(flat) == 0 {
|
|
return navEntry{}
|
|
}
|
|
matches := func(e navEntry) bool {
|
|
if focusPad != "" && e.pad != "" {
|
|
return e.pad == focusPad
|
|
}
|
|
if focusChildID != "" && e.childID != "" {
|
|
return e.childID == focusChildID
|
|
}
|
|
return false
|
|
}
|
|
if len(flat) == 1 {
|
|
if matches(flat[0]) {
|
|
return navEntry{}
|
|
}
|
|
return flat[0]
|
|
}
|
|
idx := -1
|
|
for i, e := range flat {
|
|
if matches(e) {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
idx = (idx + step) % len(flat)
|
|
if idx < 0 {
|
|
idx += len(flat)
|
|
}
|
|
if matches(flat[idx]) {
|
|
return navEntry{}
|
|
}
|
|
return flat[idx]
|
|
}
|
|
|
|
// nextChildID is retained for tests; it ignores scratchpads.
|
|
func nextChildID(children []*Child, focusID, activeAgentID string, step int) string {
|
|
flat := sidebarNavList(children, activeAgentID)
|
|
if len(flat) == 0 {
|
|
return ""
|
|
}
|
|
if len(flat) == 1 {
|
|
if flat[0].ID == focusID {
|
|
return ""
|
|
}
|
|
return flat[0].ID
|
|
}
|
|
idx := -1
|
|
for i, c := range flat {
|
|
if c.ID == focusID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
idx = (idx + step) % len(flat)
|
|
if idx < 0 {
|
|
idx += len(flat)
|
|
}
|
|
if flat[idx].ID == focusID {
|
|
return ""
|
|
}
|
|
return flat[idx].ID
|
|
}
|