From b5dfaf39c49f2bfb597544f2c8c6c44ed9b6cc1c Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Fri, 15 May 2026 15:33:39 +0100 Subject: [PATCH] Marquee long sidebar names; truncate with ellipsis otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 10 ++ internal/app/app.go | 32 +++++++ internal/app/marquee.go | 123 +++++++++++++++++++++++++ internal/app/marquee_test.go | 161 +++++++++++++++++++++++++++++++++ internal/app/sidebar.go | 171 +++++++++++++++++++++++++++++++---- 5 files changed, 481 insertions(+), 16 deletions(-) create mode 100644 internal/app/marquee.go create mode 100644 internal/app/marquee_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c19c4..d3e1e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 ### Added diff --git a/internal/app/app.go b/internal/app/app.go index 68dbc06..3aca4b7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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). wg.Add(1) sigCh := make(chan os.Signal, 1) @@ -436,6 +458,11 @@ type uiState struct { sidebarDirty atomic.Bool 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 // and palette/sidebar nav helpers read it on every chunk-driven // repaint; the cache invalidates in scratchpadsChanged() which is @@ -476,6 +503,7 @@ func (st *uiState) focusProcess(processID string) { if c == nil { return } + st.marquee.reset() layout := st.layoutSnapshot() onAlt := childIsOnAlt(c) st.mu.Lock() @@ -543,6 +571,7 @@ func (st *uiState) focusScratchpad(name string) { if name == "" { return } + st.marquee.reset() st.mu.Lock() if st.padOffsetName != name { st.padOffset = 0 @@ -586,6 +615,7 @@ func (st *uiState) restartFocusedCommand(processID string) { if c == nil || c.Kind != KindCommand { return } + st.marquee.reset() layout := st.layoutSnapshot() renderer := newViewportRenderer(layout) st.mu.Lock() @@ -672,6 +702,7 @@ func (st *uiState) scratchpadsChanged() { // OnChildSpawned auto-focuses the new child. func (st *uiState) OnChildSpawned(c *Child) { + st.marquee.reset() layout := st.layoutSnapshot() onAlt := childIsOnAlt(c) st.mu.Lock() @@ -733,6 +764,7 @@ func (st *uiState) OnChildStateChanged(string, IdleState) { // focused child. func (st *uiState) OnChildExited(c *Child) { st.lastExit.Store(int32(c.ExitCode())) + st.marquee.reset() layout := st.layoutSnapshot() renderEmpty := false st.mu.Lock() diff --git a/internal/app/marquee.go b/internal/app/marquee.go new file mode 100644 index 0000000..e411a02 --- /dev/null +++ b/internal/app/marquee.go @@ -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{} +} diff --git a/internal/app/marquee_test.go b/internal/app/marquee_test.go new file mode 100644 index 0000000..f163282 --- /dev/null +++ b/internal/app/marquee_test.go @@ -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) + } +} diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index 9f90692..9817f66 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -12,6 +12,128 @@ const ( 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 { @@ -73,6 +195,9 @@ func (st *uiState) drawSidebar() { if row > maxRow { return } + if visibleLen(content) > width { + content = clampVisible(content, width) + } pad := width - visibleLen(content) if pad < 0 { pad = 0 @@ -154,14 +279,19 @@ func (st *uiState) drawSidebar() { if c.AutoRestart() { marker = " " + styleDim + "⟳" + styleReset } - var line string + timer := timerIndicator(c) + var prefix, openStyle string if focused { - line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " + - styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c) + prefix = " " + styleAccent + "▎" + styleReset + " " + glyph + " " + openStyle = styleBold } 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 @@ -186,14 +316,19 @@ func (st *uiState) drawSidebar() { } focused := c.ID == focus glyph := statusGlyph(c, focused) - var line string + timer := timerIndicator(c) + var prefix, openStyle string if focused { - line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " + - styleBold + c.DisplayName() + styleReset + timerIndicator(c) + prefix = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " + openStyle = styleBold } 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 @@ -212,14 +347,18 @@ func (st *uiState) drawSidebar() { if row > maxRow { break } - var line string - if e.Name == focusPad { - line = " " + styleAccent + "▎" + styleReset + " " + - styleBold + e.Name + styleReset + focused := e.Name == focusPad + var prefix, openStyle string + if focused { + prefix = " " + styleAccent + "▎" + styleReset + " " + openStyle = styleBold } 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) } } }