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 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 } writeHeader("Session tree") children := visibleSessionTree(st.sess.Children(), focus) if len(children) == 0 { write(" " + styleDim + "(empty)" + styleReset) } for _, c := range children { 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.Name + styleReset } else { line = " " + indent + glyph + " " + styleHint + c.Name + 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() }