Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion cmd/root/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/docker/cagent/pkg/sessiontitle"
"github.com/docker/cagent/pkg/telemetry"
"github.com/docker/cagent/pkg/tui"
tuiinput "github.com/docker/cagent/pkg/tui/input"
)

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

p := tea.NewProgram(m, tea.WithContext(ctx))
coalescer := tuiinput.NewWheelCoalescer()
filter := func(model tea.Model, msg tea.Msg) tea.Msg {
wheelMsg, ok := msg.(tea.MouseWheelMsg)
if !ok {
return msg
}
if coalescer.Handle(wheelMsg) {
return nil
}
return msg
}

p := tea.NewProgram(m, tea.WithContext(ctx), tea.WithFilter(filter))
coalescer.SetSender(p.Send)
go a.Subscribe(ctx, p)

_, err := p.Run()
Expand Down
20 changes: 20 additions & 0 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type Editor interface {
layout.Focusable
SetWorking(working bool) tea.Cmd
AcceptSuggestion() tea.Cmd
ScrollByWheel(delta int)
// Value returns the current editor content
Value() string
// SetValue updates the editor content
Expand Down Expand Up @@ -495,6 +496,25 @@ func (e *editor) AcceptSuggestion() tea.Cmd {
return e.updateCompletionQuery()
}

func (e *editor) ScrollByWheel(delta int) {
if delta == 0 {
return
}

steps := delta
if steps < 0 {
steps = -steps
for range steps {
e.textarea.CursorUp()
}
return
}

for range steps {
e.textarea.CursorDown()
}
}

// resetAndSend prepares a message for sending: processes pending file refs,
// collects attachments, resets editor state, and returns the SendMsg command.
func (e *editor) resetAndSend(content string) tea.Cmd {
Expand Down
3 changes: 2 additions & 1 deletion pkg/tui/components/messages/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ func (m *model) extractSelectedText() string {
return ""
}

lines := strings.Split(m.rendered, "\n")
m.ensureAllItemsRendered()
lines := m.renderedLines
startLine, startCol, endLine, endCol := m.selection.normalized()

if startLine < 0 || startLine >= len(lines) {
Expand Down
98 changes: 56 additions & 42 deletions pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type Model interface {

ScrollToBottom() tea.Cmd
AdjustBottomSlack(delta int)
ScrollByWheel(delta int)
}

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

func (m *model) handleMouseWheel(msg tea.MouseWheelMsg) (layout.Model, tea.Cmd) {
const mouseScrollAmount = 2
switch msg.Button.String() {
case "wheelup":
if m.scrollOffset > 0 {
m.userHasScrolled = true
m.bottomSlack = 0
for range mouseScrollAmount {
m.setScrollOffset(m.scrollOffset - defaultScrollAmount)
}
}
m.scrollByWheel(-1)
case "wheeldown":
m.userHasScrolled = true
m.bottomSlack = 0
for range mouseScrollAmount {
m.setScrollOffset(m.scrollOffset + defaultScrollAmount)
}
if m.isAtBottom() {
m.userHasScrolled = false
}
m.scrollByWheel(1)
}
m.scrollbar.SetScrollOffset(m.scrollOffset)
return m, nil
}

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

lines := strings.Split(m.rendered, "\n")
if m.bottomSlack > 0 {
lines = append(lines, make([]string, m.bottomSlack)...)
}
if len(lines) == 0 {
// Use cached lines directly - O(1) instead of O(totalHeight) split
totalLines := len(m.renderedLines) + m.bottomSlack
if totalLines == 0 {
return ""
}

startLine := m.scrollOffset
endLine := min(startLine+m.height, len(lines))
endLine := min(startLine+m.height, totalLines)

if startLine >= endLine {
return ""
}

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

if m.selection.active {
visibleLines = m.applySelectionHighlight(visibleLines, startLine)
Expand All @@ -471,22 +463,18 @@ func (m *model) View() string {
}
}

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

if scrollbarView != "" {
// For proper horizontal layout, all components must have the same height
contentLines := strings.Split(contentView, "\n")

// Ensure content is exactly m.height lines by padding with empty lines if needed
for len(contentLines) < m.height {
contentLines = append(contentLines, "")
for len(visibleLines) < m.height {
visibleLines = append(visibleLines, "")
}
// Truncate if somehow longer (shouldn't happen but safety check)
if len(contentLines) > m.height {
contentLines = contentLines[:m.height]
if len(visibleLines) > m.height {
visibleLines = visibleLines[:m.height]
}
paddedContentView := strings.Join(contentLines, "\n")
contentView := strings.Join(visibleLines, "\n")

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

return lipgloss.JoinHorizontal(lipgloss.Top, paddedContentView, spacer, scrollbarView)
return lipgloss.JoinHorizontal(lipgloss.Top, contentView, spacer, scrollbarView)
}

return contentView
return strings.Join(visibleLines, "\n")
}

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

// Scrolling methods
const defaultScrollAmount = 1
const (
defaultScrollAmount = 1
wheelScrollAmount = 2
)

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

func (m *model) ScrollByWheel(delta int) {
m.scrollByWheel(delta)
}

func (m *model) scrollByWheel(delta int) {
if delta == 0 {
return
}

prevOffset := m.scrollOffset
m.setScrollOffset(m.scrollOffset + (delta * wheelScrollAmount * defaultScrollAmount))
if m.scrollOffset == prevOffset {
return
}

m.userHasScrolled = true
m.bottomSlack = 0
if m.isAtBottom() {
m.userHasScrolled = false
}
}

func (m *model) setScrollOffset(offset int) {
maxOffset := max(0, m.totalScrollableHeight()-m.height)
m.scrollOffset = max(0, min(offset, maxOffset))
Expand Down Expand Up @@ -788,12 +801,12 @@ func (m *model) needsSeparator(index int) bool {
}

func (m *model) ensureAllItemsRendered() {
if !m.renderDirty && m.rendered != "" {
if !m.renderDirty && len(m.renderedLines) > 0 {
return
}

if len(m.views) == 0 {
m.rendered = ""
m.renderedLines = nil
m.totalHeight = 0
m.renderDirty = false
return
Expand All @@ -816,7 +829,8 @@ func (m *model) ensureAllItemsRendered() {
}
}

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

func (m *model) invalidateAllItems() {
m.renderedItems = make(map[int]renderedItem)
m.rendered = ""
m.renderedLines = nil
m.totalHeight = 0
m.renderDirty = true
}
Expand Down Expand Up @@ -978,7 +992,7 @@ func (m *model) LoadFromSession(sess *session.Session) tea.Cmd {
m.messages = nil
m.views = nil
m.renderedItems = make(map[int]renderedItem)
m.rendered = ""
m.renderedLines = nil
m.scrollOffset = 0
m.totalHeight = 0
m.bottomSlack = 0
Expand Down
57 changes: 57 additions & 0 deletions pkg/tui/components/messages/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,3 +817,60 @@ func TestHasAnimatedContent(t *testing.T) {
})
}
}

// BenchmarkMessagesView_RenderWhileScrolling benchmarks View() with scroll offset changes.
// This measures render cost only (no input handling or coalescing).
func BenchmarkMessagesView_RenderWhileScrolling(b *testing.B) {
// Create a model with many messages to simulate a long conversation
sessionState := &service.SessionState{}
m := NewScrollableView(120, 40, sessionState).(*model)
m.SetSize(120, 40)

// Add 100 messages to create substantial history
for range 100 {
msg := types.Agent(types.MessageTypeAssistant, "root", strings.Repeat("This is a test message with some content. ", 10))
m.messages = append(m.messages, msg)
m.views = append(m.views, m.createMessageView(msg))
}

// Initial render to populate cache
m.View()

b.ResetTimer()
b.ReportAllocs()

// Simulate scrolling by varying scroll offset
for i := range b.N {
// Vary scroll position to simulate wheel scrolling
m.scrollOffset = (i % 50) * 2
m.scrollbar.SetScrollOffset(m.scrollOffset)
_ = m.View()
}
}

// BenchmarkMessagesView_LargeHistory benchmarks View() with a very large message history.
func BenchmarkMessagesView_LargeHistory(b *testing.B) {
sessionState := &service.SessionState{}
m := NewScrollableView(120, 40, sessionState).(*model)
m.SetSize(120, 40)

// Add 500 messages
for i := range 500 {
content := "Message " + strconv.Itoa(i) + ": " + strings.Repeat("content ", 20)
msg := types.Agent(types.MessageTypeAssistant, "root", content)
m.messages = append(m.messages, msg)
m.views = append(m.views, m.createMessageView(msg))
}

// Initial render to populate cache
m.View()

b.ResetTimer()
b.ReportAllocs()

for i := range b.N {
m.scrollOffset = (i % 100) * 5
m.scrollbar.SetScrollOffset(m.scrollOffset)
_ = m.View()
}
}
6 changes: 4 additions & 2 deletions pkg/tui/components/messages/selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ func (m *model) autoScroll() tea.Cmd {

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

// selectLineAt selects the entire line at the given line position
func (m *model) selectLineAt(line int) {
lines := strings.Split(m.rendered, "\n")
m.ensureAllItemsRendered()
lines := m.renderedLines
if line < 0 || line >= len(lines) {
return
}
Expand Down
Loading