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 focusPad := st.focusedPad 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 — names only. The preview pane used to live // here and clobbered the main viewport when content overflowed the // rail. Focus moves to a pad via Ctrl+W/S; the content renders in // the main viewport via repaintFocusedPad. SPEC §4. if row+2 <= maxRow { write("") writeHeader("Scratchpads") entries := st.padsList() if entries != nil { if len(entries) == 0 { write(" " + styleDim + "(none)" + styleReset) } else { for _, e := range entries { if row > maxRow { break } var line string if e.Name == focusPad { line = " " + styleAccent + "▎" + styleReset + " " + styleBold + e.Name + styleReset } else { line = " " + styleHint + e.Name + styleReset } write(line) } } } } // 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() }