448 lines
12 KiB
Go
448 lines
12 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)
|
|
}
|
|
|
|
if summary := st.activeSummaryRaw(); summary != "" && row+2 <= maxRow {
|
|
write("")
|
|
for _, line := range wrapSidebarSummary(summary, width-4) {
|
|
if row > maxRow {
|
|
break
|
|
}
|
|
write(" " + styleDim + line + styleReset)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
func wrapSidebarSummary(s string, width int) []string {
|
|
if width < 1 {
|
|
width = 1
|
|
}
|
|
words := strings.Fields(s)
|
|
if len(words) == 0 {
|
|
return nil
|
|
}
|
|
var out []string
|
|
var cur string
|
|
for _, word := range words {
|
|
if visibleLen(word) > width {
|
|
if cur != "" {
|
|
out = append(out, cur)
|
|
cur = ""
|
|
}
|
|
for visibleLen(word) > width {
|
|
out = append(out, clipRunes(word, width))
|
|
word = string([]rune(word)[width:])
|
|
}
|
|
if word != "" {
|
|
cur = word
|
|
}
|
|
continue
|
|
}
|
|
if cur == "" {
|
|
cur = word
|
|
continue
|
|
}
|
|
if visibleLen(cur)+1+visibleLen(word) <= width {
|
|
cur += " " + word
|
|
continue
|
|
}
|
|
out = append(out, cur)
|
|
cur = word
|
|
}
|
|
if cur != "" {
|
|
out = append(out, cur)
|
|
}
|
|
if len(out) > 3 {
|
|
out = out[:3]
|
|
}
|
|
return out
|
|
}
|