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, regardless of running state. The Processes sidebar section // keeps showing exited entries so the user can see what just died (and // because Session retains KindCommand entries for restart). func processList(children []*Child) []*Child { out := make([]*Child, 0, len(children)) for _, c := range children { if c.ParentID != "" { continue } if c.Kind == KindCommand || c.Kind == KindTerminal { 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 }