Skip to content

Commit 3bc200c

Browse files
authored
Merge pull request #2543 from dgageot/board/fix-tui-empty-screen-after-thinking-text-a93f3184
fix(tui): clear bottom slack after thinking text fades out
2 parents 7cdf467 + d65366a commit 3bc200c

3 files changed

Lines changed: 343 additions & 40 deletions

File tree

pkg/tui/components/messages/messages.go

Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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
585634
func (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.

pkg/tui/components/messages/messages_test.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -685,18 +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-
// Clear the dirty flag to simulate cached state
688+
// First render populates the cache.
689+
require.Contains(t, m.View(), "running_tool")
693690
m.renderDirty = false
694691

695-
// Send animation tick - should invalidate cache because we have animated content
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.
696695
m.Update(animation.TickMsg{Frame: 1})
697696

698-
// Cache should be marked dirty
699-
assert.True(t, m.renderDirty, "renderDirty should be true after animation tick with animated content")
697+
require.NotEmpty(t, m.renderedLines)
698+
require.Contains(t, m.View(), "running_tool")
700699
}
701700

702701
func TestRenderCacheNotInvalidatedOnAnimationTickWithoutAnimatedContent(t *testing.T) {

0 commit comments

Comments
 (0)