@@ -1,11 +1,13 @@
1package model
1package model
7 tea "charm.land/bubbletea/v2"
8 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9 "charm.land/lipgloss/v2"
10+ "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/ui/anim"
11 "github.com/charmbracelet/crush/internal/ui/anim"
10 "github.com/charmbracelet/crush/internal/ui/chat"
12 "github.com/charmbracelet/crush/internal/ui/chat"
11 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
@@ -30,6 +32,18 @@ type DelayedClickMsg struct {
35+// scrollbarHideMsg is sent to hide the scrollbar after the timeout period.
36+type scrollbarHideMsg struct {
37+ seq int // sequence number to ignore stale messages
40+// scrollbarHideCmd returns a command that sends a scrollbarHideMsg after the timeout.
41+func scrollbarHideCmd(seq int) tea.Cmd {
42+ return tea.Tick(scrollbarHideDuration, func(_ time.Time) tea.Msg {
43+ return scrollbarHideMsg{seq: seq}
33// Chat represents the chat UI model that handles chat interactions and
47// Chat represents the chat UI model that handles chat interactions and
34// messages.
48// messages.
35type Chat struct {
49type Chat struct {
@@ -70,8 +84,16 @@ type Chat struct {
70 // (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one
84 // (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one
71 // entry; invalidated implicitly by string inequality on the next Draw.
85 // entry; invalidated implicitly by string inequality on the next Draw.
72 drawCache *chatDrawCache
86 drawCache *chatDrawCache
88+ // Scrollbar visibility state
89+ scrollbarVisible bool
90+ scrollbarHideSeq int // current sequence number for hide timer
91+ scrollbarMode string // "default", "always", or "never"
94+// scrollbarHideDuration is how long the scrollbar remains visible after scroll activity.
95+const scrollbarHideDuration = 2 * time.Second
75// chatDrawCache holds the pre-decoded form of the last list.Render output.
97// chatDrawCache holds the pre-decoded form of the last list.Render output.
76// The cache is keyed by the rendered string and the screen's width method
98// The cache is keyed by the rendered string and the screen's width method
77// (graphemes vs wcwidth pick different decoders inside ultraviolet's
99// (graphemes vs wcwidth pick different decoders inside ultraviolet's
@@ -93,11 +115,12 @@ type chatDrawCache struct {
94// NewChat creates a new instance of [Chat] that handles chat interactions and
116// NewChat creates a new instance of [Chat] that handles chat interactions and
95// messages.
117// messages.
96−func NewChat(com *common.Common) *Chat {
118+func NewChat(com *common.Common, scrollbarMode string) *Chat {
97 c := &Chat{
119 c := &Chat{
98 com: com,
120 com: com,
99 idInxMap: make(map[string]int),
121 idInxMap: make(map[string]int),
100 pausedAnimations: make(map[string]struct{}),
122 pausedAnimations: make(map[string]struct{}),
123+ scrollbarMode: scrollbarMode,
102 l := list.NewList()
125 l := list.NewList()
103 l.SetGap(1)
126 l.SetGap(1)
@@ -122,21 +145,61 @@ func (m *Chat) Height() int {
122// rendered string and the screen's width method; area / scroll changes do not
145// rendered string and the screen's width method; area / scroll changes do not
123// invalidate it.
146// invalidate it.
124func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
147func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
148+ // Check if scrollbar should be visible.
149+ listHeight := m.list.Height() - 1
150+ listTotalHeight := m.list.TotalHeight() - 1
151+ needsScrollbar := listTotalHeight > listHeight
153+ // Determine visibility based on scrollbar mode.
154+ showScrollbar := false
155+ switch m.scrollbarMode {
156+ case config.ScrollbarAlways:
157+ showScrollbar = needsScrollbar
158+ case config.ScrollbarDefault:
159+ showScrollbar = needsScrollbar && m.scrollbarVisible
160+ case config.ScrollbarNever:
161+ showScrollbar = false
164+ // Reserve space for scrollbar only when visible.
170+ // Adjust list width to reserve space for scrollbar.
172+ if scrollbarWidth > 0 {
173+ listArea.Max.X -= scrollbarWidth
125 rendered := m.list.Render()
176 rendered := m.list.Render()
126 method, ok := scr.WidthMethod().(ansi.Method)
177 method, ok := scr.WidthMethod().(ansi.Method)
128 // Width method isn't an ansi.Method (unlikely in practice — both
179 // Width method isn't an ansi.Method (unlikely in practice — both
129 // TerminalScreen and ScreenBuffer store ansi.Method). Fall back
180 // TerminalScreen and ScreenBuffer store ansi.Method). Fall back
130 // to the uncached path so behavior matches upstream exactly.
181 // to the uncached path so behavior matches upstream exactly.
131− uv.NewStyledString(rendered).Draw(scr, area)
182+ uv.NewStyledString(rendered).Draw(scr, listArea)
184+ if m.drawCache == nil ||
185+ m.drawCache.rendered != rendered ||
186+ m.drawCache.method != method {
187+ m.drawCache = newChatDrawCache(rendered, method)
189+ drawCachedBuffer(scr, listArea, m.drawCache.buf)
134− if m.drawCache == nil ||
191+
135− m.drawCache.rendered != rendered ||
192+ // Draw scrollbar if visible and needed.
136− m.drawCache.method != method {
193+ if scrollbarWidth > 0 {
137− m.drawCache = newChatDrawCache(rendered, method)
194+ scrollbar := common.Scrollbar(m.com.Styles, listHeight, listTotalHeight, listHeight, m.list.Offset())
195+ if scrollbar != "" {
196+ scrollbarArea := image.Rectangle{
197+ Min: image.Point{X: area.Max.X - scrollbarWidth, Y: area.Min.Y},
198+ Max: image.Point{X: area.Max.X, Y: area.Max.Y},
200+ uv.NewStyledString(scrollbar).Draw(scr, scrollbarArea)
139− drawCachedBuffer(scr, area, m.drawCache.buf)
142// newChatDrawCache builds a chatDrawCache for the given rendered string by
205// newChatDrawCache builds a chatDrawCache for the given rendered string by
@@ -206,7 +269,12 @@ func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) {
207// SetSize sets the size of the chat view port.
270// SetSize sets the size of the chat view port.
208func (m *Chat) SetSize(width, height int) {
271func (m *Chat) SetSize(width, height int) {
272+ // Reserve space for scrollbar if content exceeds viewport height.
274+ if m.list.TotalHeight() > height {
275+ listWidth = max(0, width-1)
209− m.list.SetSize(width, height)
277+ m.list.SetSize(listWidth, height)
210 // Anchor to bottom if we were at the bottom.
278 // Anchor to bottom if we were at the bottom.
211 if m.AtBottom() {
279 if m.AtBottom() {
212 m.ScrollToBottom()
280 m.ScrollToBottom()
@@ -231,7 +299,7 @@ func (m *Chat) InvalidateRenderCaches() {
233// SetMessages sets the chat messages to the provided list of message items.
301// SetMessages sets the chat messages to the provided list of message items.
234−func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
302+func (m *Chat) SetMessages(msgs ...chat.MessageItem) tea.Cmd {
235 m.idInxMap = make(map[string]int)
303 m.idInxMap = make(map[string]int)
236 m.pausedAnimations = make(map[string]struct{})
304 m.pausedAnimations = make(map[string]struct{})
@@ -247,7 +315,7 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
247 items[i] = msg
315 items[i] = msg
249 m.list.SetItems(items...)
317 m.list.SetItems(items...)
250− m.ScrollToBottom()
318+ return m.ScrollToBottom()
253// AppendMessages appends a new message item to the chat list.
321// AppendMessages appends a new message item to the chat list.
@@ -378,61 +446,84 @@ func (m *Chat) Follow() bool {
380// ScrollToBottom scrolls the chat view to the bottom.
448// ScrollToBottom scrolls the chat view to the bottom.
381−func (m *Chat) ScrollToBottom() {
449+func (m *Chat) ScrollToBottom() tea.Cmd {
382 m.list.ScrollToBottom()
450 m.list.ScrollToBottom()
383 m.follow = true // Enable follow mode when user scrolls to bottom
451 m.follow = true // Enable follow mode when user scrolls to bottom
452+ return m.showScrollbar()
386// ScrollToTop scrolls the chat view to the top.
455// ScrollToTop scrolls the chat view to the top.
387−func (m *Chat) ScrollToTop() {
456+func (m *Chat) ScrollToTop() tea.Cmd {
388 m.list.ScrollToTop()
457 m.list.ScrollToTop()
389 m.follow = false // Disable follow mode when user scrolls up
458 m.follow = false // Disable follow mode when user scrolls up
459+ return m.showScrollbar()
392// ScrollBy scrolls the chat view by the given number of line deltas.
462// ScrollBy scrolls the chat view by the given number of line deltas.
393−func (m *Chat) ScrollBy(lines int) {
463+func (m *Chat) ScrollBy(lines int) tea.Cmd {
394 m.list.ScrollBy(lines)
464 m.list.ScrollBy(lines)
395 m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
465 m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
466+ return m.showScrollbar()
398// ScrollToSelected scrolls the chat view to the selected item.
469// ScrollToSelected scrolls the chat view to the selected item.
399−func (m *Chat) ScrollToSelected() {
470+func (m *Chat) ScrollToSelected() tea.Cmd {
400 m.list.ScrollToSelected()
471 m.list.ScrollToSelected()
401 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
472 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
473+ return m.showScrollbar()
404// ScrollToIndex scrolls the chat view to the item at the given index.
476// ScrollToIndex scrolls the chat view to the item at the given index.
405−func (m *Chat) ScrollToIndex(index int) {
477+func (m *Chat) ScrollToIndex(index int) tea.Cmd {
406 m.list.ScrollToIndex(index)
478 m.list.ScrollToIndex(index)
407 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
479 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
480+ return m.showScrollbar()
483+// showScrollbar makes the scrollbar visible and returns a command to hide it after timeout.
484+func (m *Chat) showScrollbar() tea.Cmd {
485+ // Only start timer for "default" mode
486+ if m.scrollbarMode != config.ScrollbarDefault {
489+ m.scrollbarVisible = true
490+ m.scrollbarHideSeq++
491+ return scrollbarHideCmd(m.scrollbarHideSeq)
494+// HideScrollbar hides the scrollbar if the sequence matches.
495+func (m *Chat) HideScrollbar(seq int) {
496+ // Only hide scrollbar for "default" mode
497+ if m.scrollbarMode != config.ScrollbarDefault {
500+ if seq == m.scrollbarHideSeq {
501+ m.scrollbarVisible = false
410// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
505// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
411// any paused animations that are now visible.
506// any paused animations that are now visible.
412func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
507func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
414− return m.RestartPausedVisibleAnimations()
508+ return tea.Batch(m.ScrollToTop(), m.RestartPausedVisibleAnimations())
417// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
511// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
418// restart any paused animations that are now visible.
512// restart any paused animations that are now visible.
419func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
513func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
421− return m.RestartPausedVisibleAnimations()
514+ return tea.Batch(m.ScrollToBottom(), m.RestartPausedVisibleAnimations())
424// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
517// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
425// a command to restart any paused animations that are now visible.
518// a command to restart any paused animations that are now visible.
426func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
519func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
428− return m.RestartPausedVisibleAnimations()
520+ return tea.Batch(m.ScrollBy(lines), m.RestartPausedVisibleAnimations())
431// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
523// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
432// command to restart any paused animations that are now visible.
524// command to restart any paused animations that are now visible.
433func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
525func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
434− m.ScrollToSelected()
435− return m.RestartPausedVisibleAnimations()
526+ return tea.Batch(m.ScrollToSelected(), m.RestartPausedVisibleAnimations())
438// SelectedItemInView returns whether the selected item is currently in view.
529// SelectedItemInView returns whether the selected item is currently in view.