2 Commits

Author SHA1 Message Date
24c8183832 Auto-snap child viewport to bottom when typing into scrollback
Typing into a focused child while its emulator viewport was
scrolled up left the keystroke heading to the PTY but the input
box invisible below the visible region — it looked like typing
did nothing. processStdin's flushForward now sets
pendingViewportBottom whenever bytes are actually injected, so
the existing post-loop handler snaps the viewport and repaints.

Wheel events and Ctrl-B paths are untouched: both are intercepted
before reaching forward, so wheel still scrolls into history and
Ctrl-B is still the explicit escape hatch. Only bytes that would
actually reach the child PTY trigger the snap.
2026-05-15 15:34:00 +01:00
b5dfaf39c4 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.
2026-05-15 15:33:39 +01:00
5 changed files with 501 additions and 24 deletions

View File

@@ -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

View File

@@ -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
View 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{}
}

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

View File

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