Skip to content

Commit 77a6b31

Browse files
authored
Merge pull request #1555 from krissetto/tui-scroll-perf
Make TUI more responsive when scrolling
2 parents ffe8b84 + f32634f commit 77a6b31

File tree

13 files changed

+515
-60
lines changed

13 files changed

+515
-60
lines changed

cmd/root/new.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/docker/cagent/pkg/sessiontitle"
1616
"github.com/docker/cagent/pkg/telemetry"
1717
"github.com/docker/cagent/pkg/tui"
18+
tuiinput "github.com/docker/cagent/pkg/tui/input"
1819
)
1920

2021
type newFlags struct {
@@ -97,7 +98,20 @@ func runTUI(ctx context.Context, rt runtime.Runtime, sess *session.Session, opts
9798
a := app.New(ctx, rt, sess, opts...)
9899
m := tui.New(ctx, a)
99100

100-
p := tea.NewProgram(m, tea.WithContext(ctx))
101+
coalescer := tuiinput.NewWheelCoalescer()
102+
filter := func(model tea.Model, msg tea.Msg) tea.Msg {
103+
wheelMsg, ok := msg.(tea.MouseWheelMsg)
104+
if !ok {
105+
return msg
106+
}
107+
if coalescer.Handle(wheelMsg) {
108+
return nil
109+
}
110+
return msg
111+
}
112+
113+
p := tea.NewProgram(m, tea.WithContext(ctx), tea.WithFilter(filter))
114+
coalescer.SetSender(p.Send)
101115
go a.Subscribe(ctx, p)
102116

103117
_, err := p.Run()

pkg/tui/components/editor/editor.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type Editor interface {
6363
layout.Focusable
6464
SetWorking(working bool) tea.Cmd
6565
AcceptSuggestion() tea.Cmd
66+
ScrollByWheel(delta int)
6667
// Value returns the current editor content
6768
Value() string
6869
// SetValue updates the editor content
@@ -495,6 +496,25 @@ func (e *editor) AcceptSuggestion() tea.Cmd {
495496
return e.updateCompletionQuery()
496497
}
497498

499+
func (e *editor) ScrollByWheel(delta int) {
500+
if delta == 0 {
501+
return
502+
}
503+
504+
steps := delta
505+
if steps < 0 {
506+
steps = -steps
507+
for range steps {
508+
e.textarea.CursorUp()
509+
}
510+
return
511+
}
512+
513+
for range steps {
514+
e.textarea.CursorDown()
515+
}
516+
}
517+
498518
// resetAndSend prepares a message for sending: processes pending file refs,
499519
// collects attachments, resets editor state, and returns the SendMsg command.
500520
func (e *editor) resetAndSend(content string) tea.Cmd {

pkg/tui/components/messages/clipboard.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ func (m *model) extractSelectedText() string {
9292
return ""
9393
}
9494

95-
lines := strings.Split(m.rendered, "\n")
95+
m.ensureAllItemsRendered()
96+
lines := m.renderedLines
9697
startLine, startCol, endLine, endCol := m.selection.normalized()
9798

9899
if startLine < 0 || startLine >= len(lines) {

pkg/tui/components/messages/messages.go

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type Model interface {
5858

5959
ScrollToBottom() tea.Cmd
6060
AdjustBottomSlack(delta int)
61+
ScrollByWheel(delta int)
6162
}
6263

6364
// renderedItem represents a cached rendered message with position information
@@ -84,7 +85,7 @@ type model struct {
8485
// Height tracking system fields
8586
scrollOffset int // Current scroll position in lines
8687
bottomSlack int // Extra blank lines added after content shrinks
87-
rendered string // Complete rendered content string
88+
renderedLines []string // Cached rendered content as lines (avoids split/join per frame)
8889
renderedItems map[int]renderedItem // Cache of rendered items with positions
8990
totalHeight int // Total height of all content in lines
9091
renderDirty bool // True when rendered content needs rebuild
@@ -333,27 +334,12 @@ func (m *model) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.C
333334
}
334335

335336
func (m *model) handleMouseWheel(msg tea.MouseWheelMsg) (layout.Model, tea.Cmd) {
336-
const mouseScrollAmount = 2
337337
switch msg.Button.String() {
338338
case "wheelup":
339-
if m.scrollOffset > 0 {
340-
m.userHasScrolled = true
341-
m.bottomSlack = 0
342-
for range mouseScrollAmount {
343-
m.setScrollOffset(m.scrollOffset - defaultScrollAmount)
344-
}
345-
}
339+
m.scrollByWheel(-1)
346340
case "wheeldown":
347-
m.userHasScrolled = true
348-
m.bottomSlack = 0
349-
for range mouseScrollAmount {
350-
m.setScrollOffset(m.scrollOffset + defaultScrollAmount)
351-
}
352-
if m.isAtBottom() {
353-
m.userHasScrolled = false
354-
}
341+
m.scrollByWheel(1)
355342
}
356-
m.scrollbar.SetScrollOffset(m.scrollOffset)
357343
return m, nil
358344
}
359345

@@ -433,22 +419,28 @@ func (m *model) View() string {
433419
m.scrollOffset = max(0, min(m.scrollOffset, maxScrollOffset))
434420
}
435421

436-
lines := strings.Split(m.rendered, "\n")
437-
if m.bottomSlack > 0 {
438-
lines = append(lines, make([]string, m.bottomSlack)...)
439-
}
440-
if len(lines) == 0 {
422+
// Use cached lines directly - O(1) instead of O(totalHeight) split
423+
totalLines := len(m.renderedLines) + m.bottomSlack
424+
if totalLines == 0 {
441425
return ""
442426
}
443427

444428
startLine := m.scrollOffset
445-
endLine := min(startLine+m.height, len(lines))
429+
endLine := min(startLine+m.height, totalLines)
446430

447431
if startLine >= endLine {
448432
return ""
449433
}
450434

451-
visibleLines := lines[startLine:endLine]
435+
// Copy only the visible window to avoid mutating cached lines
436+
// This is O(viewportHeight) instead of O(totalHeight)
437+
visibleLines := make([]string, endLine-startLine)
438+
for i := startLine; i < endLine; i++ {
439+
if i < len(m.renderedLines) {
440+
visibleLines[i-startLine] = m.renderedLines[i]
441+
}
442+
// Lines beyond renderedLines are bottom slack (empty strings), already zero-valued
443+
}
452444

453445
if m.selection.active {
454446
visibleLines = m.applySelectionHighlight(visibleLines, startLine)
@@ -471,22 +463,18 @@ func (m *model) View() string {
471463
}
472464
}
473465

474-
contentView := strings.Join(visibleLines, "\n")
475466
scrollbarView := m.scrollbar.View()
476467

477468
if scrollbarView != "" {
478-
// For proper horizontal layout, all components must have the same height
479-
contentLines := strings.Split(contentView, "\n")
480-
481469
// Ensure content is exactly m.height lines by padding with empty lines if needed
482-
for len(contentLines) < m.height {
483-
contentLines = append(contentLines, "")
470+
for len(visibleLines) < m.height {
471+
visibleLines = append(visibleLines, "")
484472
}
485473
// Truncate if somehow longer (shouldn't happen but safety check)
486-
if len(contentLines) > m.height {
487-
contentLines = contentLines[:m.height]
474+
if len(visibleLines) > m.height {
475+
visibleLines = visibleLines[:m.height]
488476
}
489-
paddedContentView := strings.Join(contentLines, "\n")
477+
contentView := strings.Join(visibleLines, "\n")
490478

491479
// Create spacer with exactly m.height lines
492480
spacerLines := make([]string, m.height)
@@ -495,10 +483,10 @@ func (m *model) View() string {
495483
}
496484
spacer := strings.Join(spacerLines, "\n")
497485

498-
return lipgloss.JoinHorizontal(lipgloss.Top, paddedContentView, spacer, scrollbarView)
486+
return lipgloss.JoinHorizontal(lipgloss.Top, contentView, spacer, scrollbarView)
499487
}
500488

501-
return contentView
489+
return strings.Join(visibleLines, "\n")
502490
}
503491

504492
// SetSize sets the dimensions of the component
@@ -560,7 +548,10 @@ func (m *model) Help() help.KeyMap {
560548
}
561549

562550
// Scrolling methods
563-
const defaultScrollAmount = 1
551+
const (
552+
defaultScrollAmount = 1
553+
wheelScrollAmount = 2
554+
)
564555

565556
func (m *model) scrollUp() {
566557
if m.scrollOffset > 0 {
@@ -605,6 +596,28 @@ func (m *model) scrollToBottom() {
605596
m.setScrollOffset(9_999_999) // Will be clamped in View()
606597
}
607598

599+
func (m *model) ScrollByWheel(delta int) {
600+
m.scrollByWheel(delta)
601+
}
602+
603+
func (m *model) scrollByWheel(delta int) {
604+
if delta == 0 {
605+
return
606+
}
607+
608+
prevOffset := m.scrollOffset
609+
m.setScrollOffset(m.scrollOffset + (delta * wheelScrollAmount * defaultScrollAmount))
610+
if m.scrollOffset == prevOffset {
611+
return
612+
}
613+
614+
m.userHasScrolled = true
615+
m.bottomSlack = 0
616+
if m.isAtBottom() {
617+
m.userHasScrolled = false
618+
}
619+
}
620+
608621
func (m *model) setScrollOffset(offset int) {
609622
maxOffset := max(0, m.totalScrollableHeight()-m.height)
610623
m.scrollOffset = max(0, min(offset, maxOffset))
@@ -788,12 +801,12 @@ func (m *model) needsSeparator(index int) bool {
788801
}
789802

790803
func (m *model) ensureAllItemsRendered() {
791-
if !m.renderDirty && m.rendered != "" {
804+
if !m.renderDirty && len(m.renderedLines) > 0 {
792805
return
793806
}
794807

795808
if len(m.views) == 0 {
796-
m.rendered = ""
809+
m.renderedLines = nil
797810
m.totalHeight = 0
798811
m.renderDirty = false
799812
return
@@ -816,7 +829,8 @@ func (m *model) ensureAllItemsRendered() {
816829
}
817830
}
818831

819-
m.rendered = strings.Join(allLines, "\n")
832+
// Store lines directly - avoid join/split on every View() call
833+
m.renderedLines = allLines
820834
m.totalHeight = len(allLines)
821835
m.renderDirty = false
822836
}
@@ -830,7 +844,7 @@ func (m *model) invalidateItem(index int) {
830844

831845
func (m *model) invalidateAllItems() {
832846
m.renderedItems = make(map[int]renderedItem)
833-
m.rendered = ""
847+
m.renderedLines = nil
834848
m.totalHeight = 0
835849
m.renderDirty = true
836850
}
@@ -978,7 +992,7 @@ func (m *model) LoadFromSession(sess *session.Session) tea.Cmd {
978992
m.messages = nil
979993
m.views = nil
980994
m.renderedItems = make(map[int]renderedItem)
981-
m.rendered = ""
995+
m.renderedLines = nil
982996
m.scrollOffset = 0
983997
m.totalHeight = 0
984998
m.bottomSlack = 0

pkg/tui/components/messages/messages_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,3 +817,60 @@ func TestHasAnimatedContent(t *testing.T) {
817817
})
818818
}
819819
}
820+
821+
// BenchmarkMessagesView_RenderWhileScrolling benchmarks View() with scroll offset changes.
822+
// This measures render cost only (no input handling or coalescing).
823+
func BenchmarkMessagesView_RenderWhileScrolling(b *testing.B) {
824+
// Create a model with many messages to simulate a long conversation
825+
sessionState := &service.SessionState{}
826+
m := NewScrollableView(120, 40, sessionState).(*model)
827+
m.SetSize(120, 40)
828+
829+
// Add 100 messages to create substantial history
830+
for range 100 {
831+
msg := types.Agent(types.MessageTypeAssistant, "root", strings.Repeat("This is a test message with some content. ", 10))
832+
m.messages = append(m.messages, msg)
833+
m.views = append(m.views, m.createMessageView(msg))
834+
}
835+
836+
// Initial render to populate cache
837+
m.View()
838+
839+
b.ResetTimer()
840+
b.ReportAllocs()
841+
842+
// Simulate scrolling by varying scroll offset
843+
for i := range b.N {
844+
// Vary scroll position to simulate wheel scrolling
845+
m.scrollOffset = (i % 50) * 2
846+
m.scrollbar.SetScrollOffset(m.scrollOffset)
847+
_ = m.View()
848+
}
849+
}
850+
851+
// BenchmarkMessagesView_LargeHistory benchmarks View() with a very large message history.
852+
func BenchmarkMessagesView_LargeHistory(b *testing.B) {
853+
sessionState := &service.SessionState{}
854+
m := NewScrollableView(120, 40, sessionState).(*model)
855+
m.SetSize(120, 40)
856+
857+
// Add 500 messages
858+
for i := range 500 {
859+
content := "Message " + strconv.Itoa(i) + ": " + strings.Repeat("content ", 20)
860+
msg := types.Agent(types.MessageTypeAssistant, "root", content)
861+
m.messages = append(m.messages, msg)
862+
m.views = append(m.views, m.createMessageView(msg))
863+
}
864+
865+
// Initial render to populate cache
866+
m.View()
867+
868+
b.ResetTimer()
869+
b.ReportAllocs()
870+
871+
for i := range b.N {
872+
m.scrollOffset = (i % 100) * 5
873+
m.scrollbar.SetScrollOffset(m.scrollOffset)
874+
_ = m.View()
875+
}
876+
}

pkg/tui/components/messages/selection.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ func (m *model) autoScroll() tea.Cmd {
136136

137137
// selectWordAt selects the word at the given line and column position
138138
func (m *model) selectWordAt(line, col int) {
139-
lines := strings.Split(m.rendered, "\n")
139+
m.ensureAllItemsRendered()
140+
lines := m.renderedLines
140141
if line < 0 || line >= len(lines) {
141142
return
142143
}
@@ -184,7 +185,8 @@ func (m *model) selectWordAt(line, col int) {
184185

185186
// selectLineAt selects the entire line at the given line position
186187
func (m *model) selectLineAt(line int) {
187-
lines := strings.Split(m.rendered, "\n")
188+
m.ensureAllItemsRendered()
189+
lines := m.renderedLines
188190
if line < 0 || line >= len(lines) {
189191
return
190192
}

0 commit comments

Comments
 (0)