Files
patterm/internal/app/sidebar.go
Harry Bayliss b5dfaf39c4 Marquee long sidebar names; truncate with ellipsis otherwise
Sidebar rows that overflow the rail width used to spill characters
into the main viewport. They now truncate with a trailing "…"
when unfocused (or when the focused name still fits). The focused
row whose name overflows runs a pause-scroll-pause marquee: 1 s
hold on the head, ~150 ms per cell scroll, 1 s hold on the tail,
snap back. The row's geometry never moves while it animates, so
nothing below shifts.

A dedicated 150 ms goroutine flips sidebarDirty only while a row
is actively animating; the chrome ticker does the actual repaint.
Idle is a single cheap wakeup. focus / spawn / exit / restart all
reset the marquee state so the new focused row starts from frame
zero. When the row's budget is tight, the trailing timer
indicator drops before the name ellipses since the name is the
only identifier the row carries.

clampVisible() is a defensive net inside write(): even if a row's
decoration size were mis-computed, it will not spill past the
sidebar band into the PTY area.
2026-05-15 15:33:39 +01:00

393 lines
11 KiB
Go

package app
import (
"fmt"
"os"
"strings"
"time"
)
const (
sidebarCols = 28
statusRows = 1
)
// fitName returns name truncated to fit budget visible cells, with a
// trailing "…" when it overflows. Operates on RAW (unstyled) input;
// the caller wraps the result in SGR. Returns "" when budget <= 0.
func fitName(name string, budget int) string {
if budget <= 0 {
return ""
}
runes := []rune(name)
if len(runes) <= budget {
return name
}
if budget == 1 {
return "…"
}
return string(runes[:budget-1]) + "…"
}
// marqueeWindow returns the window of name starting at offset, exactly
// budget cells wide. Pre: caller has decided the name overflows budget
// and offset is in [0, len([]rune(name))-budget]. Operates on RAW
// (unstyled) input.
func marqueeWindow(name string, budget, offset int) string {
if budget <= 0 {
return ""
}
runes := []rune(name)
if len(runes) <= budget {
return name
}
if offset < 0 {
offset = 0
}
end := offset + budget
if end > len(runes) {
end = len(runes)
offset = end - budget
if offset < 0 {
offset = 0
}
}
return string(runes[offset:end])
}
// clampVisible truncates s so that its visible (non-SGR) length is at
// most width cells, preserving any active style by appending a reset.
// Used as a defensive net by write() so a row whose decoration was
// mis-sized still cannot spill past the sidebar band into the PTY area.
func clampVisible(s string, width int) string {
if width <= 0 {
return ""
}
if visibleLen(s) <= width {
return s
}
var b strings.Builder
b.Grow(len(s))
visible := 0
inEsc := false
for _, r := range s {
if inEsc {
b.WriteRune(r)
if r == 'm' || r == 'H' {
inEsc = false
}
continue
}
if r == 0x1b {
inEsc = true
b.WriteRune(r)
continue
}
if visible >= width {
break
}
b.WriteRune(r)
visible++
}
b.WriteString(styleReset)
return b.String()
}
// chooseSidebarSuffix decides whether to keep or drop the trailing
// timer indicator from a sidebar row's suffix. When the row's name
// would have to ellipsise with the timer present, but the budget
// freed by dropping the timer still leaves at least 6 cells for the
// name, the timer is dropped. The name is the only identifier the
// user has for that row; the timer is recoverable from the status
// line and palette.
func chooseSidebarSuffix(nameRuneLen, width int, prefix, suffix, timer string) (string, int) {
prefixCost := visibleLen(prefix)
budget := width - prefixCost - visibleLen(suffix)
if nameRuneLen <= budget || timer == "" {
return suffix, budget
}
slim := strings.TrimSuffix(suffix, timer)
if slim == suffix {
return suffix, budget
}
slimBudget := width - prefixCost - visibleLen(slim)
if slimBudget >= 6 {
return slim, slimBudget
}
return suffix, budget
}
// rowNameSlot returns the unstyled name cell for a sidebar row.
// Unfocused (or focused-and-fitting) rows get fitName with a trailing
// "…" on overflow. The focused row, when its name overflows the
// budget, gets the current marquee window — exactly budget cells
// wide so the surrounding row geometry stays put while it animates.
func (st *uiState) rowNameSlot(id, rawName string, budget int, focused bool) string {
if budget <= 0 {
return ""
}
runes := []rune(rawName)
if !focused || len(runes) <= budget {
return fitName(rawName, budget)
}
off, _, _ := st.marquee.step(id, len(runes), budget, time.Now())
return marqueeWindow(rawName, budget, off)
}
// formatShortDuration renders a duration as a short, sidebar-friendly
// suffix: ms under 1s, "12s" under 60s, "3m" otherwise.
func formatShortDuration(d time.Duration) string {
if d <= 0 {
return "0s"
}
if d < time.Second {
return fmt.Sprintf("%dms", int(d/time.Millisecond))
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d/time.Second))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d/time.Minute))
}
return fmt.Sprintf("%dh", int(d/time.Hour))
}
// 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() {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
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
}
if visibleLen(content) > width {
content = clampVisible(content, width)
}
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)
}
// timerIndicator returns a short " ⏱ 12s" or " ⏸ paused" suffix
// when c has a pending or paused timer attached (owns or watches).
// Empty string when no timer is in play.
timerIndicator := func(c *Child) string {
if st.timers == nil {
return ""
}
info := st.timers.activeForChild(c.ID)
if info == nil {
return ""
}
if info.Status == timerStatusPaused {
return " " + styleDim + "⏸" + styleReset
}
remaining := ""
if info.FiresAtUnixMS > 0 {
d := time.Until(time.UnixMilli(info.FiresAtUnixMS))
if d < 0 {
d = 0
}
remaining = formatShortDuration(d)
}
return " " + styleDim + "⏱" + styleReset + " " + styleHint + remaining + styleReset
}
statusGlyph := func(c *Child, focused bool) string {
if c.Status() != StatusRunning {
return styleDim + "○" + styleReset
}
// Idle-detection states paint over the plain running glyph so
// the rail communicates "running but waiting on you" vs "running
// and busy" at a glance. Focused entries always use the accent
// colour so the user's selection stays visible.
style := styleHint
if focused {
style = styleAccent
}
switch c.IdleState() {
case StateError:
return styleError + "✕" + styleReset
case StatePermission:
return styleAccent + "?" + styleReset
case StateThinking:
return style + "◐" + styleReset
case StateIdle:
return style + "○" + styleReset
case StateWorking:
return style + "●" + styleReset
default:
return style + "●" + 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
}
timer := timerIndicator(c)
var prefix, openStyle string
if focused {
prefix = " " + styleAccent + "▎" + styleReset + " " + glyph + " "
openStyle = styleBold
} else {
prefix = " " + glyph + " "
openStyle = styleHint
}
raw := c.DisplayName()
suffix, budget := chooseSidebarSuffix(len([]rune(raw)), width, prefix, marker+timer, timer)
nameCell := st.rowNameSlot(c.ID, raw, budget, focused)
write(prefix + openStyle + nameCell + styleReset + suffix)
}
// 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)
timer := timerIndicator(c)
var prefix, openStyle string
if focused {
prefix = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " "
openStyle = styleBold
} else {
prefix = " " + indent + glyph + " "
openStyle = styleHint
}
raw := c.DisplayName()
suffix, budget := chooseSidebarSuffix(len([]rune(raw)), width, prefix, timer, timer)
nameCell := st.rowNameSlot(c.ID, raw, budget, focused)
write(prefix + openStyle + nameCell + styleReset + suffix)
}
// 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
}
focused := e.Name == focusPad
var prefix, openStyle string
if focused {
prefix = " " + styleAccent + "▎" + styleReset + " "
openStyle = styleBold
} else {
prefix = " "
openStyle = styleHint
}
budget := width - visibleLen(prefix)
nameCell := st.rowNameSlot("pad:"+e.Name, e.Name, budget, focused)
write(prefix + openStyle + nameCell + 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()
if st.metrics != nil {
st.metrics.recordSidebar(time.Since(entry), true)
}
return
}
st.sidebarCache = frame
st.chromeCacheMu.Unlock()
if st.metrics != nil {
defer func() { st.metrics.recordSidebar(time.Since(entry), false) }()
}
st.outMu.Lock()
// Save cursor; emit the sidebar; restore.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
st.outMu.Unlock()
}