Files
patterm/internal/app/sidebar.go
Harry Bayliss 52e06c914e
Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s
Release v0.0.1
Bundles the in-flight work into the first tagged release. See
CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list.
Highlights:

- Sidebar / chrome stability: clamp absolute cursor positioning and
  printable bytes to the viewport so long-running TUIs (claude, codex)
  can't spray into the right rail; bound tab bar's row clear to the
  viewport width so the rail isn't wiped on every tab redraw; flag
  scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K`
  to viewport columns.
- Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill
  entries mark the focused tab, dead agents drop out of the switch
  list.
- Sidebar: split into Processes (session-wide) + Agent Tree
  (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the
  combined list, Ctrl+A/D steps tabs.
- MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`,
  `ping`), `mcp_injection.kind = cli_override / config_env` so codex
  and opencode pick up the server with no file writes, `lifecycle`
  help topic and tool-description cleanup-duty pointers.
- Lifecycle: orchestrator-spawned children cascade-killed when the
  parent dies; orchestrator-injected prompts end with CR + delayed
  Enter so claude submits cleanly.
2026-05-14 22:04:32 +01:00

204 lines
5.2 KiB
Go

package app
import (
"fmt"
"os"
"strings"
)
const (
sidebarCols = 28
statusRows = 1
)
// drawSidebar paints the right-rail session tree + scratchpad list.
// SPEC §4: the rail is the active session's child hierarchy on top and
// the scratchpad list (with preview) on the bottom.
//
// Implementation note: the PTY child's winsize is constrained to the
// computed main viewport, so the sidebar region is outside the child's
// cursor range. We can redraw freely without fighting the child for cells.
func (st *uiState) drawSidebar() {
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
activeAgent := st.activeAgentID
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
if !layout.sidebarVisible || layout.hostRows < 4 {
return
}
left := int(layout.sidebarLeft)
width := int(layout.sidebarWidth) - 1
maxRow := int(layout.statusRow) - statusRows
var b strings.Builder
for r := 1; r <= maxRow; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH%s│%s", r, left-1, styleBorder, styleReset)
}
row := 1
// write paints one styled line into the sidebar column band and pads
// it out to `width` cells. Content may carry inline SGR escapes —
// visibleLen ignores them when computing padding.
write := func(content string) {
if row > maxRow {
return
}
pad := width - visibleLen(content)
if pad < 0 {
pad = 0
}
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s%s\x1b[K",
row, left, content, strings.Repeat(" ", pad), styleReset)
row++
}
writeHeader := func(text string) {
write(" " + styleActive + text + styleReset)
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
}
statusGlyph := func(c *Child, focused bool) string {
if c.Status() != StatusRunning {
return styleDim + "○" + styleReset
}
if focused {
return styleAccent + "●" + styleReset
}
return styleHint + "●" + styleReset
}
// Processes section — top-level command/terminal processes,
// session-wide (does not change when the user switches agent tabs).
writeHeader("Processes")
procs := processList(st.sess.Children())
if len(procs) == 0 {
write(" " + styleDim + "(none)" + styleReset)
}
for _, c := range procs {
if row > maxRow {
break
}
focused := c.ID == focus
glyph := statusGlyph(c, focused)
marker := ""
if c.AutoRestart() {
marker = " " + styleDim + "⟳" + styleReset
}
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
styleBold + c.DisplayName() + styleReset + marker
} else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
}
write(line)
}
// Agent Tree section — formerly "Session tree". Shows the active
// agent tab's root plus its sub-agents. The active agent is pinned
// by activeAgentID, so the tree keeps showing the right tab even
// when focus moves into the Processes section above.
if row+2 <= maxRow {
write("")
}
writeHeader("Agent Tree")
agents := visibleAgentTree(st.sess.Children(), activeAgent)
if len(agents) == 0 {
write(" " + styleDim + "(empty)" + styleReset)
}
for _, c := range agents {
if row > maxRow {
break
}
indent := ""
if c.ParentID != "" {
indent = " "
}
focused := c.ID == focus
glyph := statusGlyph(c, focused)
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
styleBold + c.DisplayName() + styleReset
} else {
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset
}
write(line)
}
// Scratchpads list — pick the most-recently-modified one as the
// preview target. SPEC §4.
var previewName string
if row+2 <= maxRow {
write("")
writeHeader("Scratchpads")
entries, err := st.pads.List()
if err == nil {
if len(entries) == 0 {
write(" " + styleDim + "(none)" + styleReset)
} else {
var newestTS string
for _, e := range entries {
if e.ModifiedAt > newestTS {
newestTS = e.ModifiedAt
previewName = e.Name
}
}
for _, e := range entries {
if row > maxRow {
break
}
var line string
if e.Name == previewName {
line = " " + styleAccent + "▎" + styleReset + " " +
styleBold + e.Name + styleReset
} else {
line = " " + styleHint + e.Name + styleReset
}
write(line)
}
}
}
}
// Preview pane: dim file content under a thin divider.
if previewName != "" && row+2 <= maxRow {
write("")
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
write(" " + styleActive + previewName + styleReset)
content, _, err := st.pads.Read(previewName)
if err == nil {
for _, line := range strings.Split(content, "\n") {
if row > maxRow {
break
}
write(" " + styleDim + line + styleReset)
}
}
}
// Blank-fill any rows the rail content didn't cover so stale
// content from a previous redraw doesn't linger.
for row <= maxRow {
write("")
}
frame := b.String()
st.chromeCacheMu.Lock()
if frame == st.sidebarCache {
st.chromeCacheMu.Unlock()
return
}
st.sidebarCache = frame
st.chromeCacheMu.Unlock()
st.outMu.Lock()
// Save cursor; emit the sidebar; restore.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
st.outMu.Unlock()
}