on main help sign in
feat(ui): add scrollbar to chat view (#3018)

New setting added to control it. * `options.tui.scrollbar` * `default` (hides after ~2s) * `always` * `never` Assisted-by: Crush:qwen3.6-max-preview Assisted-by: Crush:qwen3.7-plus

commitb72f9aab844ab42de7bcf778e3fcf743a010d0bd authorAndrey Nering <andreynering@users.noreply.github.com> dateMon 29 Jun 2026, 16:01 (4 months ago) parentd3d6804
7 files changed+152−30
internal/agent/agent.go+2−1 internal/agent/coordinator.go+2−1 internal/config/config.go+8 internal/ui/model/chat.go+115−24 internal/ui/model/layout_test.go+2−1 internal/ui/model/ui.go+13−3 schema.json+10
internal/agent/agent.go+2−1
@@ -2215,7 +2215,8 @@ func providerRetryLogFields(err *fantasy.ProviderError, delay time.Duration) []a
22152215// The second return value indicates whether sanitization occurred.
22162216func sanitizeToolInput(toolName, toolCallID, input string) (string, bool) {
22172217 if !json.Valid([]byte(input)) {
2218− slog.Warn("Malformed tool call JSON from provider, replacing with empty object",
2218+ slog.Warn(
2219+ "Malformed tool call JSON from provider, replacing with empty object",
22192220 "tool", toolName,
22202221 "id", toolCallID,
22212222 "input_len", len(input),
internal/agent/coordinator.go+2−1
@@ -1326,7 +1326,8 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
13261326 // Update parent session cost on a best-effort basis. A failure here must
13271327 // not discard the sub-agent output that was already produced.
13281328 if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
1329− slog.Warn("Failed to update parent session cost",
1329+ slog.Warn(
1330+ "Failed to update parent session cost",
13301331 "child_session", session.ID,
13311332 "parent_session", params.SessionID,
13321333 "error", err,
internal/config/config.go+8
@@ -226,6 +226,7 @@ type TUIOptions struct {
226226
227227 Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
228228 Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
229+ Scrollbar string `json:"scrollbar,omitempty" jsonschema:"description=Chat scrollbar visibility,enum=default,enum=always,enum=never,default=default"`
229230}
230231
231232// Completions defines options for the completions UI.
@@ -238,6 +239,13 @@ func (c Completions) Limits() (depth, items int) {
238239 return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0)
239240}
240241
242+// Scrollbar visibility options.
243+const (
244+ ScrollbarDefault = "default" // Auto-hide after 2 seconds
245+ ScrollbarAlways = "always" // Always show when content exceeds viewport
246+ ScrollbarNever = "never" // Never show scrollbar
247+)
248+
241249type Permissions struct {
242250 AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
243251}
internal/ui/model/chat.go+115−24
@@ -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.
internal/ui/model/layout_test.go+2−1
@@ -6,6 +6,7 @@ import (
66 "testing"
77
88 "charm.land/bubbles/v2/textarea"
9+ "github.com/charmbracelet/crush/internal/config"
910 "github.com/charmbracelet/crush/internal/session"
1011 "github.com/charmbracelet/crush/internal/ui/chat"
1112 "github.com/charmbracelet/crush/internal/ui/common"
@@ -45,7 +46,7 @@ func newTestUI() *UI {
4546 u := &UI{
4647 com: com,
4748 status: NewStatus(com, nil),
48− chat: NewChat(com),
49+ chat: NewChat(com, config.ScrollbarDefault),
4950 textarea: ta,
5051 state: uiChat,
5152 focus: uiFocusEditor,
internal/ui/model/ui.go+13−3
@@ -330,7 +330,11 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
330330 ta.MaxHeight = TextareaMaxHeight
331331 ta.Focus()
332332
333− ch := NewChat(com)
333+ scrollbarMode := config.ScrollbarDefault
334+ if cfg := com.Config(); cfg.Options.TUI != nil && cfg.Options.TUI.Scrollbar != "" {
335+ scrollbarMode = cfg.Options.TUI.Scrollbar
336+ }
337+ ch := NewChat(com, scrollbarMode)
334338
335339 keyMap := DefaultKeyMap()
336340
@@ -961,6 +965,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
961965 }
962966 }
963967 }
968+ case scrollbarHideMsg:
969+ if m.state == uiChat {
970+ m.chat.HideScrollbar(msg.seq)
971+ }
964972 case spinner.TickMsg:
965973 if m.dialog.HasDialogs() {
966974 // route to dialog
@@ -1154,8 +1162,10 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
11541162 }
11551163 }
11561164
1157− m.chat.SetMessages(items...)
1158− if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1165+ if cmd := m.chat.SetMessages(items...); cmd != nil {
1166+ cmds = append(cmds, cmd)
1167+ }
1168+ if cmd := m.chat.RestartPausedVisibleAnimations(); cmd != nil {
11591169 cmds = append(cmds, cmd)
11601170 }
11611171 m.chat.SelectLast()
schema.json+10
@@ -756,6 +756,16 @@
756756 "type": "boolean",
757757 "description": "Enable transparent background for the TUI interface",
758758 "default": false
759+ },
760+ "scrollbar": {
761+ "type": "string",
762+ "enum": [
763+ "default",
764+ "always",
765+ "never"
766+ ],
767+ "description": "Chat scrollbar visibility",
768+ "default": "default"
759769 }
760770 },
761771 "additionalProperties": false,