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) } } }