@@ -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.
582580func (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 .
621613func (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
651634func (m * model ) SetSize (width , height int ) tea.Cmd {
652635 if m .width == width && m .height == height {
0 commit comments