@@ -100,12 +100,13 @@ type model struct {
100100 height int
101101
102102 // Height tracking system fields
103- scrollOffset int // Current scroll position in lines
104- bottomSlack int // Extra blank lines added after content shrinks
105- renderedLines []string // Cached rendered content as lines (avoids split/join per frame)
106- renderedItems map [int ]renderedItem // Cache of rendered items with positions
107- totalHeight int // Total height of all content in lines
108- renderDirty bool // True when rendered content needs rebuild
103+ scrollOffset int // Current scroll position in lines
104+ bottomSlack int // Extra blank lines added after content shrinks
105+ slackAnimationSub animation.Subscription // Subscription to animation ticks while slack > 0
106+ renderedLines []string // Cached rendered content as lines (avoids split/join per frame)
107+ renderedItems map [int ]renderedItem // Cache of rendered items with positions
108+ totalHeight int // Total height of all content in lines
109+ renderDirty bool // True when rendered content needs rebuild
109110
110111 selection selectionState
111112
@@ -270,6 +271,15 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
270271 }
271272 }
272273
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.
279+ if _ , ok := msg .(animation.TickMsg ); ok {
280+ cmds = append (cmds , m .handleAnimationTick ())
281+ }
282+
273283 return m , tea .Batch (cmds ... )
274284}
275285
@@ -515,36 +525,18 @@ func (m *model) View() string {
515525 return ""
516526 }
517527
518- prevTotalHeight := m .totalHeight
519- prevScrollableHeight := m .totalHeight + m .bottomSlack
520- m .ensureAllItemsRendered ()
528+ m .updateScrollState ()
529+ // Release the slack subscription once it's no longer needed. Starting it
530+ // is only done from Update via handleAnimationTick, where the returned
531+ // tea.Cmd can be propagated to actually schedule the next tick.
532+ if m .bottomSlack == 0 {
533+ m .slackAnimationSub .Stop ()
534+ }
521535
522536 if m .totalHeight == 0 {
523537 return ""
524538 }
525539
526- if m .userHasScrolled {
527- m .bottomSlack = 0
528- } else {
529- delta := m .totalHeight - prevTotalHeight
530- if delta < 0 {
531- m .bottomSlack += - delta
532- } else if delta > 0 && m .bottomSlack > 0 {
533- consume := min (delta , m .bottomSlack )
534- m .bottomSlack -= consume
535- }
536- }
537-
538- scrollableHeight := m .totalHeight + m .bottomSlack
539- maxScrollOffset := max (0 , scrollableHeight - m .height )
540-
541- // Auto-scroll when content grows beyond any slack.
542- if ! m .userHasScrolled && scrollableHeight > prevScrollableHeight {
543- m .scrollOffset = maxScrollOffset
544- } else {
545- m .scrollOffset = max (0 , min (m .scrollOffset , maxScrollOffset ))
546- }
547-
548540 // Use cached lines directly - O(1) instead of O(totalHeight) split
549541 totalLines := len (m .renderedLines ) + m .bottomSlack
550542 if totalLines == 0 {
@@ -581,6 +573,63 @@ func (m *model) View() string {
581573 return m .scrollview .ViewWithLines (visibleLines )
582574}
583575
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.
580+ func (m * model ) updateScrollState () {
581+ prevTotalHeight := m .totalHeight
582+ prevScrollableHeight := m .totalHeight + m .bottomSlack
583+ m .ensureAllItemsRendered ()
584+
585+ if m .userHasScrolled {
586+ m .bottomSlack = 0
587+ } else {
588+ delta := m .totalHeight - prevTotalHeight
589+ switch {
590+ case delta < 0 :
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 ())
594+ case delta > 0 && m .bottomSlack > 0 :
595+ m .bottomSlack = max (0 , m .bottomSlack - delta )
596+ }
597+ }
598+
599+ scrollableHeight := m .totalHeight + m .bottomSlack
600+ maxScrollOffset := max (0 , scrollableHeight - m .height )
601+
602+ // Auto-scroll when content grows beyond any slack.
603+ if ! m .userHasScrolled && scrollableHeight > prevScrollableHeight {
604+ m .scrollOffset = maxScrollOffset
605+ } else {
606+ m .scrollOffset = max (0 , min (m .scrollOffset , maxScrollOffset ))
607+ }
608+ }
609+
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.
613+ func (m * model ) maxBottomSlack () int {
614+ return max (1 , min (5 , m .height / 3 ))
615+ }
616+
617+ // handleAnimationTick refreshes scroll state, decays any leftover slack by
618+ // one line, and keeps the slack subscription alive while slack > 0 so
619+ // further ticks fire even after fade animations finish. Returns the command
620+ // to schedule the next tick when the subscription transitions to active.
621+ func (m * model ) handleAnimationTick () tea.Cmd {
622+ m .updateScrollState ()
623+ if ! m .userHasScrolled && m .bottomSlack > 0 {
624+ m .bottomSlack --
625+ }
626+ if m .bottomSlack > 0 {
627+ return m .slackAnimationSub .Start ()
628+ }
629+ m .slackAnimationSub .Stop ()
630+ return nil
631+ }
632+
584633// SetSize sets the dimensions of the component
585634func (m * model ) SetSize (width , height int ) tea.Cmd {
586635 if m .width == width && m .height == height {
@@ -1535,7 +1584,7 @@ func (m *model) AdjustBottomSlack(delta int) {
15351584 if delta == 0 {
15361585 return
15371586 }
1538- m .bottomSlack = max (0 , m .bottomSlack + delta )
1587+ m .bottomSlack = max (0 , min ( m .bottomSlack + delta , m . maxBottomSlack ()) )
15391588}
15401589
15411590// contentWidth returns the width available for content.
0 commit comments