on main help sign in
feat(ui): add scrollbar to chat view (#3018)
b72f9aaAndrey Nering
internal/agent/agent.go+2−1
@@ -2215,7 +2215,8 @@ func providerRetryLogFields(err *fantasy.ProviderError, delay time.Duration) []a
2215// The second return value indicates whether sanitization occurred.
2215// The second return value indicates whether sanitization occurred.
2216func sanitizeToolInput(toolName, toolCallID, input string) (string, bool) {
2216func sanitizeToolInput(toolName, toolCallID, input string) (string, bool) {
2217 if !json.Valid([]byte(input)) {
2217 if !json.Valid([]byte(input)) {
2218+ slog.Warn(
2218− slog.Warn("Malformed tool call JSON from provider, replacing with empty object",
2219+ "Malformed tool call JSON from provider, replacing with empty object",
2219 "tool", toolName,
2220 "tool", toolName,
2220 "id", toolCallID,
2221 "id", toolCallID,
2221 "input_len", len(input),
2222 "input_len", len(input),
internal/agent/coordinator.go+2−1
@@ -1326,7 +1326,8 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
1326 // Update parent session cost on a best-effort basis. A failure here must
1326 // Update parent session cost on a best-effort basis. A failure here must
1327 // not discard the sub-agent output that was already produced.
1327 // not discard the sub-agent output that was already produced.
1328 if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
1328 if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
1329+ slog.Warn(
1329− slog.Warn("Failed to update parent session cost",
1330+ "Failed to update parent session cost",
1330 "child_session", session.ID,
1331 "child_session", session.ID,
1331 "parent_session", params.SessionID,
1332 "parent_session", params.SessionID,
1332 "error", err,
1333 "error", err,
internal/config/config.go+8
@@ -226,6 +226,7 @@ type TUIOptions struct {
226
226
227 Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
227 Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
228 Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
228 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"`
229}
230}
230
231
231// Completions defines options for the completions UI.
232// Completions defines options for the completions UI.
@@ -238,6 +239,13 @@ func (c Completions) Limits() (depth, items int) {
238 return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0)
239 return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0)
239}
240}
240
241
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+
241type Permissions struct {
249type Permissions struct {
242 AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
250 AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
243}
251}
internal/ui/model/chat.go+115−24
@@ -1,11 +1,13 @@
1package model
1package model
2
2
3import (
3import (
4+ "image"
4 "strings"
5 "strings"
5 "time"
6 "time"
6
7
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 {
30 X, Y int
32 X, Y int
31}
33}
32
34
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+
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
87+
88+ // Scrollbar visibility state
89+ scrollbarVisible bool
90+ scrollbarHideSeq int // current sequence number for hide timer
91+ scrollbarMode string // "default", "always", or "never"
73}
92}
74
93
94+// scrollbarHideDuration is how long the scrollbar remains visible after scroll activity.
95+const scrollbarHideDuration = 2 * time.Second
96+
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 {
93
115
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,
101 }
124 }
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
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+
125 rendered := m.list.Render()
176 rendered := m.list.Render()
126 method, ok := scr.WidthMethod().(ansi.Method)
177 method, ok := scr.WidthMethod().(ansi.Method)
127 if !ok {
178 if !ok {
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)
132− return
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)
133 }
190 }
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},
199+ }
200+ uv.NewStyledString(scrollbar).Draw(scr, scrollbarArea)
201+ }
138 }
202 }
139− drawCachedBuffer(scr, area, m.drawCache.buf)
140}
203}
141
204
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) {
206
269
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.
273+ listWidth := width
274+ if m.list.TotalHeight() > height {
275+ listWidth = max(0, width-1)
276+ }
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() {
231}
299}
232
300
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{})
237
305
@@ -247,7 +315,7 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
247 items[i] = msg
315 items[i] = msg
248 }
316 }
249 m.list.SetItems(items...)
317 m.list.SetItems(items...)
250− m.ScrollToBottom()
318+ return m.ScrollToBottom()
251}
319}
252
320
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 {
378}
446}
379
447
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()
384}
453}
385
454
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()
390}
460}
391
461
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()
396}
467}
397
468
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()
402}
474}
403
475
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()
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+ }
408}
503}
409
504
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 {
413− m.ScrollToTop()
414− return m.RestartPausedVisibleAnimations()
508+ return tea.Batch(m.ScrollToTop(), m.RestartPausedVisibleAnimations())
415}
509}
416
510
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 {
420− m.ScrollToBottom()
421− return m.RestartPausedVisibleAnimations()
514+ return tea.Batch(m.ScrollToBottom(), m.RestartPausedVisibleAnimations())
422}
515}
423
516
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 {
427− m.ScrollBy(lines)
428− return m.RestartPausedVisibleAnimations()
520+ return tea.Batch(m.ScrollBy(lines), m.RestartPausedVisibleAnimations())
429}
521}
430
522
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())
436}
527}
437
528
438// SelectedItemInView returns whether the selected item is currently in view.
529// SelectedItemInView returns whether the selected item is currently in view.
internal/ui/model/layout_test.go+2−1
@@ -6,6 +6,7 @@ import (
6 "testing"
6 "testing"
7
7
8 "charm.land/bubbles/v2/textarea"
8 "charm.land/bubbles/v2/textarea"
9+ "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/ui/chat"
11 "github.com/charmbracelet/crush/internal/ui/chat"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/common"
@@ -45,7 +46,7 @@ func newTestUI() *UI {
45 u := &UI{
46 u := &UI{
46 com: com,
47 com: com,
47 status: NewStatus(com, nil),
48 status: NewStatus(com, nil),
48− chat: NewChat(com),
49+ chat: NewChat(com, config.ScrollbarDefault),
49 textarea: ta,
50 textarea: ta,
50 state: uiChat,
51 state: uiChat,
51 focus: uiFocusEditor,
52 focus: uiFocusEditor,
internal/ui/model/ui.go+13−3
@@ -330,7 +330,11 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
330 ta.MaxHeight = TextareaMaxHeight
330 ta.MaxHeight = TextareaMaxHeight
331 ta.Focus()
331 ta.Focus()
332
332
333+ scrollbarMode := config.ScrollbarDefault
334+ if cfg := com.Config(); cfg.Options.TUI != nil && cfg.Options.TUI.Scrollbar != "" {
335+ scrollbarMode = cfg.Options.TUI.Scrollbar
336+ }
333− ch := NewChat(com)
337+ ch := NewChat(com, scrollbarMode)
334
338
335 keyMap := DefaultKeyMap()
339 keyMap := DefaultKeyMap()
336
340
@@ -961,6 +965,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
961 }
965 }
962 }
966 }
963 }
967 }
968+ case scrollbarHideMsg:
969+ if m.state == uiChat {
970+ m.chat.HideScrollbar(msg.seq)
971+ }
964 case spinner.TickMsg:
972 case spinner.TickMsg:
965 if m.dialog.HasDialogs() {
973 if m.dialog.HasDialogs() {
966 // route to dialog
974 // route to dialog
@@ -1154,8 +1162,10 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
1154 }
1162 }
1155 }
1163 }
1156
1164
1157− m.chat.SetMessages(items...)
1165+ if cmd := m.chat.SetMessages(items...); cmd != nil {
1158− if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1166+ cmds = append(cmds, cmd)
1167+ }
1168+ if cmd := m.chat.RestartPausedVisibleAnimations(); cmd != nil {
1159 cmds = append(cmds, cmd)
1169 cmds = append(cmds, cmd)
1160 }
1170 }
1161 m.chat.SelectLast()
1171 m.chat.SelectLast()
schema.json+10
@@ -756,6 +756,16 @@
756 "type": "boolean",
756 "type": "boolean",
757 "description": "Enable transparent background for the TUI interface",
757 "description": "Enable transparent background for the TUI interface",
758 "default": false
758 "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"
759 }
769 }
760 },
770 },
761 "additionalProperties": false,
771 "additionalProperties": false,