@@ -1,11 +1,13 @@
11package model
22
33import (
4+ "image"
45 "strings"
56 "time"
67
78 tea "charm.land/bubbletea/v2"
89 "charm.land/lipgloss/v2"
10+ "github.com/charmbracelet/crush/internal/config"
911 "github.com/charmbracelet/crush/internal/ui/anim"
1012 "github.com/charmbracelet/crush/internal/ui/chat"
1113 "github.com/charmbracelet/crush/internal/ui/common"
@@ -30,6 +32,18 @@ type DelayedClickMsg struct {
3032 X, Y int
3133}
3234
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
38+}
39+
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}
44+ })
45+}
46+
3347// Chat represents the chat UI model that handles chat interactions and
3448// messages.
3549type Chat struct {
@@ -70,8 +84,16 @@ type Chat struct {
7084 // (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one
7185 // entry; invalidated implicitly by string inequality on the next Draw.
7286 drawCache *chatDrawCache
87+
88+ // Scrollbar visibility state
89+ scrollbarVisible bool
90+ scrollbarHideSeq int // current sequence number for hide timer
91+ scrollbarMode string // "default", "always", or "never"
7392}
7493
94+// scrollbarHideDuration is how long the scrollbar remains visible after scroll activity.
95+const scrollbarHideDuration = 2 * time.Second
96+
7597// chatDrawCache holds the pre-decoded form of the last list.Render output.
7698// The cache is keyed by the rendered string and the screen's width method
7799// (graphemes vs wcwidth pick different decoders inside ultraviolet's
@@ -93,11 +115,12 @@ type chatDrawCache struct {
93115
94116// NewChat creates a new instance of [Chat] that handles chat interactions and
95117// messages.
96−func NewChat(com *common.Common) *Chat {
118+func NewChat(com *common.Common, scrollbarMode string) *Chat {
97119 c := &Chat{
98120 com: com,
99121 idInxMap: make(map[string]int),
100122 pausedAnimations: make(map[string]struct{}),
123+ scrollbarMode: scrollbarMode,
101124 }
102125 l := list.NewList()
103126 l.SetGap(1)
@@ -122,21 +145,61 @@ func (m *Chat) Height() int {
122145// rendered string and the screen's width method; area / scroll changes do not
123146// invalidate it.
124147func (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
152+
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
162+ }
163+
164+ // Reserve space for scrollbar only when visible.
165+ scrollbarWidth := 0
166+ if showScrollbar {
167+ scrollbarWidth = 1
168+ }
169+
170+ // Adjust list width to reserve space for scrollbar.
171+ listArea := area
172+ if scrollbarWidth > 0 {
173+ listArea.Max.X -= scrollbarWidth
174+ }
175+
125176 rendered := m.list.Render()
126177 method, ok := scr.WidthMethod().(ansi.Method)
127178 if !ok {
128179 // Width method isn't an ansi.Method (unlikely in practice — both
129180 // TerminalScreen and ScreenBuffer store ansi.Method). Fall back
130181 // to the uncached path so behavior matches upstream exactly.
131− uv.NewStyledString(rendered).Draw(scr, area)
132− return
182+ uv.NewStyledString(rendered).Draw(scr, listArea)
183+ } else {
184+ if m.drawCache == nil ||
185+ m.drawCache.rendered != rendered ||
186+ m.drawCache.method != method {
187+ m.drawCache = newChatDrawCache(rendered, method)
188+ }
189+ drawCachedBuffer(scr, listArea, m.drawCache.buf)
133190 }
134− if m.drawCache == nil ||
135− m.drawCache.rendered != rendered ||
136− m.drawCache.method != method {
137− m.drawCache = newChatDrawCache(rendered, method)
191+
192+ // Draw scrollbar if visible and needed.
193+ if scrollbarWidth > 0 {
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},
199+ }
200+ uv.NewStyledString(scrollbar).Draw(scr, scrollbarArea)
201+ }
138202 }
139− drawCachedBuffer(scr, area, m.drawCache.buf)
140203}
141204
142205// 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) {
206269
207270// SetSize sets the size of the chat view port.
208271func (m *Chat) SetSize(width, height int) {
209− m.list.SetSize(width, height)
272+ // Reserve space for scrollbar if content exceeds viewport height.
273+ listWidth := width
274+ if m.list.TotalHeight() > height {
275+ listWidth = max(0, width-1)
276+ }
277+ m.list.SetSize(listWidth, height)
210278 // Anchor to bottom if we were at the bottom.
211279 if m.AtBottom() {
212280 m.ScrollToBottom()
@@ -231,7 +299,7 @@ func (m *Chat) InvalidateRenderCaches() {
231299}
232300
233301// 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 {
235303 m.idInxMap = make(map[string]int)
236304 m.pausedAnimations = make(map[string]struct{})
237305
@@ -247,7 +315,7 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
247315 items[i] = msg
248316 }
249317 m.list.SetItems(items...)
250− m.ScrollToBottom()
318+ return m.ScrollToBottom()
251319}
252320
253321// AppendMessages appends a new message item to the chat list.
@@ -378,61 +446,84 @@ func (m *Chat) Follow() bool {
378446}
379447
380448// ScrollToBottom scrolls the chat view to the bottom.
381−func (m *Chat) ScrollToBottom() {
449+func (m *Chat) ScrollToBottom() tea.Cmd {
382450 m.list.ScrollToBottom()
383451 m.follow = true // Enable follow mode when user scrolls to bottom
452+ return m.showScrollbar()
384453}
385454
386455// ScrollToTop scrolls the chat view to the top.
387−func (m *Chat) ScrollToTop() {
456+func (m *Chat) ScrollToTop() tea.Cmd {
388457 m.list.ScrollToTop()
389458 m.follow = false // Disable follow mode when user scrolls up
459+ return m.showScrollbar()
390460}
391461
392462// 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 {
394464 m.list.ScrollBy(lines)
395465 m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
466+ return m.showScrollbar()
396467}
397468
398469// ScrollToSelected scrolls the chat view to the selected item.
399−func (m *Chat) ScrollToSelected() {
470+func (m *Chat) ScrollToSelected() tea.Cmd {
400471 m.list.ScrollToSelected()
401472 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
473+ return m.showScrollbar()
402474}
403475
404476// 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 {
406478 m.list.ScrollToIndex(index)
407479 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
480+ return m.showScrollbar()
481+}
482+
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 {
487+ return nil
488+ }
489+ m.scrollbarVisible = true
490+ m.scrollbarHideSeq++
491+ return scrollbarHideCmd(m.scrollbarHideSeq)
492+}
493+
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 {
498+ return
499+ }
500+ if seq == m.scrollbarHideSeq {
501+ m.scrollbarVisible = false
502+ }
408503}
409504
410505// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
411506// any paused animations that are now visible.
412507func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
413− m.ScrollToTop()
414− return m.RestartPausedVisibleAnimations()
508+ return tea.Batch(m.ScrollToTop(), m.RestartPausedVisibleAnimations())
415509}
416510
417511// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
418512// restart any paused animations that are now visible.
419513func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
420− m.ScrollToBottom()
421− return m.RestartPausedVisibleAnimations()
514+ return tea.Batch(m.ScrollToBottom(), m.RestartPausedVisibleAnimations())
422515}
423516
424517// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
425518// a command to restart any paused animations that are now visible.
426519func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
427− m.ScrollBy(lines)
428− return m.RestartPausedVisibleAnimations()
520+ return tea.Batch(m.ScrollBy(lines), m.RestartPausedVisibleAnimations())
429521}
430522
431523// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
432524// command to restart any paused animations that are now visible.
433525func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
434− m.ScrollToSelected()
435− return m.RestartPausedVisibleAnimations()
526+ return tea.Batch(m.ScrollToSelected(), m.RestartPausedVisibleAnimations())
436527}
437528
438529// SelectedItemInView returns whether the selected item is currently in view.