diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index aec21715fcd924fde40ab9c41e9a4b6e65727ee8..32a8e661a5caaba2c8f36235eb554a2044ee14e0 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -77,9 +77,7 @@ func (l *List) Gap() int { // AtBottom returns whether the list is showing the last item at the bottom. func (l *List) AtBottom() bool { - const margin = 2 - - if len(l.items) == 0 || l.offsetIdx >= len(l.items)-1 { + if len(l.items) == 0 { return true } @@ -94,7 +92,7 @@ func (l *List) AtBottom() bool { totalHeight += itemHeight } - return totalHeight-l.offsetLine-margin <= l.height + return totalHeight-l.offsetLine <= l.height } // SetReverse shows the list in reverse order. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index a424bd1053134496688d422b0ee19aef3a0b4e35..ccd2325507545b35c9ee2e664cd869da9d2a8a4f 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -59,6 +59,10 @@ type Chat struct { // Pending single click action (delayed to detect double-click) pendingClickID int // Incremented on each click to invalidate old pending clicks + + // follow is a flag to indicate whether the view should auto-scroll to + // bottom on new messages. + follow bool } // NewChat creates a new instance of [Chat] that handles chat interactions and @@ -93,8 +97,8 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { func (m *Chat) SetSize(width, height int) { m.list.SetSize(width, height) // Anchor to bottom if we were at the bottom. - if m.list.AtBottom() { - m.list.ScrollToBottom() + if m.AtBottom() { + m.ScrollToBottom() } } @@ -120,7 +124,7 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) { items[i] = msg } m.list.SetItems(items...) - m.list.ScrollToBottom() + m.ScrollToBottom() } // AppendMessages appends a new message item to the chat list. @@ -239,31 +243,72 @@ func (m *Chat) Blur() { m.list.Blur() } +// AtBottom returns whether the chat list is currently scrolled to the bottom. +func (m *Chat) AtBottom() bool { + return m.list.AtBottom() +} + +// Follow returns whether the chat view is in follow mode (auto-scroll to +// bottom on new messages). +func (m *Chat) Follow() bool { + return m.follow +} + +// ScrollToBottom scrolls the chat view to the bottom. +func (m *Chat) ScrollToBottom() { + m.list.ScrollToBottom() + m.follow = true // Enable follow mode when user scrolls to bottom +} + +// ScrollToTop scrolls the chat view to the top. +func (m *Chat) ScrollToTop() { + m.list.ScrollToTop() + m.follow = false // Disable follow mode when user scrolls up +} + +// ScrollBy scrolls the chat view by the given number of line deltas. +func (m *Chat) ScrollBy(lines int) { + m.list.ScrollBy(lines) + m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up +} + +// ScrollToSelected scrolls the chat view to the selected item. +func (m *Chat) ScrollToSelected() { + m.list.ScrollToSelected() + m.follow = m.AtBottom() // Disable follow mode if user scrolls up +} + +// ScrollToIndex scrolls the chat view to the item at the given index. +func (m *Chat) ScrollToIndex(index int) { + m.list.ScrollToIndex(index) + m.follow = m.AtBottom() // Disable follow mode if user scrolls up +} + // ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart // any paused animations that are now visible. func (m *Chat) ScrollToTopAndAnimate() tea.Cmd { - m.list.ScrollToTop() + m.ScrollToTop() return m.RestartPausedVisibleAnimations() } // ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to // restart any paused animations that are now visible. func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd { - m.list.ScrollToBottom() + m.ScrollToBottom() return m.RestartPausedVisibleAnimations() } // ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns // a command to restart any paused animations that are now visible. func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd { - m.list.ScrollBy(lines) + m.ScrollBy(lines) return m.RestartPausedVisibleAnimations() } // ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a // command to restart any paused animations that are now visible. func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd { - m.list.ScrollToSelected() + m.ScrollToSelected() return m.RestartPausedVisibleAnimations() } @@ -438,10 +483,10 @@ func (m *Chat) MessageItem(id string) chat.MessageItem { func (m *Chat) ToggleExpandedSelectedItem() { if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok { if !expandable.ToggleExpanded() { - m.list.ScrollToIndex(m.list.Selected()) + m.ScrollToIndex(m.list.Selected()) } - if m.list.AtBottom() { - m.list.ScrollToBottom() + if m.AtBottom() { + m.ScrollToBottom() } } } @@ -549,11 +594,11 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool { // Toggle expansion if applicable. if expandable, ok := selectedItem.(chat.Expandable); ok { if !expandable.ToggleExpanded() { - m.list.ScrollToIndex(m.list.Selected()) + m.ScrollToIndex(m.list.Selected()) } } - if m.list.AtBottom() { - m.list.ScrollToBottom() + if m.AtBottom() { + m.ScrollToBottom() } return handled } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 04b644361be47ed223201ba9d0744e084feb7119..52b12fd4ff827d5fd9ad7a8061eda81c8d767912 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -53,6 +53,10 @@ import ( "github.com/charmbracelet/x/editor" ) +// MouseScrollThreshold defines how many lines to scroll the chat when a mouse +// wheel event occurs. +const MouseScrollThreshold = 5 + // Compact mode breakpoints. const ( compactModeWidthBreakpoint = 120 @@ -661,7 +665,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiChat: switch msg.Button { case tea.MouseWheelUp: - if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { @@ -671,7 +675,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case tea.MouseWheelDown: - if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { @@ -882,7 +886,6 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) { // if the message is a tool result it will update the corresponding tool call message func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd - atBottom := m.chat.list.AtBottom() existing := m.chat.MessageItem(msg.ID) if existing != nil { @@ -915,7 +918,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } m.chat.AppendMessages(items...) - if atBottom { + if m.chat.Follow() { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } @@ -923,7 +926,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0)) m.chat.AppendMessages(infoItem) - if atBottom { + if m.chat.Follow() { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } @@ -938,7 +941,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok { toolMsgItem.SetResult(&tr) - if atBottom { + if m.chat.Follow() { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } @@ -973,7 +976,6 @@ func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) { func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd existingItem := m.chat.MessageItem(msg.ID) - atBottom := m.chat.list.AtBottom() if existingItem != nil { if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { @@ -1022,7 +1024,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { } m.chat.AppendMessages(items...) - if atBottom { + if m.chat.Follow() { if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } @@ -1035,7 +1037,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd { var cmds []tea.Cmd - atBottom := m.chat.list.AtBottom() + atBottom := m.chat.AtBottom() // Only process messages with tool calls or results. if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { return nil