on main help sign in
Merge pull request #2204 from charmbracelet/charm-1151-toggling-open-a-block-while-streaming-causes-jitter
d34c56bAyman Bagabas
internal/ui/list/list.go+2−4
@@ -77,9 +77,7 @@ func (l *List) Gap() int {
77
77
78// AtBottom returns whether the list is showing the last item at the bottom.
78// AtBottom returns whether the list is showing the last item at the bottom.
79func (l *List) AtBottom() bool {
79func (l *List) AtBottom() bool {
80− const margin = 2
81−
82− if len(l.items) == 0 || l.offsetIdx >= len(l.items)-1 {
80+ if len(l.items) == 0 {
83 return true
81 return true
84 }
82 }
85
83
@@ -94,7 +92,7 @@ func (l *List) AtBottom() bool {
94 totalHeight += itemHeight
92 totalHeight += itemHeight
95 }
93 }
96
94
97− return totalHeight-l.offsetLine-margin <= l.height
95+ return totalHeight-l.offsetLine <= l.height
98}
96}
99
97
100// SetReverse shows the list in reverse order.
98// SetReverse shows the list in reverse order.
internal/ui/model/chat.go+58−13
@@ -59,6 +59,10 @@ type Chat struct {
59
59
60 // Pending single click action (delayed to detect double-click)
60 // Pending single click action (delayed to detect double-click)
61 pendingClickID int // Incremented on each click to invalidate old pending clicks
61 pendingClickID int // Incremented on each click to invalidate old pending clicks
62+
63+ // follow is a flag to indicate whether the view should auto-scroll to
64+ // bottom on new messages.
65+ follow bool
62}
66}
63
67
64// NewChat creates a new instance of [Chat] that handles chat interactions and
68// 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) {
93func (m *Chat) SetSize(width, height int) {
97func (m *Chat) SetSize(width, height int) {
94 m.list.SetSize(width, height)
98 m.list.SetSize(width, height)
95 // Anchor to bottom if we were at the bottom.
99 // Anchor to bottom if we were at the bottom.
96− if m.list.AtBottom() {
100+ if m.AtBottom() {
97− m.list.ScrollToBottom()
101+ m.ScrollToBottom()
98 }
102 }
99}
103}
100
104
@@ -120,7 +124,7 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
120 items[i] = msg
124 items[i] = msg
121 }
125 }
122 m.list.SetItems(items...)
126 m.list.SetItems(items...)
123− m.list.ScrollToBottom()
127+ m.ScrollToBottom()
124}
128}
125
129
126// AppendMessages appends a new message item to the chat list.
130// AppendMessages appends a new message item to the chat list.
@@ -239,31 +243,72 @@ func (m *Chat) Blur() {
239 m.list.Blur()
243 m.list.Blur()
240}
244}
241
245
246+// AtBottom returns whether the chat list is currently scrolled to the bottom.
247+func (m *Chat) AtBottom() bool {
248+ return m.list.AtBottom()
249+}
250+
251+// Follow returns whether the chat view is in follow mode (auto-scroll to
252+// bottom on new messages).
253+func (m *Chat) Follow() bool {
254+ return m.follow
255+}
256+
257+// ScrollToBottom scrolls the chat view to the bottom.
258+func (m *Chat) ScrollToBottom() {
259+ m.list.ScrollToBottom()
260+ m.follow = true // Enable follow mode when user scrolls to bottom
261+}
262+
263+// ScrollToTop scrolls the chat view to the top.
264+func (m *Chat) ScrollToTop() {
265+ m.list.ScrollToTop()
266+ m.follow = false // Disable follow mode when user scrolls up
267+}
268+
269+// ScrollBy scrolls the chat view by the given number of line deltas.
270+func (m *Chat) ScrollBy(lines int) {
271+ m.list.ScrollBy(lines)
272+ m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
273+}
274+
275+// ScrollToSelected scrolls the chat view to the selected item.
276+func (m *Chat) ScrollToSelected() {
277+ m.list.ScrollToSelected()
278+ m.follow = m.AtBottom() // Disable follow mode if user scrolls up
279+}
280+
281+// ScrollToIndex scrolls the chat view to the item at the given index.
282+func (m *Chat) ScrollToIndex(index int) {
283+ m.list.ScrollToIndex(index)
284+ m.follow = m.AtBottom() // Disable follow mode if user scrolls up
285+}
286+
242// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
287// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
243// any paused animations that are now visible.
288// any paused animations that are now visible.
244func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
289func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
245− m.list.ScrollToTop()
290+ m.ScrollToTop()
246 return m.RestartPausedVisibleAnimations()
291 return m.RestartPausedVisibleAnimations()
247}
292}
248
293
249// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
294// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
250// restart any paused animations that are now visible.
295// restart any paused animations that are now visible.
251func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
296func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
252− m.list.ScrollToBottom()
297+ m.ScrollToBottom()
253 return m.RestartPausedVisibleAnimations()
298 return m.RestartPausedVisibleAnimations()
254}
299}
255
300
256// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
301// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
257// a command to restart any paused animations that are now visible.
302// a command to restart any paused animations that are now visible.
258func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
303func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
259− m.list.ScrollBy(lines)
304+ m.ScrollBy(lines)
260 return m.RestartPausedVisibleAnimations()
305 return m.RestartPausedVisibleAnimations()
261}
306}
262
307
263// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
308// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
264// command to restart any paused animations that are now visible.
309// command to restart any paused animations that are now visible.
265func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
310func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
266− m.list.ScrollToSelected()
311+ m.ScrollToSelected()
267 return m.RestartPausedVisibleAnimations()
312 return m.RestartPausedVisibleAnimations()
268}
313}
269
314
@@ -438,10 +483,10 @@ func (m *Chat) MessageItem(id string) chat.MessageItem {
438func (m *Chat) ToggleExpandedSelectedItem() {
483func (m *Chat) ToggleExpandedSelectedItem() {
439 if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
484 if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
440 if !expandable.ToggleExpanded() {
485 if !expandable.ToggleExpanded() {
441− m.list.ScrollToIndex(m.list.Selected())
486+ m.ScrollToIndex(m.list.Selected())
442 }
487 }
443− if m.list.AtBottom() {
488+ if m.AtBottom() {
444− m.list.ScrollToBottom()
489+ m.ScrollToBottom()
445 }
490 }
446 }
491 }
447}
492}
@@ -549,11 +594,11 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
549 // Toggle expansion if applicable.
594 // Toggle expansion if applicable.
550 if expandable, ok := selectedItem.(chat.Expandable); ok {
595 if expandable, ok := selectedItem.(chat.Expandable); ok {
551 if !expandable.ToggleExpanded() {
596 if !expandable.ToggleExpanded() {
552− m.list.ScrollToIndex(m.list.Selected())
597+ m.ScrollToIndex(m.list.Selected())
553 }
598 }
554 }
599 }
555− if m.list.AtBottom() {
600+ if m.AtBottom() {
556− m.list.ScrollToBottom()
601+ m.ScrollToBottom()
557 }
602 }
558 return handled
603 return handled
559 }
604 }
internal/ui/model/ui.go+11−9
@@ -53,6 +53,10 @@ import (
53 "github.com/charmbracelet/x/editor"
53 "github.com/charmbracelet/x/editor"
54)
54)
55
55
56+// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
57+// wheel event occurs.
58+const MouseScrollThreshold = 5
59+
56// Compact mode breakpoints.
60// Compact mode breakpoints.
57const (
61const (
58 compactModeWidthBreakpoint = 120
62 compactModeWidthBreakpoint = 120
@@ -661,7 +665,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
661 case uiChat:
665 case uiChat:
662 switch msg.Button {
666 switch msg.Button {
663 case tea.MouseWheelUp:
667 case tea.MouseWheelUp:
664− if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
668+ if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
665 cmds = append(cmds, cmd)
669 cmds = append(cmds, cmd)
666 }
670 }
667 if !m.chat.SelectedItemInView() {
671 if !m.chat.SelectedItemInView() {
@@ -671,7 +675,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
671 }
675 }
672 }
676 }
673 case tea.MouseWheelDown:
677 case tea.MouseWheelDown:
674− if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
678+ if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
675 cmds = append(cmds, cmd)
679 cmds = append(cmds, cmd)
676 }
680 }
677 if !m.chat.SelectedItemInView() {
681 if !m.chat.SelectedItemInView() {
@@ -882,7 +886,6 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
882// if the message is a tool result it will update the corresponding tool call message
886// if the message is a tool result it will update the corresponding tool call message
883func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
887func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
884 var cmds []tea.Cmd
888 var cmds []tea.Cmd
885− atBottom := m.chat.list.AtBottom()
886
889
887 existing := m.chat.MessageItem(msg.ID)
890 existing := m.chat.MessageItem(msg.ID)
888 if existing != nil {
891 if existing != nil {
@@ -915,7 +918,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
915 }
918 }
916 }
919 }
917 m.chat.AppendMessages(items...)
920 m.chat.AppendMessages(items...)
918− if atBottom {
921+ if m.chat.Follow() {
919 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
922 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
920 cmds = append(cmds, cmd)
923 cmds = append(cmds, cmd)
921 }
924 }
@@ -923,7 +926,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
923 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
926 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
924 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
927 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
925 m.chat.AppendMessages(infoItem)
928 m.chat.AppendMessages(infoItem)
926− if atBottom {
929+ if m.chat.Follow() {
927 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
930 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
928 cmds = append(cmds, cmd)
931 cmds = append(cmds, cmd)
929 }
932 }
@@ -938,7 +941,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
938 }
941 }
939 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
942 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
940 toolMsgItem.SetResult(&tr)
943 toolMsgItem.SetResult(&tr)
941− if atBottom {
944+ if m.chat.Follow() {
942 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
945 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
943 cmds = append(cmds, cmd)
946 cmds = append(cmds, cmd)
944 }
947 }
@@ -973,7 +976,6 @@ func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
973func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
976func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
974 var cmds []tea.Cmd
977 var cmds []tea.Cmd
975 existingItem := m.chat.MessageItem(msg.ID)
978 existingItem := m.chat.MessageItem(msg.ID)
976− atBottom := m.chat.list.AtBottom()
977
979
978 if existingItem != nil {
980 if existingItem != nil {
979 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
981 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
@@ -1022,7 +1024,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1022 }
1024 }
1023
1025
1024 m.chat.AppendMessages(items...)
1026 m.chat.AppendMessages(items...)
1025− if atBottom {
1027+ if m.chat.Follow() {
1026 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1028 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1027 cmds = append(cmds, cmd)
1029 cmds = append(cmds, cmd)
1028 }
1030 }
@@ -1035,7 +1037,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1035func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1037func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1036 var cmds []tea.Cmd
1038 var cmds []tea.Cmd
1037
1039
1038− atBottom := m.chat.list.AtBottom()
1040+ atBottom := m.chat.AtBottom()
1039 // Only process messages with tool calls or results.
1041 // Only process messages with tool calls or results.
1040 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1042 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1041 return nil
1043 return nil