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.
393 lines
11 KiB
Go
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()
|
|
}
|