Compare commits
2 Commits
1fb919c22a
...
24c8183832
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c8183832 | |||
| b5dfaf39c4 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -7,12 +7,28 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Typing into a focused child while its emulator viewport is
|
||||
scrolled up into scrollback history now auto-snaps the viewport
|
||||
back to the live area. Previously the keystroke reached the
|
||||
child PTY but the input box was off-screen below the visible
|
||||
region, so it looked like typing did nothing. Wheel scrolling
|
||||
and Ctrl-B are unchanged; only forwarded keystrokes snap.
|
||||
- Top tab bar now keeps the top-level agent's tab highlighted
|
||||
when focus is on one of its sub-agents (or on a Processes pane
|
||||
entry, matching the existing agent-tree behavior). Previously
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
@@ -1291,6 +1323,15 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
}
|
||||
|
||||
forward := make([]byte, 0, len(chunk))
|
||||
|
||||
var pendingAction *paletteAction
|
||||
var pendingNav navEntry
|
||||
var pendingRestartID string
|
||||
var pendingViewportDelta int
|
||||
var pendingViewportBottom bool
|
||||
var pendingPadStep int
|
||||
var pendingPadExit bool
|
||||
|
||||
flushForward := func() {
|
||||
if len(forward) == 0 {
|
||||
return
|
||||
@@ -1305,19 +1346,16 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
if prev != OwnerUser {
|
||||
go st.drawStatusLine()
|
||||
}
|
||||
// Auto-snap the emulator viewport to the live area
|
||||
// on any forwarded keystroke. Without this, typing
|
||||
// while scrolled into history leaves the cursor /
|
||||
// echoed bytes off-screen below the visible region.
|
||||
pendingViewportBottom = true
|
||||
}
|
||||
}
|
||||
forward = forward[:0]
|
||||
}
|
||||
|
||||
var pendingAction *paletteAction
|
||||
var pendingNav navEntry
|
||||
var pendingRestartID string
|
||||
var pendingViewportDelta int
|
||||
var pendingViewportBottom bool
|
||||
var pendingPadStep int
|
||||
var pendingPadExit bool
|
||||
|
||||
// childOnPrimary captures whether the focused child is on its primary
|
||||
// screen at the start of this chunk. Wheel events on the primary
|
||||
// screen scroll the emulator viewport (inline scrollback); on the
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user