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:
10
CHANGELOG.md
10
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
|
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
|
||||||
|
|||||||
@@ -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
123
internal/app/marquee.go
Normal 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{}
|
||||||
|
}
|
||||||
161
internal/app/marquee_test.go
Normal file
161
internal/app/marquee_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user