@@ -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.
@@ -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
}
@@ -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