Files
patterm/internal/app/tree.go
Harry Bayliss 3622c41fd0 Land staged session/MCP/chrome work + sidebar clear-J fix
This batches the in-flight [Unreleased] block from CHANGELOG.md into a
single commit. Highlights:

- Real MCP protocol layer (initialize / tools/list / tools/call) so
  vendor MCP clients can complete the handshake against the per-PID
  socket. Legacy direct-dispatch preserved for the harness.
- New mcp_injection kinds — cli_override for codex, config_env for
  opencode — joining the existing env-var and config_file paths so
  patterm can slot into more agents without touching their real
  config or auth.
- Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab
  process lists, recognised in legacy / kitty CSI u / xterm
  modifyOtherKeys encodings.
- Palette macros (sw / k / sp ) and reordering so open sessions
  surface above spawn-new entries.
- Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe
  on agent spawn, CR-terminated orchestrator injections, and split-
  Enter PTY writes so paste-detecting TUIs see Enter as a key event.

Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion
emits CSI 0 J, which the viewport renderer was forwarding verbatim —
wiping the sidebar to the right of the cursor and leaving the chrome
cache convinced nothing had changed. CSI 0 J and CSI 1 J are now
translated into per-row ECH sequences clamped to the viewport, same
as CSI 2 J and CSI K already were.

Agent guides (CLAUDE.md / AGENTS.md) now spell out the
TODO->CHANGELOG workflow so completed items land in the changelog
rather than as ticked entries left behind in TODO.
2026-05-14 19:09:35 +01:00

153 lines
3.3 KiB
Go

package app
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
}
func activeRootID(children []*Child, focusID string) string {
if focusID != "" {
for _, c := range children {
if c.ID != focusID {
continue
}
if c.ParentID == "" {
return c.ID
}
if parent := findChildInSnapshot(children, c.ParentID); parent != nil {
return parent.ID
}
return ""
}
}
for _, c := range children {
if c.ParentID == "" && c.Status() == StatusRunning {
return c.ID
}
}
return ""
}
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 session in the order
// they appear in the snapshot — the same order the tab bar uses, so
// Ctrl+A/D navigation matches what the user sees on screen.
func runningTopLevels(children []*Child) []*Child {
out := make([]*Child, 0, len(children))
for _, c := range children {
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
}
// nextChildID returns the process id `step` positions away from the
// current focus inside its tab, wrapping at both ends. Empty when
// there's only one process in the tab.
func nextChildID(children []*Child, focusID string, step int) string {
flat := currentTabFlat(children, focusID)
if len(flat) < 2 {
return ""
}
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
}