Skip to content

Commit 355ab32

Browse files
committed
refactor(tui): simplify slack subscription handling
Tighten the bottom-slack code without changing behaviour: - Inline the maintainSlackAnimation helper into onAnimationTick (its only meaningful caller). View() now just calls Stop() when slack reaches zero, which avoids a tea.Cmd that would have been silently dropped — and removes the subtle "subscription marked active but no tick scheduled" failure mode that came with it. - Rename tickBottomSlackDecay to onAnimationTick to match Bubbletea's on<Event> naming. - Collapse the slack add/consume branches in updateScrollState to a single line each using min/max. - Drop the redundant nil-cmd check around onAnimationTick (tea.Batch already filters nils). - Tighten the surrounding comments. Also simplify the spinner-tick test that the previous commit updated: the dead linesBeforeTick capture is removed and the assertion reads top-down. Assisted-By: docker-agent
1 parent f20285c commit 355ab32

2 files changed

Lines changed: 39 additions & 67 deletions

File tree

pkg/tui/components/messages/messages.go

Lines changed: 32 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -271,15 +271,13 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
271271
}
272272
}
273273

274-
// On animation ticks, decay any leftover bottom slack so empty lines don't
275-
// persist after thinking text fades out. This must run AFTER the children
276-
// have processed the tick (so reasoning blocks have already updated their
277-
// fade state) and BEFORE tui.go's animation.HasActive() check, so the
278-
// registration reflects whether more ticks are needed.
274+
// On animation ticks, decay leftover bottom slack and keep the slack
275+
// subscription in sync so empty lines don't persist after thinking text
276+
// fades out. Must run after children update so reasoning blocks have
277+
// applied their fade state, and before tui.go's HasActive() check so the
278+
// subscription is registered when the next tick is scheduled.
279279
if _, ok := msg.(animation.TickMsg); ok {
280-
if cmd := m.tickBottomSlackDecay(); cmd != nil {
281-
cmds = append(cmds, cmd)
282-
}
280+
cmds = append(cmds, m.onAnimationTick())
283281
}
284282

285283
return m, tea.Batch(cmds...)
@@ -528,11 +526,12 @@ func (m *model) View() string {
528526
}
529527

530528
m.updateScrollState()
531-
// Best-effort: keep slack subscription in sync. The returned cmd (if any)
532-
// is dropped because View() cannot return commands. The fade-out path —
533-
// which is where this matters — handles registration in Update via
534-
// tickBottomSlackDecay before tui.go's HasActive() check.
535-
_ = m.maintainSlackAnimation()
529+
// Release the slack subscription once it's no longer needed. The reverse
530+
// (starting it) is only done from Update via onAnimationTick, where the
531+
// returned tea.Cmd can be propagated to actually schedule the next tick.
532+
if m.bottomSlack == 0 {
533+
m.slackAnimationSub.Stop()
534+
}
536535

537536
if m.totalHeight == 0 {
538537
return ""
@@ -574,11 +573,10 @@ func (m *model) View() string {
574573
return m.scrollview.ViewWithLines(visibleLines)
575574
}
576575

577-
// updateScrollState recomputes rendered content, bottom slack and scroll offset
578-
// based on the current state of the message list. It is called both from View()
579-
// (so non-tick paths see the latest state) and from Update() on animation ticks
580-
// so that the animation registration reflects whether slack > 0 before the
581-
// next tick is scheduled.
576+
// updateScrollState recomputes rendered content, bottom slack and scroll
577+
// offset from the current state of the message list. Called both from View()
578+
// and from Update() on animation ticks so that the slack subscription is
579+
// registered before tui.go schedules the next tick.
582580
func (m *model) updateScrollState() {
583581
prevTotalHeight := m.totalHeight
584582
prevScrollableHeight := m.totalHeight + m.bottomSlack
@@ -590,17 +588,11 @@ func (m *model) updateScrollState() {
590588
delta := m.totalHeight - prevTotalHeight
591589
switch {
592590
case delta < 0:
593-
m.bottomSlack += -delta
594-
// Cap slack so the viewport is never mostly empty after a large
595-
// shrinkage (e.g., several thinking-text tool calls fading out at
596-
// once). Without this, the user would see an empty screen and have
597-
// to manually scroll back to the bottom.
598-
if maxSlack := m.maxBottomSlack(); m.bottomSlack > maxSlack {
599-
m.bottomSlack = maxSlack
600-
}
591+
// Cap so the viewport is never mostly empty after a large
592+
// shrinkage (e.g., several tool calls fading out at once).
593+
m.bottomSlack = min(m.bottomSlack-delta, m.maxBottomSlack())
601594
case delta > 0 && m.bottomSlack > 0:
602-
consume := min(delta, m.bottomSlack)
603-
m.bottomSlack -= consume
595+
m.bottomSlack = max(0, m.bottomSlack-delta)
604596
}
605597
}
606598

@@ -615,38 +607,29 @@ func (m *model) updateScrollState() {
615607
}
616608
}
617609

618-
// maxBottomSlack returns the maximum number of blank lines that can be added
619-
// after content shrinks. The cap is intentionally small: enough to absorb a
620-
// typical tool fade-out (~2 lines) without making the viewport feel empty.
610+
// maxBottomSlack returns the maximum blank lines added after content shrinks.
611+
// Small enough that the viewport never feels empty, large enough to absorb a
612+
// typical tool fade-out (~2 lines) without a visible jump.
621613
func (m *model) maxBottomSlack() int {
622614
return max(1, min(5, m.height/3))
623615
}
624616

625-
// maintainSlackAnimation registers or unregisters the messages component with
626-
// the animation coordinator based on whether slack > 0. The registration keeps
627-
// animation ticks firing so tickBottomSlackDecay can gradually clear empty
628-
// lines after thinking text fades out.
629-
func (m *model) maintainSlackAnimation() tea.Cmd {
617+
// onAnimationTick refreshes scroll state, decays any leftover slack by one
618+
// line, and keeps the slack subscription alive while slack > 0 so further
619+
// ticks fire even after fade animations finish. Returns the command to
620+
// schedule the next tick when the subscription transitions to active.
621+
func (m *model) onAnimationTick() tea.Cmd {
622+
m.updateScrollState()
623+
if !m.userHasScrolled && m.bottomSlack > 0 {
624+
m.bottomSlack--
625+
}
630626
if m.bottomSlack > 0 {
631627
return m.slackAnimationSub.Start()
632628
}
633629
m.slackAnimationSub.Stop()
634630
return nil
635631
}
636632

637-
// tickBottomSlackDecay updates slack from any height changes since the previous
638-
// render and decays slack by one line per tick. It must be called from Update
639-
// on animation.TickMsg AFTER children have processed the tick, so that fade
640-
// animations have already updated their height and the registration reflects
641-
// the post-tick state before tui.go checks animation.HasActive().
642-
func (m *model) tickBottomSlackDecay() tea.Cmd {
643-
m.updateScrollState()
644-
if !m.userHasScrolled && m.bottomSlack > 0 {
645-
m.bottomSlack--
646-
}
647-
return m.maintainSlackAnimation()
648-
}
649-
650633
// SetSize sets the dimensions of the component
651634
func (m *model) SetSize(width, height int) tea.Cmd {
652635
if m.width == width && m.height == height {

pkg/tui/components/messages/messages_test.go

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -685,28 +685,17 @@ func TestRenderCacheInvalidatesOnAnimationTickWithAnimatedContent(t *testing.T)
685685
m.views = append(m.views, m.createToolCallView(toolMsg))
686686
m.renderDirty = true
687687

688-
// First render
689-
view1 := m.View()
690-
require.Contains(t, view1, "running_tool")
691-
692-
// Capture the rendered cache snapshot to detect re-render after the tick
693-
linesBeforeTick := slices.Clone(m.renderedLines)
688+
// First render populates the cache.
689+
require.Contains(t, m.View(), "running_tool")
694690
m.renderDirty = false
695691

696-
// Send animation tick - the tick path must refresh the rendered cache so
697-
// the spinner frame advances. The Update path now calls
698-
// ensureAllItemsRendered eagerly via tickBottomSlackDecay, so we verify
699-
// the cache was refreshed (not just marked dirty).
692+
// An animation tick must refresh the cache so the spinner frame advances.
693+
// onAnimationTick now re-renders eagerly inside Update, so the resulting
694+
// View() output stays consistent with the latest tick.
700695
m.Update(animation.TickMsg{Frame: 1})
701696

702-
// The cache should reflect the new spinner state. Since rendering happened
703-
// eagerly, renderDirty is back to false but renderedLines were rebuilt.
704-
assert.NotEmpty(t, m.renderedLines)
705-
_ = linesBeforeTick // structural check below
706-
707-
// A subsequent View() must produce output consistent with the latest tick.
708-
view2 := m.View()
709-
require.Contains(t, view2, "running_tool")
697+
require.NotEmpty(t, m.renderedLines)
698+
require.Contains(t, m.View(), "running_tool")
710699
}
711700

712701
func TestRenderCacheNotInvalidatedOnAnimationTickWithoutAnimatedContent(t *testing.T) {

0 commit comments

Comments
 (0)