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.
This commit is contained in:
2026-05-15 15:33:39 +01:00
parent 1fb919c22a
commit b5dfaf39c4
5 changed files with 481 additions and 16 deletions

View File

@@ -13,6 +13,16 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
the tab would lose its highlight as soon as you stepped into a the tab would lose its highlight as soon as you stepped into a
child agent, even though you were still within that thread. child agent, even though you were still within that thread.
### Changed
- Sidebar rows (Processes, Agent Tree, Scratchpads) now truncate
overflowing names with a trailing `…` instead of spilling into
the main viewport. The focused row marquees its name when it
overflows — 1 s hold on the head, ~150 ms per cell scroll until
the tail is visible, 1 s hold on the tail, snap back. Row
position never moves while the marquee animates. When budget is
tight, the trailing timer indicator drops before the name
ellipses, since the name is the only identifier the row carries.
## [0.0.2] - 2026-05-15 ## [0.0.2] - 2026-05-15
### Added ### Added

View File

@@ -306,6 +306,28 @@ func Run(ctx context.Context, opts Options) error {
} }
}() }()
// Marquee ticker: while a focused sidebar row's name overflows the
// rail width, advance the pause-scroll-pause animation by marking
// the sidebar dirty every marqueeStep. The chrome ticker above does
// the actual repaint. When no row is animating, this is a single
// cheap wakeup with no work.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(marqueeStep)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if st.marquee.active() {
st.markSidebarDirty()
}
}
}()
// External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit). // External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit).
wg.Add(1) wg.Add(1)
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@@ -436,6 +458,11 @@ type uiState struct {
sidebarDirty atomic.Bool sidebarDirty atomic.Bool
chromeWake chan struct{} chromeWake chan struct{}
// marquee animates the focused sidebar row's name when it overflows
// the rail width. The dedicated 150ms ticker below flips
// sidebarDirty while a row is animating; idle case is free.
marquee marqueeState
// padsCacheMu guards the cached scratchpad listing. The sidebar // padsCacheMu guards the cached scratchpad listing. The sidebar
// and palette/sidebar nav helpers read it on every chunk-driven // and palette/sidebar nav helpers read it on every chunk-driven
// repaint; the cache invalidates in scratchpadsChanged() which is // repaint; the cache invalidates in scratchpadsChanged() which is
@@ -476,6 +503,7 @@ func (st *uiState) focusProcess(processID string) {
if c == nil { if c == nil {
return return
} }
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
@@ -543,6 +571,7 @@ func (st *uiState) focusScratchpad(name string) {
if name == "" { if name == "" {
return return
} }
st.marquee.reset()
st.mu.Lock() st.mu.Lock()
if st.padOffsetName != name { if st.padOffsetName != name {
st.padOffset = 0 st.padOffset = 0
@@ -586,6 +615,7 @@ func (st *uiState) restartFocusedCommand(processID string) {
if c == nil || c.Kind != KindCommand { if c == nil || c.Kind != KindCommand {
return return
} }
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
renderer := newViewportRenderer(layout) renderer := newViewportRenderer(layout)
st.mu.Lock() st.mu.Lock()
@@ -672,6 +702,7 @@ func (st *uiState) scratchpadsChanged() {
// OnChildSpawned auto-focuses the new child. // OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) { func (st *uiState) OnChildSpawned(c *Child) {
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
@@ -733,6 +764,7 @@ func (st *uiState) OnChildStateChanged(string, IdleState) {
// focused child. // focused child.
func (st *uiState) OnChildExited(c *Child) { func (st *uiState) OnChildExited(c *Child) {
st.lastExit.Store(int32(c.ExitCode())) st.lastExit.Store(int32(c.ExitCode()))
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
renderEmpty := false renderEmpty := false
st.mu.Lock() st.mu.Lock()

123
internal/app/marquee.go Normal file
View File

@@ -0,0 +1,123 @@
package app
import (
"sync"
"time"
)
// Phase ordering of the marquee state machine: hold the head, scroll
// one cell per marqueeStep until the tail is visible, hold the tail,
// snap back to the head.
const (
phaseHoldStart = iota
phaseScroll
phaseHoldEnd
)
const (
marqueeHoldStart = time.Second
marqueeStep = 150 * time.Millisecond
marqueeHoldEnd = time.Second
)
// marqueeState drives the focused sidebar row's pause-scroll-pause
// animation. State is wall-clock anchored (since), not tick-count
// anchored, so a missed tick yields a slightly later frame rather
// than a skipped one.
type marqueeState struct {
mu sync.Mutex
id string
nameLen int
budget int
state int
offset int
since time.Time
}
// step advances the state machine for the row identified by id with
// the given visible name length (in runes) and column budget. It
// returns the current scroll offset, whether the row is animating
// (i.e. nameLen > budget), and how long until the next visual change.
//
// When id changes, or nameLen <= budget, the state machine resets to
// phaseHoldStart with offset 0 anchored at now.
func (m *marqueeState) step(id string, nameLen, budget int, now time.Time) (offset int, animating bool, nextWake time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
if id != m.id || nameLen != m.nameLen || budget != m.budget {
m.id = id
m.nameLen = nameLen
m.budget = budget
m.state = phaseHoldStart
m.offset = 0
m.since = now
}
if nameLen <= budget || budget <= 0 {
return 0, false, 0
}
maxOffset := nameLen - budget
for {
elapsed := now.Sub(m.since)
switch m.state {
case phaseHoldStart:
if elapsed < marqueeHoldStart {
return 0, true, marqueeHoldStart - elapsed
}
m.state = phaseScroll
m.since = m.since.Add(marqueeHoldStart)
continue
case phaseScroll:
steps := int(elapsed / marqueeStep)
if steps >= maxOffset {
m.offset = maxOffset
m.state = phaseHoldEnd
m.since = m.since.Add(time.Duration(maxOffset) * marqueeStep)
continue
}
m.offset = steps
rem := marqueeStep - (elapsed % marqueeStep)
return m.offset, true, rem
case phaseHoldEnd:
if elapsed < marqueeHoldEnd {
return maxOffset, true, marqueeHoldEnd - elapsed
}
m.state = phaseHoldStart
m.offset = 0
m.since = m.since.Add(marqueeHoldEnd)
continue
default:
m.state = phaseHoldStart
m.offset = 0
m.since = now
return 0, true, marqueeHoldStart
}
}
}
// active reports whether the marquee currently has an overflowing row
// to animate. The marquee ticker goroutine uses this to gate dirty
// flag flips so an idle sidebar costs nothing.
func (m *marqueeState) active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.id != "" && m.nameLen > m.budget && m.budget > 0
}
// reset clears all state, forcing the next step() call to start a
// fresh phaseHoldStart. Call this when focus changes so the newly
// focused row begins with a full head-hold instead of inheriting
// whatever phase the previous focus was in.
func (m *marqueeState) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.id = ""
m.nameLen = 0
m.budget = 0
m.state = phaseHoldStart
m.offset = 0
m.since = time.Time{}
}

View File

@@ -0,0 +1,161 @@
package app
import (
"testing"
"time"
)
func TestMarqueeStepFits(t *testing.T) {
var m marqueeState
now := time.Unix(0, 0)
off, animating, _ := m.step("a", 5, 10, now)
if animating {
t.Fatalf("expected no animation when name fits in budget")
}
if off != 0 {
t.Fatalf("expected offset 0, got %d", off)
}
}
func TestMarqueePhaseProgression(t *testing.T) {
var m marqueeState
// name 10 runes, budget 5 → maxOffset = 5.
const nameLen, budget = 10, 5
t0 := time.Unix(0, 0)
// At t0: phaseHoldStart, offset 0, animating.
off, anim, wake := m.step("row", nameLen, budget, t0)
if off != 0 || !anim || wake != marqueeHoldStart {
t.Fatalf("t0: off=%d anim=%v wake=%v", off, anim, wake)
}
// Just before hold expires: still offset 0.
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart-time.Millisecond))
if off != 0 || !anim {
t.Fatalf("pre-expiry hold: off=%d anim=%v", off, anim)
}
// At hold expiry + 1 step: should have transitioned to scroll, offset 1.
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+marqueeStep))
if !anim || off != 1 {
t.Fatalf("first scroll step: off=%d anim=%v", off, anim)
}
// Mid-scroll: offset == 3.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+3*marqueeStep))
if off != 3 {
t.Fatalf("mid scroll: off=%d", off)
}
// Tail reached: offset == maxOffset == 5.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+time.Millisecond))
if off != 5 {
t.Fatalf("tail: off=%d", off)
}
// Hold-end window still pegged at maxOffset.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd/2))
if off != 5 {
t.Fatalf("hold-end mid: off=%d", off)
}
// After hold-end: snap back to offset 0.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd+time.Millisecond))
if off != 0 {
t.Fatalf("snap back: off=%d", off)
}
}
func TestMarqueeIDChangeResets(t *testing.T) {
var m marqueeState
t0 := time.Unix(0, 0)
_, _, _ = m.step("a", 10, 5, t0)
// Advance well into scroll for row "a".
_, _, _ = m.step("a", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
// Now focus moves to "b": offset must reset to 0 and phase to hold-start.
off, anim, wake := m.step("b", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
if off != 0 || !anim || wake != marqueeHoldStart {
t.Fatalf("id reset: off=%d anim=%v wake=%v", off, anim, wake)
}
}
func TestMarqueeActive(t *testing.T) {
var m marqueeState
if m.active() {
t.Fatalf("fresh marquee should not be active")
}
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
if !m.active() {
t.Fatalf("expected active after overflow step")
}
_, _, _ = m.step("row", 4, 5, time.Unix(0, 0))
if m.active() {
t.Fatalf("should not be active when name fits")
}
}
func TestMarqueeReset(t *testing.T) {
var m marqueeState
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
m.reset()
if m.active() {
t.Fatalf("expected inactive after reset")
}
// After reset, stepping the same id starts fresh.
off, _, wake := m.step("row", 10, 5, time.Unix(5, 0))
if off != 0 || wake != marqueeHoldStart {
t.Fatalf("post-reset start: off=%d wake=%v", off, wake)
}
}
func TestFitName(t *testing.T) {
cases := []struct {
name, in string
budget int
want string
}{
{"fits", "abc", 5, "abc"},
{"exact", "abcde", 5, "abcde"},
{"truncate", "abcdef", 5, "abcd…"},
{"budget1", "abcdef", 1, "…"},
{"budget0", "abc", 0, ""},
{"unicode", "αβγδεζη", 4, "αβγ…"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := fitName(c.in, c.budget)
if got != c.want {
t.Fatalf("fitName(%q, %d) = %q want %q", c.in, c.budget, got, c.want)
}
})
}
}
func TestMarqueeWindow(t *testing.T) {
got := marqueeWindow("abcdefgh", 4, 2)
if got != "cdef" {
t.Fatalf("window = %q", got)
}
// Clamp end-of-string overflow.
got = marqueeWindow("abcdef", 4, 10)
if got != "cdef" {
t.Fatalf("clamped window = %q", got)
}
}
func TestClampVisible(t *testing.T) {
// Plain string longer than width.
if got := clampVisible("abcdef", 3); visibleLen(got) != 3 {
t.Fatalf("plain clamp visible = %d (%q)", visibleLen(got), got)
}
// Already-fitting string is unchanged.
if got := clampVisible("abc", 5); got != "abc" {
t.Fatalf("unchanged = %q", got)
}
// SGR-wrapped string: visible portion must be <= width.
in := "\x1b[1mhello\x1b[0m world"
got := clampVisible(in, 5)
if visibleLen(got) != 5 {
t.Fatalf("sgr clamp visible = %d (%q)", visibleLen(got), got)
}
}

View File

@@ -12,6 +12,128 @@ const (
statusRows = 1 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 // formatShortDuration renders a duration as a short, sidebar-friendly
// suffix: ms under 1s, "12s" under 60s, "3m" otherwise. // suffix: ms under 1s, "12s" under 60s, "3m" otherwise.
func formatShortDuration(d time.Duration) string { func formatShortDuration(d time.Duration) string {
@@ -73,6 +195,9 @@ func (st *uiState) drawSidebar() {
if row > maxRow { if row > maxRow {
return return
} }
if visibleLen(content) > width {
content = clampVisible(content, width)
}
pad := width - visibleLen(content) pad := width - visibleLen(content)
if pad < 0 { if pad < 0 {
pad = 0 pad = 0
@@ -154,14 +279,19 @@ func (st *uiState) drawSidebar() {
if c.AutoRestart() { if c.AutoRestart() {
marker = " " + styleDim + "⟳" + styleReset marker = " " + styleDim + "⟳" + styleReset
} }
var line string timer := timerIndicator(c)
var prefix, openStyle string
if focused { if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " + prefix = " " + styleAccent + "▎" + styleReset + " " + glyph + " "
styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c) openStyle = styleBold
} else { } else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + timerIndicator(c) prefix = " " + glyph + " "
openStyle = styleHint
} }
write(line) 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 Tree section — formerly "Session tree". Shows the active
@@ -186,14 +316,19 @@ func (st *uiState) drawSidebar() {
} }
focused := c.ID == focus focused := c.ID == focus
glyph := statusGlyph(c, focused) glyph := statusGlyph(c, focused)
var line string timer := timerIndicator(c)
var prefix, openStyle string
if focused { if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " + prefix = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " "
styleBold + c.DisplayName() + styleReset + timerIndicator(c) openStyle = styleBold
} else { } else {
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + timerIndicator(c) prefix = " " + indent + glyph + " "
openStyle = styleHint
} }
write(line) 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 // Scratchpads list — names only. The preview pane used to live
@@ -212,14 +347,18 @@ func (st *uiState) drawSidebar() {
if row > maxRow { if row > maxRow {
break break
} }
var line string focused := e.Name == focusPad
if e.Name == focusPad { var prefix, openStyle string
line = " " + styleAccent + "▎" + styleReset + " " + if focused {
styleBold + e.Name + styleReset prefix = " " + styleAccent + "▎" + styleReset + " "
openStyle = styleBold
} else { } else {
line = " " + styleHint + e.Name + styleReset prefix = " "
openStyle = styleHint
} }
write(line) budget := width - visibleLen(prefix)
nameCell := st.rowNameSlot("pad:"+e.Name, e.Name, budget, focused)
write(prefix + openStyle + nameCell + styleReset)
} }
} }
} }