Compare commits
3 Commits
v0.0.2
...
24c8183832
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c8183832 | |||
| b5dfaf39c4 | |||
| 1fb919c22a |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -6,6 +6,29 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [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()
|
||||||
@@ -1291,6 +1323,15 @@ func (st *uiState) processStdin(chunk []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
forward := make([]byte, 0, len(chunk))
|
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() {
|
flushForward := func() {
|
||||||
if len(forward) == 0 {
|
if len(forward) == 0 {
|
||||||
return
|
return
|
||||||
@@ -1305,19 +1346,16 @@ func (st *uiState) processStdin(chunk []byte) {
|
|||||||
if prev != OwnerUser {
|
if prev != OwnerUser {
|
||||||
go st.drawStatusLine()
|
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]
|
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
|
// childOnPrimary captures whether the focused child is on its primary
|
||||||
// screen at the start of this chunk. Wheel events on the primary
|
// screen at the start of this chunk. Wheel events on the primary
|
||||||
// screen scroll the emulator viewport (inline scrollback); on the
|
// 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
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ func (st *uiState) drawTabBar() {
|
|||||||
}
|
}
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
palOpen := st.palette != nil
|
palOpen := st.palette != nil
|
||||||
focus := st.focusedID
|
// Highlight the top-level agent tab even when focus has stepped
|
||||||
|
// into a sub-agent (or a Processes pane entry). activeAgentID walks
|
||||||
|
// the parent chain to the root, so the user always sees which tab
|
||||||
|
// their current thread belongs to.
|
||||||
|
focus := st.activeAgentID
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
if palOpen {
|
if palOpen {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user