From e33becae6b475b2e7904ac52953f291381fcaf31 Mon Sep 17 00:00:00 2001 From: Christopher Petito Date: Mon, 9 Feb 2026 13:54:27 +0100 Subject: [PATCH] refactor scrollbar into more reusable scrollview component also fixes some minor scrollbar positioning and ux bugs in certain scenarios when dragging the scrollbar with a mouse Signed-off-by: Christopher Petito --- pkg/tui/components/messages/messages.go | 113 +++---- pkg/tui/components/messages/messages_test.go | 4 +- pkg/tui/components/scrollview/scrollview.go | 324 +++++++++++++++++++ pkg/tui/components/sidebar/layout_test.go | 2 +- pkg/tui/components/sidebar/sidebar.go | 117 +++---- pkg/tui/dialog/command_palette.go | 291 +++++------------ pkg/tui/dialog/cost.go | 77 ++--- pkg/tui/dialog/file_picker.go | 147 ++++----- pkg/tui/dialog/model_picker.go | 299 ++++------------- pkg/tui/dialog/model_picker_test.go | 16 +- pkg/tui/dialog/permissions.go | 83 ++--- pkg/tui/dialog/session_browser.go | 152 ++++----- pkg/tui/dialog/session_browser_test.go | 11 +- pkg/tui/dialog/theme_picker.go | 320 ++++-------------- pkg/tui/page/chat/chat.go | 4 +- pkg/tui/page/chat/input_handlers.go | 38 ++- 16 files changed, 869 insertions(+), 1129 deletions(-) create mode 100644 pkg/tui/components/scrollview/scrollview.go diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index e1853d358..028ebbc1c 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -21,7 +21,7 @@ import ( "github.com/docker/cagent/pkg/tui/animation" "github.com/docker/cagent/pkg/tui/components/message" "github.com/docker/cagent/pkg/tui/components/reasoningblock" - "github.com/docker/cagent/pkg/tui/components/scrollbar" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/components/tool" "github.com/docker/cagent/pkg/tui/components/tool/editfile" "github.com/docker/cagent/pkg/tui/core" @@ -61,6 +61,9 @@ type Model interface { AdjustBottomSlack(delta int) ScrollByWheel(delta int) + // IsScrollbarDragging returns true when the scrollbar thumb is being dragged. + IsScrollbarDragging() bool + // Inline editing methods StartInlineEdit(msgIndex, sessionPosition int, content string) tea.Cmd CancelInlineEdit() tea.Cmd @@ -99,7 +102,7 @@ type model struct { selection selectionState sessionState *service.SessionState - scrollbar *scrollbar.Model + scrollview *scrollview.Model xPos, yPos int @@ -133,12 +136,16 @@ func NewScrollableView(width, height int, sessionState *service.SessionState) Mo } func newModel(width, height int, sessionState *service.SessionState) *model { + sv := scrollview.New( + scrollview.WithReserveScrollbarSpace(true), + ) + sv.SetSize(width, height) return &model{ width: width, height: height, renderedItems: make(map[int]renderedItem), sessionState: sessionState, - scrollbar: scrollbar.New(), + scrollview: sv, selectedMessageIndex: -1, inlineEditMsgIndex: -1, debugLayout: os.Getenv("CAGENT_EXPERIMENTAL_DEBUG_LAYOUT") == "1", @@ -247,7 +254,7 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { func (m *model) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) { if m.isMouseOnScrollbar(msg.X, msg.Y) { - return m.handleScrollbarUpdate(msg) + return m.handleScrollviewUpdate(msg) } if msg.Button != tea.MouseLeft { @@ -325,8 +332,8 @@ func (m *model) globalLineToMessageLine(globalLine int) (msgIdx, localLine int) } func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd) { - if m.scrollbar.IsDragging() { - return m.handleScrollbarUpdate(msg) + if m.scrollview.IsDragging() { + return m.handleScrollviewUpdate(msg) } if m.selection.mouseButtonDown && m.selection.active { @@ -340,7 +347,7 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd } func (m *model) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.Cmd) { - if updated, cmd := m.handleScrollbarUpdate(msg); cmd != nil { + if updated, cmd := m.handleScrollviewUpdate(msg); cmd != nil { return updated, cmd } @@ -518,61 +525,38 @@ func (m *model) View() string { visibleLines = m.applySelectionHighlight(visibleLines, startLine) } - m.scrollbar.SetDimensions(m.height, m.totalHeight) - m.scrollbar.SetScrollOffset(m.scrollOffset) - - // Truncate lines that exceed content width to prevent scrollbar from wrapping - // When debug layout is enabled, lines that need truncation are displayed with red background - contentWidth := m.contentWidth() - for i, line := range visibleLines { - if ansi.StringWidth(line) > contentWidth { - truncated := ansi.Truncate(line, contentWidth, "") - if m.debugLayout { + // Apply debug layout highlighting for truncated lines + if m.debugLayout { + contentWidth := m.contentWidth() + for i, line := range visibleLines { + if ansi.StringWidth(line) > contentWidth { + truncated := ansi.Truncate(line, contentWidth, "") visibleLines[i] = styles.BaseStyle.Background(styles.Error).Render(ansi.Strip(truncated)) - } else { - visibleLines[i] = truncated } } } - scrollbarView := m.scrollbar.View() - - if scrollbarView != "" { - // Ensure content is exactly m.height lines by padding with empty lines if needed - for len(visibleLines) < m.height { - visibleLines = append(visibleLines, "") - } - // Truncate if somehow longer (shouldn't happen but safety check) - if len(visibleLines) > m.height { - visibleLines = visibleLines[:m.height] - } - contentView := strings.Join(visibleLines, "\n") - - // Create spacer with exactly m.height lines - spacerLines := make([]string, m.height) - for i := range spacerLines { - spacerLines[i] = " " // Single space for each line - } - spacer := strings.Join(spacerLines, "\n") - - return lipgloss.JoinHorizontal(lipgloss.Top, contentView, spacer, scrollbarView) - } - - return strings.Join(visibleLines, "\n") + // Sync scroll state and delegate rendering to scrollview which guarantees + // fixed-width padding, pinned scrollbar, and exact height. + m.scrollview.SetContent(m.renderedLines, m.totalScrollableHeight()) + m.scrollview.SetScrollOffset(m.scrollOffset) + return m.scrollview.ViewWithLines(visibleLines) } // SetSize sets the dimensions of the component func (m *model) SetSize(width, height int) tea.Cmd { + if m.width == width && m.height == height { + return nil // Dimensions unchanged — skip expensive cache invalidation + } m.width = width m.height = height - // Content width reserves space for scrollbar (2 chars: space + scrollbar) + m.scrollview.SetSize(width, height) contentWidth := m.contentWidth() for _, view := range m.views { view.SetSize(contentWidth, 0) } - m.scrollbar.SetPosition(1+m.xPos+contentWidth+1, m.yPos) m.invalidateAllItems() return nil } @@ -580,6 +564,7 @@ func (m *model) SetSize(width, height int) tea.Cmd { func (m *model) SetPosition(x, y int) tea.Cmd { m.xPos = x m.yPos = y + m.scrollview.SetPosition(x, y) return nil } @@ -737,7 +722,7 @@ func (m *model) scrollByWheel(delta int) { func (m *model) setScrollOffset(offset int) { maxOffset := max(0, m.totalScrollableHeight()-m.height) m.scrollOffset = max(0, min(offset, maxOffset)) - m.scrollbar.SetScrollOffset(m.scrollOffset) + m.scrollview.SetScrollOffset(m.scrollOffset) } func (m *model) isAtBottom() bool { @@ -856,15 +841,16 @@ func (m *model) scrollToSelectedMessage() { } endLine := startLine + selectedHeight - // Scroll to make the selected message visible - if startLine < m.scrollOffset { - m.userHasScrolled = true - m.bottomSlack = 0 - m.setScrollOffset(startLine) - } else if endLine > m.scrollOffset+m.height { + prevOffset := m.scrollOffset + m.scrollview.SetContent(m.renderedLines, m.totalScrollableHeight()) + m.scrollview.SetScrollOffset(m.scrollOffset) + m.scrollview.EnsureRangeVisible(startLine, endLine-1) + + newOffset := m.scrollview.ScrollOffset() + if newOffset != prevOffset { m.userHasScrolled = true m.bottomSlack = 0 - m.setScrollOffset(endLine - m.height) + m.setScrollOffset(newOffset) } } @@ -1488,9 +1474,9 @@ func (m *model) AdjustBottomSlack(delta int) { } // contentWidth returns the width available for content. -// Always reserves 2 chars for scrollbar (space + bar) to prevent layout shifts. +// Always reserves space for scrollbar (gap + bar) to prevent layout shifts. func (m *model) contentWidth() int { - return m.width - 2 + return m.scrollview.ContentWidth() } func (m *model) totalScrollableHeight() int { @@ -1582,7 +1568,7 @@ func (m *model) isEditLabelClick(msgIdx, localLine, col int) (bool, *types.Messa } func (m *model) mouseToLineCol(x, y int) (line, col int) { - adjustedX := max(0, x-1-m.xPos) + adjustedX := max(0, x-m.xPos) adjustedY := max(0, y-m.yPos) return m.scrollOffset + adjustedY, adjustedX } @@ -1591,17 +1577,18 @@ func (m *model) isMouseOnScrollbar(x, y int) bool { if m.totalHeight <= m.height { return false } - // Scrollbar is at: 1 (app padding) + xPos + contentWidth + 1 (spacer) - scrollbarX := 1 + m.xPos + m.contentWidth() + 1 - return x == scrollbarX && y >= m.yPos && y < m.yPos+m.height + return x == m.scrollview.ScrollbarX() && y >= m.yPos && y < m.yPos+m.height +} + +func (m *model) IsScrollbarDragging() bool { + return m.scrollview.IsDragging() } -func (m *model) handleScrollbarUpdate(msg tea.Msg) (layout.Model, tea.Cmd) { - sb, cmd := m.scrollbar.Update(msg) - m.scrollbar = sb +func (m *model) handleScrollviewUpdate(msg tea.Msg) (layout.Model, tea.Cmd) { + _, cmd := m.scrollview.UpdateMouse(msg) m.userHasScrolled = true m.bottomSlack = 0 - m.scrollOffset = m.scrollbar.GetScrollOffset() + m.scrollOffset = m.scrollview.ScrollOffset() return m, cmd } diff --git a/pkg/tui/components/messages/messages_test.go b/pkg/tui/components/messages/messages_test.go index 040502a8f..c83776070 100644 --- a/pkg/tui/components/messages/messages_test.go +++ b/pkg/tui/components/messages/messages_test.go @@ -844,7 +844,7 @@ func BenchmarkMessagesView_RenderWhileScrolling(b *testing.B) { for i := range b.N { // Vary scroll position to simulate wheel scrolling m.scrollOffset = (i % 50) * 2 - m.scrollbar.SetScrollOffset(m.scrollOffset) + m.scrollview.SetScrollOffset(m.scrollOffset) _ = m.View() } } @@ -871,7 +871,7 @@ func BenchmarkMessagesView_LargeHistory(b *testing.B) { for i := range b.N { m.scrollOffset = (i % 100) * 5 - m.scrollbar.SetScrollOffset(m.scrollOffset) + m.scrollview.SetScrollOffset(m.scrollOffset) _ = m.View() } } diff --git a/pkg/tui/components/scrollview/scrollview.go b/pkg/tui/components/scrollview/scrollview.go new file mode 100644 index 000000000..134db8d64 --- /dev/null +++ b/pkg/tui/components/scrollview/scrollview.go @@ -0,0 +1,324 @@ +// Package scrollview provides a composable scrollable view that pairs content +// with a fixed-position scrollbar. It guarantees that every rendered line is +// exactly [Model.width] columns wide and exactly [Model.height] lines tall +// +// Simple path: call [Model.Update] + [Model.View]. +// Advanced path (custom scroll management): use [Model.UpdateMouse], +// [Model.SetScrollOffset], and [Model.ViewWithLines]. +package scrollview + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/docker/cagent/pkg/tui/components/scrollbar" +) + +// ScrollKeyMap defines which keys trigger scroll actions. +type ScrollKeyMap struct { + Up key.Binding // optional — leave unset for list dialogs that use up/down for selection + Down key.Binding + PageUp key.Binding + PageDown key.Binding + Top key.Binding // home + Bottom key.Binding // end +} + +// DefaultScrollKeyMap returns a key map with page-up/down and home/end. +// Up/Down are intentionally unbound so list dialogs can use them for selection. +func DefaultScrollKeyMap() *ScrollKeyMap { + return &ScrollKeyMap{ + PageUp: key.NewBinding(key.WithKeys("pgup")), + PageDown: key.NewBinding(key.WithKeys("pgdown")), + Top: key.NewBinding(key.WithKeys("home")), + Bottom: key.NewBinding(key.WithKeys("end")), + } +} + +// ReadOnlyScrollKeyMap returns a key map where up/down/j/k also scroll. +func ReadOnlyScrollKeyMap() *ScrollKeyMap { + return &ScrollKeyMap{ + Up: key.NewBinding(key.WithKeys("up", "k")), + Down: key.NewBinding(key.WithKeys("down", "j")), + PageUp: key.NewBinding(key.WithKeys("pgup")), + PageDown: key.NewBinding(key.WithKeys("pgdown")), + Top: key.NewBinding(key.WithKeys("home")), + Bottom: key.NewBinding(key.WithKeys("end")), + } +} + +type Option func(*Model) + +// WithGapWidth sets the space columns between content and scrollbar (default 1). +func WithGapWidth(n int) Option { return func(m *Model) { m.gapWidth = n } } + +// WithReserveScrollbarSpace always reserves gap+scrollbar columns, preventing layout shifts. +func WithReserveScrollbarSpace(v bool) Option { + return func(m *Model) { m.reserveScrollbarSpace = v } +} + +// WithWheelStep sets lines scrolled per wheel tick (default 2). +func WithWheelStep(n int) Option { return func(m *Model) { m.wheelStep = n } } + +// WithKeyMap sets keyboard bindings for scroll actions. Pass nil to disable. +func WithKeyMap(km *ScrollKeyMap) Option { return func(m *Model) { m.keyMap = km } } + +// Model is a composable scrollable view that owns a scrollbar and ensures +// fixed-width rendering. +type Model struct { + sb *scrollbar.Model + + xPos, yPos int + width, height int + + gapWidth int + reserveScrollbarSpace bool + wheelStep int + keyMap *ScrollKeyMap + + lines []string + totalHeight int + + // scrollOffset tracks the desired scroll position independently of the + // scrollbar, so EnsureLineVisible works before SetContent is called. + scrollOffset int +} + +// New creates a new scrollview with the given options. +func New(opts ...Option) *Model { + m := &Model{ + sb: scrollbar.New(), + gapWidth: 1, + wheelStep: 2, + keyMap: DefaultScrollKeyMap(), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// SetSize sets the total width and height of the scrollable region. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height + m.updateScrollbarPosition() +} + +// SetPosition sets the absolute screen position (for mouse hit-testing). +func (m *Model) SetPosition(x, y int) { + m.xPos = x + m.yPos = y + m.updateScrollbarPosition() +} + +// SetContent provides the full content buffer and total height. +// totalHeight may be >= len(lines) for virtual blank lines (e.g. bottomSlack). +func (m *Model) SetContent(lines []string, totalHeight int) { + m.lines = lines + m.totalHeight = max(totalHeight, len(lines)) + m.sb.SetDimensions(m.height, m.totalHeight) +} + +// NeedsScrollbar returns true if content is taller than the viewport. +func (m *Model) NeedsScrollbar() bool { return m.totalHeight > m.height } + +// ContentWidth returns the width available for content text. +func (m *Model) ContentWidth() int { + if m.reserveScrollbarSpace || m.NeedsScrollbar() { + return max(1, m.width-m.gapWidth-scrollbar.Width) + } + return max(1, m.width) +} + +// ReservedCols returns columns reserved for gap + scrollbar. +func (m *Model) ReservedCols() int { return m.gapWidth + scrollbar.Width } + +// VisibleHeight returns the viewport height in lines. +func (m *Model) VisibleHeight() int { return m.height } + +// ScrollbarX returns the absolute screen X of the scrollbar column. +func (m *Model) ScrollbarX() int { return m.xPos + m.width - scrollbar.Width } + +// ScrollOffset returns the current scroll offset. +func (m *Model) ScrollOffset() int { return m.scrollOffset } + +// SetScrollOffset sets the scroll offset, clamped when content dimensions are known. +func (m *Model) SetScrollOffset(offset int) { + m.scrollOffset = max(0, offset) + if m.totalHeight > 0 && m.height > 0 { + m.scrollOffset = min(m.scrollOffset, max(0, m.totalHeight-m.height)) + } + m.sb.SetScrollOffset(m.scrollOffset) +} + +// ScrollBy adjusts the scroll offset by delta lines. +func (m *Model) ScrollBy(delta int) { m.SetScrollOffset(m.scrollOffset + delta) } +func (m *Model) LineUp() { m.ScrollBy(-1) } +func (m *Model) LineDown() { m.ScrollBy(1) } +func (m *Model) PageUp() { m.ScrollBy(-m.height) } +func (m *Model) PageDown() { m.ScrollBy(m.height) } +func (m *Model) ScrollToTop() { m.SetScrollOffset(0) } +func (m *Model) ScrollToBottom() { m.SetScrollOffset(m.totalHeight) } + +// EnsureLineVisible scrolls minimally to bring a line into the viewport. +// Works before [SetContent] — only needs [SetSize]. +func (m *Model) EnsureLineVisible(line int) { + m.EnsureRangeVisible(line, line) +} + +// EnsureRangeVisible scrolls minimally to bring lines startLine..endLine into +// the view. If the range is taller than the view, the start is prioritized. +func (m *Model) EnsureRangeVisible(startLine, endLine int) { + startLine = max(0, startLine) + endLine = max(startLine, endLine) + if endLine >= m.scrollOffset+m.height { + m.SetScrollOffset(endLine - m.height + 1) + } + if startLine < m.scrollOffset { + m.SetScrollOffset(startLine) + } +} + +// Update handles mouse (scrollbar click/drag/wheel) and keyboard scroll events. +// Returns handled=true when the event was consumed. +func (m *Model) Update(msg tea.Msg) (handled bool, cmd tea.Cmd) { + m.updateScrollbarPosition() // Ensure scrollbar position is fresh for hit-testing + switch msg := msg.(type) { + case tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg: + return m.UpdateMouse(msg) + + case tea.MouseWheelMsg: + switch msg.Button.String() { + case "wheelup": + m.ScrollBy(-m.wheelStep) + return true, nil + case "wheeldown": + m.ScrollBy(m.wheelStep) + return true, nil + } + + case tea.KeyPressMsg: + if m.keyMap == nil { + return false, nil + } + switch { + case m.keyMap.Up.Enabled() && key.Matches(msg, m.keyMap.Up): + m.LineUp() + return true, nil + case m.keyMap.Down.Enabled() && key.Matches(msg, m.keyMap.Down): + m.LineDown() + return true, nil + case key.Matches(msg, m.keyMap.PageUp): + m.PageUp() + return true, nil + case key.Matches(msg, m.keyMap.PageDown): + m.PageDown() + return true, nil + case key.Matches(msg, m.keyMap.Top): + m.ScrollToTop() + return true, nil + case key.Matches(msg, m.keyMap.Bottom): + m.ScrollToBottom() + return true, nil + } + } + return false, nil +} + +// UpdateMouse delegates mouse events to the scrollbar. Low-level alternative to [Update]. +func (m *Model) UpdateMouse(msg tea.Msg) (handled bool, cmd tea.Cmd) { + prev := m.scrollOffset + sb, c := m.sb.Update(msg) + m.sb = sb + m.scrollOffset = m.sb.GetScrollOffset() + return m.scrollOffset != prev || m.sb.IsDragging(), c +} + +// IsDragging returns whether the scrollbar thumb is being dragged. +func (m *Model) IsDragging() bool { return m.sb.IsDragging() } + +// View renders the scrollable region with automatic content slicing. +// Output is exactly width columns wide and height lines tall. +func (m *Model) View() string { + if m.width <= 0 || m.height <= 0 { + return "" + } + m.syncScrollbar() + + // Slice visible window from content + visible := make([]string, m.height) + for i := range m.height { + if idx := m.scrollOffset + i; idx < len(m.lines) { + visible[i] = m.lines[idx] + } + } + return m.compose(visible) +} + +// ViewWithLines renders pre-sliced visible lines with the scrollbar. +// The caller provides exactly the visible window; scrollview handles +// padding, truncation, and scrollbar composition. +func (m *Model) ViewWithLines(visibleLines []string) string { + if m.width <= 0 || m.height <= 0 { + return "" + } + m.syncScrollbar() + + result := make([]string, m.height) + copy(result, visibleLines) + return m.compose(result) +} + +// syncScrollbar syncs the local scroll offset to the scrollbar and reads back the clamped value. +func (m *Model) syncScrollbar() { + m.sb.SetDimensions(m.height, m.totalHeight) + m.sb.SetScrollOffset(m.scrollOffset) + m.scrollOffset = m.sb.GetScrollOffset() +} + +// compose pads/truncates lines to contentWidth and joins with the scrollbar column. +// lines must have exactly m.height entries. +func (m *Model) compose(lines []string) string { + contentWidth := m.ContentWidth() + + // Pad or truncate each line to exact content width + for i, line := range lines { + w := ansi.StringWidth(line) + switch { + case w > contentWidth: + lines[i] = ansi.Truncate(line, contentWidth, "") + case w < contentWidth: + lines[i] = line + strings.Repeat(" ", contentWidth-w) + } + } + + contentView := strings.Join(lines, "\n") + + // Build the right-side column (scrollbar, placeholder, or nothing) + if m.NeedsScrollbar() { + return lipgloss.JoinHorizontal(lipgloss.Top, contentView, m.buildColumn(m.gapWidth), m.sb.View()) + } + if m.reserveScrollbarSpace { + return lipgloss.JoinHorizontal(lipgloss.Top, contentView, m.buildColumn(m.gapWidth+scrollbar.Width)) + } + return contentView +} + +// buildColumn returns a column of spaces with the given width and m.height lines. +func (m *Model) buildColumn(colWidth int) string { + col := strings.Repeat(" ", colWidth) + lines := make([]string, m.height) + for i := range lines { + lines[i] = col + } + return strings.Join(lines, "\n") +} + +func (m *Model) updateScrollbarPosition() { + m.sb.SetPosition(m.ScrollbarX(), m.yPos) +} diff --git a/pkg/tui/components/sidebar/layout_test.go b/pkg/tui/components/sidebar/layout_test.go index 33932754f..ffd0aa710 100644 --- a/pkg/tui/components/sidebar/layout_test.go +++ b/pkg/tui/components/sidebar/layout_test.go @@ -182,7 +182,7 @@ func BenchmarkSidebarVerticalView_Scroll(b *testing.B) { for i := range b.N { // Simulate scrolling by updating scroll offset - m.scrollbar.SetScrollOffset(i % 10) + m.scrollview.SetScrollOffset(i % 10) _ = m.verticalView() } } diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 78a4babfa..b16b9b2af 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -20,6 +20,7 @@ import ( "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/tools" "github.com/docker/cagent/pkg/tui/components/scrollbar" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/components/spinner" "github.com/docker/cagent/pkg/tui/components/tab" "github.com/docker/cagent/pkg/tui/components/tool/todotool" @@ -90,6 +91,8 @@ type Model interface { SetTitleRegenerating(regenerating bool) tea.Cmd // ScrollByWheel applies a wheel delta to the sidebar scrollbar. ScrollByWheel(delta int) + // IsScrollbarDragging returns true when the scrollbar thumb is being dragged. + IsScrollbarDragging() bool } // ragIndexingState tracks per-strategy indexing progress @@ -127,7 +130,7 @@ type model struct { toolsLoading bool // true when more tools may still be loading sessionState *service.SessionState workingAgent string // Name of the agent currently working (empty if none) - scrollbar *scrollbar.Model + scrollview *scrollview.Model workingDirectory string queuedMessages []string // Truncated preview of queued messages streamCancelled bool // true after ESC cancel until next StreamStartedEvent @@ -162,17 +165,20 @@ func New(sessionState *service.SessionState, opts ...Option) Model { ti.Prompt = "" // No prompt to maximize usable width in collapsed sidebar m := &model{ - width: 20, - layoutCfg: DefaultLayoutConfig(), - height: 24, - sessionUsage: make(map[string]*runtime.Usage), - sessionAgent: make(map[string]string), - todoComp: todotool.NewSidebarComponent(), - spinner: spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle), - sessionTitle: "New session", - ragIndexing: make(map[string]*ragIndexingState), - sessionState: sessionState, - scrollbar: scrollbar.New(), + width: 20, + layoutCfg: DefaultLayoutConfig(), + height: 24, + sessionUsage: make(map[string]*runtime.Usage), + sessionAgent: make(map[string]string), + todoComp: todotool.NewSidebarComponent(), + spinner: spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle), + sessionTitle: "New session", + ragIndexing: make(map[string]*ragIndexingState), + sessionState: sessionState, + scrollview: scrollview.New( + scrollview.WithWheelStep(1), + scrollview.WithKeyMap(nil), // Sidebar has no keyboard scroll — only mouse + ), workingDirectory: getCurrentWorkingDirectory(), reasoningSupported: true, preferredWidth: DefaultWidth, @@ -317,11 +323,15 @@ func (m *model) SetTitleRegenerating(regenerating bool) tea.Cmd { return nil } +func (m *model) IsScrollbarDragging() bool { + return m.scrollview.IsDragging() +} + func (m *model) ScrollByWheel(delta int) { if m.mode != ModeVertical || delta == 0 { return } - m.scrollbar.SetScrollOffset(m.scrollbar.GetScrollOffset() + delta) + m.scrollview.ScrollBy(delta) } // ClickResult indicates what was clicked in the sidebar @@ -367,7 +377,7 @@ func (m *model) HandleClickType(x, y int) ClickResult { } // In vertical mode, the title starts at verticalStarY - scrollOffset := m.scrollbar.GetScrollOffset() + scrollOffset := m.scrollview.ScrollOffset() contentY := y + scrollOffset // Convert viewport Y to content Y titleLines := m.titleLineCount() @@ -479,23 +489,12 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case tea.WindowSizeMsg: cmd := m.SetSize(msg.Width, msg.Height) return m, cmd - case tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg: + case tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg, tea.MouseWheelMsg: if m.mode == ModeVertical { - sb, cmd := m.scrollbar.Update(msg) - m.scrollbar = sb + _, cmd := m.scrollview.Update(msg) return m, cmd } return m, nil - case tea.MouseWheelMsg: - if m.mode == ModeVertical { - switch msg.Button.String() { - case "wheelup": - m.ScrollByWheel(-1) - case "wheeldown": - m.ScrollByWheel(1) - } - } - return m, nil case *runtime.TokenUsageEvent: m.SetTokenUsage(msg) return m, nil @@ -776,19 +775,18 @@ func (m *model) collapsedView() string { } func (m *model) verticalView() string { - visibleLines := m.height contentWidthNoScroll := m.contentWidth(false) // Use cached render if available and width hasn't changed if !m.cacheDirty && len(m.cachedLines) > 0 && m.cachedWidth == contentWidthNoScroll { - return m.renderFromCache(visibleLines) + return m.renderFromCache() } // Two-pass rendering: first check if scrollbar is needed // Pass 1: render without scrollbar to count lines lines := m.renderSections(contentWidthNoScroll) totalLines := len(lines) - needsScrollbar := totalLines > visibleLines + needsScrollbar := totalLines > m.height // Pass 2: if scrollbar needed, re-render with narrower content width if needsScrollbar { @@ -802,43 +800,22 @@ func (m *model) verticalView() string { m.cachedNeedsScrollbar = needsScrollbar m.cacheDirty = false - return m.renderFromCache(visibleLines) + return m.renderFromCache() } -// renderFromCache renders the sidebar from cached lines, applying scroll offset and scrollbar. -func (m *model) renderFromCache(visibleLines int) string { - lines := m.cachedLines - totalLines := len(lines) - needsScrollbar := m.cachedNeedsScrollbar - - // Update scrollbar dimensions - m.scrollbar.SetDimensions(visibleLines, totalLines) - - // Get scroll offset from scrollbar - scrollOffset := m.scrollbar.GetScrollOffset() - - // Extract visible portion - copy to avoid mutating cache - endIdx := min(scrollOffset+visibleLines, totalLines) - visibleContent := make([]string, endIdx-scrollOffset) - copy(visibleContent, lines[scrollOffset:endIdx]) - - // Pad to fill height if content is shorter - for len(visibleContent) < visibleLines { - visibleContent = append(visibleContent, "") +// renderFromCache renders the sidebar from cached lines using the scrollview +// component which guarantees fixed-width output and a pinned scrollbar. +func (m *model) renderFromCache() string { + // Compute the scrollview region width: content + gap + scrollbar (if needed) + regionWidth := m.contentWidth(m.cachedNeedsScrollbar) + if m.cachedNeedsScrollbar { + regionWidth += m.layoutCfg.ScrollbarGap + scrollbar.Width } - // Render with scrollbar gap if needed - if needsScrollbar { - scrollbarGap := strings.Repeat(" ", m.layoutCfg.ScrollbarGap) - scrollbarView := m.scrollbar.View() - return lipgloss.JoinHorizontal(lipgloss.Top, - strings.Join(visibleContent, "\n"), - scrollbarGap, - scrollbarView, - ) - } + m.scrollview.SetSize(regionWidth, m.height) + m.scrollview.SetContent(m.cachedLines, len(m.cachedLines)) - return strings.Join(visibleContent, "\n") + return m.scrollview.View() } // renderSections renders all sidebar sections and returns them as lines. @@ -1196,9 +1173,12 @@ func (m *model) renderToggleIndicator(label, shortcut string, contentWidth int) // SetSize sets the dimensions of the component func (m *model) SetSize(width, height int) tea.Cmd { + if m.width == width && m.height == height { + return nil // Dimensions unchanged — skip cache invalidation + } m.width = width m.height = height - m.updateScrollbarPosition() + m.updateScrollviewPosition() m.updateTitleInputWidth() m.invalidateCache() // Width/height change affects layout return nil @@ -1225,15 +1205,14 @@ func (m *model) updateTitleInputWidth() { func (m *model) SetPosition(x, y int) tea.Cmd { m.xPos = x m.yPos = y - m.updateScrollbarPosition() + m.updateScrollviewPosition() return nil } -// updateScrollbarPosition updates the scrollbar's position based on sidebar position and size -func (m *model) updateScrollbarPosition() { - // Scrollbar is at the right edge of the sidebar content - // width-1 because the scrollbar is 1 char wide and at the rightmost position - m.scrollbar.SetPosition(m.xPos+m.width-1, m.yPos) +// updateScrollviewPosition updates the scrollview's position based on sidebar position and layout. +func (m *model) updateScrollviewPosition() { + // The scrollview region starts after left padding. + m.scrollview.SetPosition(m.xPos+m.layoutCfg.PaddingLeft, m.yPos) } // GetSize returns the current dimensions diff --git a/pkg/tui/dialog/command_palette.go b/pkg/tui/dialog/command_palette.go index ac265f1c4..c77129c77 100644 --- a/pkg/tui/dialog/command_palette.go +++ b/pkg/tui/dialog/command_palette.go @@ -10,7 +10,7 @@ import ( "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/tui/commands" - "github.com/docker/cagent/pkg/tui/components/scrollbar" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/components/toolcommon" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" @@ -25,25 +25,22 @@ type CommandExecuteMsg struct { // commandPaletteDialog implements Dialog for the command palette type commandPaletteDialog struct { BaseDialog - textInput textinput.Model - categories []commands.Category - filtered []commands.Item - selected int - keyMap commandPaletteKeyMap - scrollbar *scrollbar.Model - needsScrollToSel bool // true when keyboard nav requires scrolling to selection - lastClickTime time.Time - lastClickIndex int + textInput textinput.Model + categories []commands.Category + filtered []commands.Item + selected int + keyMap commandPaletteKeyMap + scrollview *scrollview.Model + lastClickTime time.Time + lastClickIndex int } // commandPaletteKeyMap defines key bindings for the command palette type commandPaletteKeyMap struct { - Up key.Binding - Down key.Binding - PageUp key.Binding - PageDown key.Binding - Enter key.Binding - Escape key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Escape key.Binding } // defaultCommandPaletteKeyMap returns default key bindings @@ -57,14 +54,6 @@ func defaultCommandPaletteKeyMap() commandPaletteKeyMap { key.WithKeys("down", "ctrl+j"), key.WithHelp("↓/ctrl+j", "down"), ), - PageUp: key.NewBinding( - key.WithKeys("pgup"), - key.WithHelp("pgup", "page up"), - ), - PageDown: key.NewBinding( - key.WithKeys("pgdown"), - key.WithHelp("pgdown", "page down"), - ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "execute"), @@ -85,7 +74,6 @@ func NewCommandPaletteDialog(categories []commands.Category) Dialog { ti.CharLimit = 100 ti.SetWidth(50) - // Build initial filtered list (all commands) var allCommands []commands.Item for _, cat := range categories { allCommands = append(allCommands, cat.Commands...) @@ -97,7 +85,7 @@ func NewCommandPaletteDialog(categories []commands.Category) Dialog { filtered: allCommands, selected: 0, keyMap: defaultCommandPaletteKeyMap(), - scrollbar: scrollbar.New(), + scrollview: scrollview.New(scrollview.WithReserveScrollbarSpace(true)), } } @@ -108,7 +96,10 @@ func (d *commandPaletteDialog) Init() tea.Cmd { // Update handles messages for the command palette dialog func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { - var cmds []tea.Cmd + // Scrollview handles mouse scrollbar, wheel, and pgup/pgdn/home/end + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -116,21 +107,28 @@ func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return d, cmd case tea.PasteMsg: - // Forward paste to text input var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) - cmds = append(cmds, cmd) d.filterCommands() - return d, tea.Batch(cmds...) + return d, cmd case tea.MouseClickMsg: - return d.handleMouseClick(msg) - - case tea.MouseMotionMsg, tea.MouseReleaseMsg: - return d.handleMouseDrag(msg) - - case tea.MouseWheelMsg: - return d.handleMouseWheel(msg) + // Scrollbar clicks already handled above; this handles list item clicks + if msg.Button == tea.MouseLeft { + if cmdIdx := d.mouseYToCommandIndex(msg.Y); cmdIdx >= 0 { + now := time.Now() + if cmdIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { + d.selected = cmdIdx + d.lastClickTime = time.Time{} + cmd := d.executeSelected() + return d, cmd + } + d.selected = cmdIdx + d.lastClickTime = now + d.lastClickIndex = cmdIdx + } + } + return d, nil case tea.KeyPressMsg: if cmd := HandleQuit(msg); cmd != nil { @@ -144,31 +142,15 @@ func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Up): if d.selected > 0 { d.selected-- - d.needsScrollToSel = true + d.scrollview.EnsureLineVisible(d.findSelectedLine()) } return d, nil case key.Matches(msg, d.keyMap.Down): if d.selected < len(d.filtered)-1 { d.selected++ - d.needsScrollToSel = true - } - return d, nil - - case key.Matches(msg, d.keyMap.PageUp): - d.selected -= d.visibleLines() - if d.selected < 0 { - d.selected = 0 - } - d.needsScrollToSel = true - return d, nil - - case key.Matches(msg, d.keyMap.PageDown): - d.selected += d.visibleLines() - if d.selected >= len(d.filtered) { - d.selected = max(0, len(d.filtered)-1) + d.scrollview.EnsureLineVisible(d.findSelectedLine()) } - d.needsScrollToSel = true return d, nil case key.Matches(msg, d.keyMap.Enter): @@ -178,12 +160,12 @@ func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { default: var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) - cmds = append(cmds, cmd) d.filterCommands() + return d, cmd } } - return d, tea.Batch(cmds...) + return d, nil } // executeSelected executes the currently selected command and closes the dialog. @@ -199,74 +181,17 @@ func (d *commandPaletteDialog) executeSelected() tea.Cmd { return tea.Sequence(cmds...) } -// handleMouseClick handles mouse click events on the dialog -func (d *commandPaletteDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) { - // Check if click is on the scrollbar - if d.isMouseOnScrollbar(msg.X, msg.Y) { - d.scrollbar, _ = d.scrollbar.Update(msg) - return d, nil - } - - // Check if click is on a command in the list - if msg.Button != tea.MouseLeft { - return d, nil - } - cmdIdx := d.mouseYToCommandIndex(msg.Y) - if cmdIdx < 0 { - return d, nil - } - - now := time.Now() - // Double-click: execute - if cmdIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { - d.selected = cmdIdx - d.lastClickTime = time.Time{} - cmd := d.executeSelected() - return d, cmd - } - // Single click: select - d.selected = cmdIdx - d.lastClickTime = now - d.lastClickIndex = cmdIdx - return d, nil -} - -// handleMouseDrag handles mouse drag/release for scrollbar -func (d *commandPaletteDialog) handleMouseDrag(msg tea.Msg) (layout.Model, tea.Cmd) { - if d.scrollbar.IsDragging() { - d.scrollbar, _ = d.scrollbar.Update(msg) - } - return d, nil -} - -// handleMouseWheel handles mouse wheel scrolling inside the dialog -func (d *commandPaletteDialog) handleMouseWheel(msg tea.MouseWheelMsg) (layout.Model, tea.Cmd) { - if !d.isMouseInDialog(msg.X, msg.Y) { - return d, nil - } - switch msg.Button.String() { - case "wheelup": - d.scrollbar.ScrollUp() - d.scrollbar.ScrollUp() - case "wheeldown": - d.scrollbar.ScrollDown() - d.scrollbar.ScrollDown() - } - return d, nil -} - // filterCommands filters the command list based on search input func (d *commandPaletteDialog) filterCommands() { query := strings.ToLower(strings.TrimSpace(d.textInput.Value())) if query == "" { - // Show all commands d.filtered = make([]commands.Item, 0) for _, cat := range d.categories { d.filtered = append(d.filtered, cat.Commands...) } d.selected = 0 - d.scrollbar.SetScrollOffset(0) + d.scrollview.SetScrollOffset(0) return } @@ -285,73 +210,51 @@ func (d *commandPaletteDialog) filterCommands() { if d.selected >= len(d.filtered) { d.selected = 0 } - d.scrollbar.SetScrollOffset(0) + d.scrollview.SetScrollOffset(0) } // Command palette dialog dimension constants const ( - paletteWidthPercent = 80 - paletteMinWidth = 50 - paletteMaxWidth = 80 - paletteHeightPercent = 70 - paletteMaxHeight = 30 - paletteDialogPadding = 6 // horizontal padding inside dialog border - paletteListOverhead = 8 // title(1) + space(1) + input(1) + separator(1) + space(1) + help(1) + borders(2) - paletteListStartY = 6 // border(1) + padding(1) + title(1) + space(1) + input(1) + separator(1) - paletteScrollbarXInset = 3 - paletteScrollbarGap = 1 + paletteWidthPercent = 80 + paletteMinWidth = 50 + paletteMaxWidth = 80 + paletteHeightPercent = 70 + paletteMaxHeight = 30 + paletteDialogPadding = 6 // horizontal padding inside dialog border + paletteListOverhead = 8 // title(1) + space(1) + input(1) + separator(1) + space(1) + help(1) + borders(2) + paletteListStartY = 6 // border(1) + padding(1) + title(1) + space(1) + input(1) + separator(1) ) // dialogSize returns the dialog dimensions. func (d *commandPaletteDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { dialogWidth = max(min(d.Width()*paletteWidthPercent/100, paletteMaxWidth), paletteMinWidth) maxHeight = min(d.Height()*paletteHeightPercent/100, paletteMaxHeight) - contentWidth = dialogWidth - paletteDialogPadding - scrollbar.Width - paletteScrollbarGap + contentWidth = dialogWidth - paletteDialogPadding - d.scrollview.ReservedCols() return dialogWidth, maxHeight, contentWidth } -// visibleLines returns the number of lines available for the command list. -func (d *commandPaletteDialog) visibleLines() int { - _, maxHeight, _ := d.dialogSize() - return max(1, maxHeight-paletteListOverhead) -} - -// isMouseInDialog checks if the mouse position is inside the dialog bounds -func (d *commandPaletteDialog) isMouseInDialog(x, y int) bool { - dialogRow, dialogCol := d.Position() - dialogWidth, maxHeight, _ := d.dialogSize() - return x >= dialogCol && x < dialogCol+dialogWidth && - y >= dialogRow && y < dialogRow+maxHeight -} - -// isMouseOnScrollbar checks if the mouse position is on the scrollbar -func (d *commandPaletteDialog) isMouseOnScrollbar(x, y int) bool { - lines, lineToCmd := d.buildLines(0) - if len(lines) <= d.visibleLines() { - return false - } - _ = lineToCmd // used for other purposes - dialogRow, dialogCol := d.Position() - dialogWidth, _, _ := d.dialogSize() - scrollbarX := dialogCol + dialogWidth - paletteScrollbarXInset - scrollbar.Width - scrollbarY := dialogRow + paletteListStartY - visLines := d.visibleLines() - return x >= scrollbarX && x < scrollbarX+scrollbar.Width && - y >= scrollbarY && y < scrollbarY+visLines +// SetSize sets the dialog dimensions and configures the scrollview. +func (d *commandPaletteDialog) SetSize(width, height int) tea.Cmd { + cmd := d.BaseDialog.SetSize(width, height) + _, maxHeight, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + visLines := max(1, maxHeight-paletteListOverhead) + d.scrollview.SetSize(regionWidth, visLines) + return cmd } // mouseYToCommandIndex converts a mouse Y position to a command index. // Returns -1 if the position is not on a command. func (d *commandPaletteDialog) mouseYToCommandIndex(y int) int { dialogRow, _ := d.Position() - visLines := d.visibleLines() + visLines := d.scrollview.VisibleHeight() listStartY := dialogRow + paletteListStartY if y < listStartY || y >= listStartY+visLines { return -1 } lineInView := y - listStartY - actualLine := d.scrollbar.GetScrollOffset() + lineInView + actualLine := d.scrollview.ScrollOffset() + lineInView _, lineToCmd := d.buildLines(0) if actualLine < 0 || actualLine >= len(lineToCmd) { @@ -403,75 +306,32 @@ func (d *commandPaletteDialog) findSelectedLine() int { // View renders the command palette dialog func (d *commandPaletteDialog) View() string { dialogWidth, _, contentWidth := d.dialogSize() - visLines := d.visibleLines() d.textInput.SetWidth(contentWidth) - // Build all lines with command mapping allLines, _ := d.buildLines(contentWidth) - totalLines := len(allLines) - - // Update scrollbar dimensions - d.scrollbar.SetDimensions(visLines, totalLines) - - // Auto-scroll to selection when keyboard navigation occurred - if d.needsScrollToSel { - selectedLine := d.findSelectedLine() - scrollOffset := d.scrollbar.GetScrollOffset() - if selectedLine < scrollOffset { - d.scrollbar.SetScrollOffset(selectedLine) - } else if selectedLine >= scrollOffset+visLines { - d.scrollbar.SetScrollOffset(selectedLine - visLines + 1) - } - d.needsScrollToSel = false - } + regionWidth := contentWidth + d.scrollview.ReservedCols() - // Slice visible lines based on scroll offset - scrollOffset := d.scrollbar.GetScrollOffset() - visibleEnd := min(scrollOffset+visLines, totalLines) - var visibleCommandLines []string - if totalLines > 0 { - visibleCommandLines = allLines[scrollOffset:visibleEnd] - } + // Set scrollview position for mouse hit-testing (auto-computed from dialog position) + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+paletteListStartY) - // Pad with empty lines if content is shorter than visible area - for len(visibleCommandLines) < visLines { - visibleCommandLines = append(visibleCommandLines, "") - } + d.scrollview.SetContent(allLines, len(allLines)) - // Handle empty state + var scrollableContent string if len(d.filtered) == 0 { - visibleCommandLines = []string{"", styles.DialogContentStyle. - Italic(true). - Align(lipgloss.Center). - Width(contentWidth). + visLines := d.scrollview.VisibleHeight() + emptyLines := []string{"", styles.DialogContentStyle. + Italic(true).Align(lipgloss.Center).Width(contentWidth). Render("No commands found")} - for len(visibleCommandLines) < visLines { - visibleCommandLines = append(visibleCommandLines, "") + for len(emptyLines) < visLines { + emptyLines = append(emptyLines, "") } + scrollableContent = d.scrollview.ViewWithLines(emptyLines) + } else { + scrollableContent = d.scrollview.View() } - // Build command list with fixed width - commandListStyle := lipgloss.NewStyle().Width(contentWidth) - fixedWidthLines := make([]string, len(visibleCommandLines)) - for i, line := range visibleCommandLines { - fixedWidthLines[i] = commandListStyle.Render(line) - } - commandListContent := strings.Join(fixedWidthLines, "\n") - - // Set scrollbar position for mouse hit testing - dialogRow, dialogCol := d.Position() - scrollbarX := dialogCol + dialogWidth - paletteScrollbarXInset - scrollbar.Width - d.scrollbar.SetPosition(scrollbarX, dialogRow+paletteListStartY) - - // Combine content with scrollbar - gap := strings.Repeat(" ", paletteScrollbarGap) - scrollbarView := d.scrollbar.View() - if scrollbarView == "" { - scrollbarView = strings.Repeat(" ", scrollbar.Width) - } - scrollableContent := lipgloss.JoinHorizontal(lipgloss.Top, commandListContent, gap, scrollbarView) - - content := NewContent(contentWidth+paletteScrollbarGap+scrollbar.Width). + content := NewContent(regionWidth). AddTitle("Commands"). AddSpace(). AddContent(d.textInput.View()). @@ -499,7 +359,6 @@ func (d *commandPaletteDialog) renderCommand(cmd commands.Item, selected bool, c var content string content += actionStyle.Render(label) if cmd.Description != "" { - // Calculate available width for description: contentWidth - label - " • " separator separator := " • " separatorWidth := lipgloss.Width(separator) availableWidth := contentWidth - labelWidth - separatorWidth diff --git a/pkg/tui/dialog/cost.go b/pkg/tui/dialog/cost.go index e41879a60..eb8df0abc 100644 --- a/pkg/tui/dialog/cost.go +++ b/pkg/tui/dialog/cost.go @@ -14,6 +14,7 @@ import ( "github.com/docker/cagent/pkg/chat" "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/tui/components/notification" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" @@ -22,25 +23,25 @@ import ( // costDialog displays detailed cost breakdown for a session. type costDialog struct { BaseDialog - session *session.Session - keyMap costDialogKeyMap - offset int + session *session.Session + keyMap costDialogKeyMap + scrollview *scrollview.Model } type costDialogKeyMap struct { - Close, Copy, Up, Down, PageUp, PageDown key.Binding + Close, Copy key.Binding } func NewCostDialog(sess *session.Session) Dialog { return &costDialog{ session: sess, + scrollview: scrollview.New( + scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()), + scrollview.WithReserveScrollbarSpace(true), + ), keyMap: costDialogKeyMap{ - Close: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")), - Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), - Up: key.NewBinding(key.WithKeys("up", "k")), - Down: key.NewBinding(key.WithKeys("down", "j")), - PageUp: key.NewBinding(key.WithKeys("pgup")), - PageDown: key.NewBinding(key.WithKeys("pgdown")), + Close: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")), + Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), }, } } @@ -50,6 +51,10 @@ func (d *costDialog) Init() tea.Cmd { } func (d *costDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + switch msg := msg.(type) { case tea.WindowSizeMsg: cmd := d.SetSize(msg.Width, msg.Height) @@ -62,22 +67,6 @@ func (d *costDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Copy): _ = clipboard.WriteAll(d.renderPlainText()) return d, notification.SuccessCmd("Cost details copied to clipboard.") - case key.Matches(msg, d.keyMap.Up): - d.offset = max(0, d.offset-1) - case key.Matches(msg, d.keyMap.Down): - d.offset++ - case key.Matches(msg, d.keyMap.PageUp): - d.offset = max(0, d.offset-d.pageSize()) - case key.Matches(msg, d.keyMap.PageDown): - d.offset += d.pageSize() - } - - case tea.MouseWheelMsg: - switch msg.Button.String() { - case "wheelup": - d.offset = max(0, d.offset-1) - case "wheeldown": - d.offset++ } } return d, nil @@ -86,15 +75,10 @@ func (d *costDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { func (d *costDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { dialogWidth = d.ComputeDialogWidth(70, 50, 80) maxHeight = min(d.Height()*70/100, 40) - contentWidth = d.ContentWidth(dialogWidth, 2) + contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols() return dialogWidth, maxHeight, contentWidth } -func (d *costDialog) pageSize() int { - _, maxHeight, _ := d.dialogSize() - return max(1, maxHeight-10) -} - func (d *costDialog) Position() (row, col int) { dialogWidth, maxHeight, _ := d.dialogSize() return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight) @@ -261,29 +245,20 @@ func (d *costDialog) applyScrolling(allLines []string, contentWidth, maxHeight i visibleLines := max(1, maxHeight-headerLines-footerLines-4) contentLines := allLines[headerLines:] - totalContentLines := len(contentLines) - // Clamp offset - maxOffset := max(0, totalContentLines-visibleLines) - d.offset = min(d.offset, maxOffset) + regionWidth := contentWidth + d.scrollview.ReservedCols() + d.scrollview.SetSize(regionWidth, visibleLines) - // Extract visible portion - endIdx := min(d.offset+visibleLines, totalContentLines) - parts := append(allLines[:headerLines], contentLines[d.offset:endIdx]...) + // Set scrollview position for mouse hit-testing (auto-computed from dialog position) + // Y offset: border(1) + padding(1) + headerLines(3) = 5 + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines) - // Scroll indicator - if totalContentLines > visibleLines { - scrollInfo := fmt.Sprintf("[%d-%d of %d]", d.offset+1, endIdx, totalContentLines) - if d.offset > 0 { - scrollInfo = "↑ " + scrollInfo - } - if endIdx < totalContentLines { - scrollInfo += " ↓" - } - parts = append(parts, styles.MutedStyle.Render(scrollInfo)) - } + d.scrollview.SetContent(contentLines, len(contentLines)) - parts = append(parts, "", RenderHelpKeys(contentWidth, "↑↓", "scroll", "c", "copy", "Esc", "close")) + scrollableContent := d.scrollview.View() + parts := append(allLines[:headerLines], scrollableContent) + parts = append(parts, "", RenderHelpKeys(regionWidth, "↑↓", "scroll", "c", "copy", "Esc", "close")) return lipgloss.JoinVertical(lipgloss.Left, parts...) } diff --git a/pkg/tui/dialog/file_picker.go b/pkg/tui/dialog/file_picker.go index 90c2c2930..c29f0587f 100644 --- a/pkg/tui/dialog/file_picker.go +++ b/pkg/tui/dialog/file_picker.go @@ -1,7 +1,6 @@ package dialog import ( - "fmt" "os" "path/filepath" "strings" @@ -13,6 +12,7 @@ import ( "github.com/docker/go-units" "github.com/docker/cagent/pkg/fsx" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/messages" @@ -26,6 +26,11 @@ type fileEntry struct { size int64 } +const ( + filePickerListOverhead = 10 + filePickerListStartY = 7 // border(1) + padding(1) + title(1) + space(1) + dir(1) + input(1) + separator(1) +) + type filePickerDialog struct { BaseDialog textInput textinput.Model @@ -33,7 +38,7 @@ type filePickerDialog struct { entries []fileEntry filtered []fileEntry selected int - offset int + scrollview *scrollview.Model keyMap commandPaletteKeyMap err error } @@ -56,9 +61,7 @@ func NewFilePickerDialog(initialPath string) Dialog { startDir := cwd var selectFile string - // Handle initial path if provided if initialPath != "" { - // Make path absolute if relative if !filepath.IsAbs(initialPath) { initialPath = filepath.Join(cwd, initialPath) } @@ -72,7 +75,6 @@ func NewFilePickerDialog(initialPath string) Dialog { selectFile = filepath.Base(initialPath) } } else { - // Path doesn't exist, try to use parent directory parentDir := filepath.Dir(initialPath) if info, err := os.Stat(parentDir); err == nil && info.IsDir() { startDir = parentDir @@ -83,12 +85,12 @@ func NewFilePickerDialog(initialPath string) Dialog { d := &filePickerDialog{ textInput: ti, currentDir: startDir, + scrollview: scrollview.New(scrollview.WithReserveScrollbarSpace(true)), keyMap: defaultCommandPaletteKeyMap(), } d.loadDirectory() - // If we have a file to select, find and select it if selectFile != "" { for i, entry := range d.filtered { if entry.name == selectFile { @@ -105,10 +107,9 @@ func (d *filePickerDialog) loadDirectory() { d.entries = nil d.filtered = nil d.selected = 0 - d.offset = 0 + d.scrollview.SetScrollOffset(0) d.err = nil - // Add parent directory entry if not at root if d.currentDir != "/" { d.entries = append(d.entries, fileEntry{ name: "..", @@ -117,7 +118,6 @@ func (d *filePickerDialog) loadDirectory() { }) } - // Try to use VCS matcher to filter out ignored files var shouldIgnore func(string) bool if vcsMatcher, err := fsx.NewVCSMatcher(d.currentDir); err == nil && vcsMatcher != nil { shouldIgnore = vcsMatcher.ShouldIgnore @@ -129,20 +129,14 @@ func (d *filePickerDialog) loadDirectory() { return } - // First pass: add directories for _, entry := range dirEntries { - // Skip hidden files/directories if strings.HasPrefix(entry.Name(), ".") { continue } - fullPath := filepath.Join(d.currentDir, entry.Name()) - - // Skip ignored files if shouldIgnore != nil && shouldIgnore(fullPath) { continue } - if entry.IsDir() { d.entries = append(d.entries, fileEntry{ name: entry.Name() + "/", @@ -152,30 +146,19 @@ func (d *filePickerDialog) loadDirectory() { } } - // Second pass: add all files (not just images) for _, entry := range dirEntries { - if entry.IsDir() { - continue - } - - // Skip hidden files - if strings.HasPrefix(entry.Name(), ".") { + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { continue } - fullPath := filepath.Join(d.currentDir, entry.Name()) - - // Skip ignored files if shouldIgnore != nil && shouldIgnore(fullPath) { continue } - info, err := entry.Info() size := int64(0) if err == nil { size = info.Size() } - d.entries = append(d.entries, fileEntry{ name: entry.Name(), path: fullPath, @@ -192,13 +175,17 @@ func (d *filePickerDialog) Init() tea.Cmd { } func (d *filePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + // Scrollview handles mouse click/motion/release, wheel, and pgup/pgdn/home/end + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + switch msg := msg.(type) { case tea.WindowSizeMsg: cmd := d.SetSize(msg.Width, msg.Height) return d, cmd case tea.PasteMsg: - // Forward paste to text input var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) d.filterEntries() @@ -216,26 +203,14 @@ func (d *filePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Up): if d.selected > 0 { d.selected-- + d.scrollview.EnsureLineVisible(d.selected) } return d, nil case key.Matches(msg, d.keyMap.Down): if d.selected < len(d.filtered)-1 { d.selected++ - } - return d, nil - - case key.Matches(msg, d.keyMap.PageUp): - d.selected -= d.pageSize() - if d.selected < 0 { - d.selected = 0 - } - return d, nil - - case key.Matches(msg, d.keyMap.PageDown): - d.selected += d.pageSize() - if d.selected >= len(d.filtered) { - d.selected = max(0, len(d.filtered)-1) + d.scrollview.EnsureLineVisible(d.selected) } return d, nil @@ -243,13 +218,11 @@ func (d *filePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { if d.selected >= 0 && d.selected < len(d.filtered) { entry := d.filtered[d.selected] if entry.isDir { - // Navigate into directory d.currentDir = entry.path d.textInput.SetValue("") d.loadDirectory() return d, nil } - // Select file return d, tea.Sequence( core.CmdHandler(CloseDialogMsg{}), core.CmdHandler(messages.InsertFileRefMsg{FilePath: entry.path}), @@ -273,18 +246,16 @@ func (d *filePickerDialog) filterEntries() { if query == "" { d.filtered = d.entries d.selected = 0 - d.offset = 0 + d.scrollview.SetScrollOffset(0) return } d.filtered = nil for _, entry := range d.entries { - // Always include parent directory in filter results if entry.name == ".." { d.filtered = append(d.filtered, entry) continue } - if strings.Contains(strings.ToLower(entry.name), query) { d.filtered = append(d.filtered, entry) } @@ -293,69 +264,70 @@ func (d *filePickerDialog) filterEntries() { if d.selected >= len(d.filtered) { d.selected = 0 } - d.offset = 0 + d.scrollview.SetScrollOffset(0) } func (d *filePickerDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { dialogWidth = max(min(d.Width()*80/100, 80), 60) maxHeight = min(d.Height()*70/100, 30) - contentWidth = dialogWidth - 6 + contentWidth = dialogWidth - 6 - d.scrollview.ReservedCols() return dialogWidth, maxHeight, contentWidth } func (d *filePickerDialog) View() string { - dialogWidth, maxHeight, contentWidth := d.dialogSize() - + dialogWidth, _, contentWidth := d.dialogSize() d.textInput.SetWidth(contentWidth) - // Show current directory displayDir := d.currentDir if len(displayDir) > contentWidth-4 { displayDir = "…" + displayDir[len(displayDir)-(contentWidth-5):] } dirLine := styles.MutedStyle.Render("📁 " + displayDir) - var entryLines []string - maxItems := maxHeight - 10 - - // Adjust offset to keep selected item visible - if d.selected < d.offset { - d.offset = d.selected - } else if d.selected >= d.offset+maxItems { - d.offset = d.selected - maxItems + 1 + // Build all entry lines + var allLines []string + for i, entry := range d.filtered { + allLines = append(allLines, d.renderEntry(entry, i == d.selected, contentWidth)) } - // Render visible items based on offset - visibleEnd := min(d.offset+maxItems, len(d.filtered)) - for i := d.offset; i < visibleEnd; i++ { - entryLines = append(entryLines, d.renderEntry(d.filtered[i], i == d.selected, contentWidth)) - } + regionWidth := contentWidth + d.scrollview.ReservedCols() + visibleLines := d.scrollview.VisibleHeight() - // Show indicator if there are more items - if visibleEnd < len(d.filtered) { - entryLines = append(entryLines, styles.MutedStyle.Render(fmt.Sprintf(" … and %d more", len(d.filtered)-visibleEnd))) - } + // Set scrollview position for mouse hit-testing (auto-computed from dialog position) + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+filePickerListStartY) - if d.err != nil { - entryLines = append(entryLines, "", styles.ErrorStyle. - Align(lipgloss.Center). - Width(contentWidth). - Render(d.err.Error())) - } else if len(d.filtered) == 0 { - entryLines = append(entryLines, "", styles.DialogContentStyle. - Italic(true). - Align(lipgloss.Center). - Width(contentWidth). - Render("No files found")) + d.scrollview.SetContent(allLines, len(allLines)) + + var scrollableContent string + switch { + case d.err != nil: + errLines := []string{"", styles.ErrorStyle. + Align(lipgloss.Center).Width(contentWidth). + Render(d.err.Error())} + for len(errLines) < visibleLines { + errLines = append(errLines, "") + } + scrollableContent = d.scrollview.ViewWithLines(errLines) + case len(d.filtered) == 0: + emptyLines := []string{"", styles.DialogContentStyle. + Italic(true).Align(lipgloss.Center).Width(contentWidth). + Render("No files found")} + for len(emptyLines) < visibleLines { + emptyLines = append(emptyLines, "") + } + scrollableContent = d.scrollview.ViewWithLines(emptyLines) + default: + scrollableContent = d.scrollview.View() } - content := NewContent(contentWidth). + content := NewContent(regionWidth). AddTitle("Attach File"). AddSpace(). AddContent(dirLine). AddContent(d.textInput.View()). AddSeparator(). - AddContent(strings.Join(entryLines, "\n")). + AddContent(scrollableContent). AddSpace(). AddHelpKeys("↑/↓", "navigate", "enter", "select", "esc", "close"). Build() @@ -363,9 +335,14 @@ func (d *filePickerDialog) View() string { return styles.DialogStyle.Width(dialogWidth).Render(content) } -func (d *filePickerDialog) pageSize() int { - _, maxHeight, _ := d.dialogSize() - return max(1, maxHeight-10) +// SetSize sets the dialog dimensions and configures the scrollview region. +func (d *filePickerDialog) SetSize(width, height int) tea.Cmd { + cmd := d.BaseDialog.SetSize(width, height) + _, maxHeight, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + visibleLines := max(1, maxHeight-filePickerListOverhead) + d.scrollview.SetSize(regionWidth, visibleLines) + return cmd } func (d *filePickerDialog) renderEntry(entry fileEntry, selected bool, maxWidth int) string { diff --git a/pkg/tui/dialog/model_picker.go b/pkg/tui/dialog/model_picker.go index b916052ac..60cab1c48 100644 --- a/pkg/tui/dialog/model_picker.go +++ b/pkg/tui/dialog/model_picker.go @@ -12,7 +12,7 @@ import ( "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/runtime" - "github.com/docker/cagent/pkg/tui/components/scrollbar" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/components/toolcommon" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" @@ -32,14 +32,13 @@ var SupportedProviders = []string{ // modelPickerDialog is a dialog for selecting a model for the current agent. type modelPickerDialog struct { BaseDialog - textInput textinput.Model - models []runtime.ModelChoice - filtered []runtime.ModelChoice - selected int - keyMap commandPaletteKeyMap - errMsg string // validation error message - scrollbar *scrollbar.Model - needsScrollToSel bool // true when keyboard nav requires scrolling to selection + textInput textinput.Model + models []runtime.ModelChoice + filtered []runtime.ModelChoice + selected int + keyMap commandPaletteKeyMap + errMsg string // validation error message + scrollview *scrollview.Model // Double-click detection lastClickTime time.Time @@ -85,10 +84,10 @@ func NewModelPickerDialog(models []runtime.ModelChoice) Dialog { }) d := &modelPickerDialog{ - textInput: ti, - models: sortedModels, - keyMap: defaultCommandPaletteKeyMap(), - scrollbar: scrollbar.New(), + textInput: ti, + models: sortedModels, + keyMap: defaultCommandPaletteKeyMap(), + scrollview: scrollview.New(scrollview.WithReserveScrollbarSpace(true)), } d.filterModels() return d @@ -99,30 +98,40 @@ func (d *modelPickerDialog) Init() tea.Cmd { } func (d *modelPickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + // Scrollview handles mouse scrollbar, wheel, and pgup/pgdn/home/end + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + switch msg := msg.(type) { case tea.WindowSizeMsg: cmd := d.SetSize(msg.Width, msg.Height) return d, cmd case tea.PasteMsg: - // Forward paste to text input var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) d.filterModels() - d.errMsg = "" // Clear error when user types + d.errMsg = "" return d, cmd case tea.MouseClickMsg: - return d.handleMouseClick(msg) - - case tea.MouseMotionMsg: - return d.handleMouseMotion(msg) - - case tea.MouseReleaseMsg: - return d.handleMouseRelease(msg) - - case tea.MouseWheelMsg: - return d.handleMouseWheel(msg) + // Scrollbar clicks handled above; this handles list item clicks + if msg.Button == tea.MouseLeft { + if modelIdx := d.mouseYToModelIndex(msg.Y); modelIdx >= 0 { + now := time.Now() + if modelIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { + d.selected = modelIdx + d.lastClickTime = time.Time{} + cmd := d.handleSelection() + return d, cmd + } + d.selected = modelIdx + d.lastClickTime = now + d.lastClickIndex = modelIdx + } + } + return d, nil case tea.KeyPressMsg: if cmd := HandleQuit(msg); cmd != nil { @@ -136,33 +145,17 @@ func (d *modelPickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Up): if d.selected > 0 { d.selected-- - d.needsScrollToSel = true + d.scrollview.EnsureLineVisible(d.findSelectedLine(nil)) } return d, nil case key.Matches(msg, d.keyMap.Down): if d.selected < len(d.filtered)-1 { d.selected++ - d.needsScrollToSel = true + d.scrollview.EnsureLineVisible(d.findSelectedLine(nil)) } return d, nil - case key.Matches(msg, d.keyMap.PageUp): - d.selected -= d.pageSize() - if d.selected < 0 { - d.selected = 0 - } - d.needsScrollToSel = true - return d, nil - - case key.Matches(msg, d.keyMap.PageDown): - d.selected += d.pageSize() - if d.selected >= len(d.filtered) { - d.selected = max(0, len(d.filtered)-1) - } - d.needsScrollToSel = true - return d, nil - case key.Matches(msg, d.keyMap.Enter): cmd := d.handleSelection() return d, cmd @@ -171,7 +164,7 @@ func (d *modelPickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) d.filterModels() - d.errMsg = "" // Clear error when user types + d.errMsg = "" return d, cmd } } @@ -179,114 +172,11 @@ func (d *modelPickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return d, nil } -// handleMouseClick handles mouse click events on the dialog -func (d *modelPickerDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) { - // Check if click is on the scrollbar - if d.isMouseOnScrollbar(msg.X, msg.Y) { - sb, cmd := d.scrollbar.Update(msg) - d.scrollbar = sb - return d, cmd - } - - // Check if click is on a model in the list - if msg.Button == tea.MouseLeft { - if modelIdx := d.mouseYToModelIndex(msg.Y); modelIdx >= 0 { - now := time.Now() - - // Check for double-click: same index within threshold - if modelIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { - // Double-click: confirm selection - d.selected = modelIdx - d.lastClickTime = time.Time{} // Reset to prevent triple-click - cmd := d.handleSelection() - return d, cmd - } - - // Single click: just highlight - d.selected = modelIdx - d.lastClickTime = now - d.lastClickIndex = modelIdx - } - } - return d, nil -} - -// handleMouseMotion handles mouse drag events (for scrollbar dragging) -func (d *modelPickerDialog) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd) { - if d.scrollbar.IsDragging() { - sb, cmd := d.scrollbar.Update(msg) - d.scrollbar = sb - return d, cmd - } - // Hover highlighting disabled for now - return d, nil -} - -// handleMouseRelease handles mouse button release events -func (d *modelPickerDialog) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.Cmd) { - if d.scrollbar.IsDragging() { - sb, cmd := d.scrollbar.Update(msg) - d.scrollbar = sb - return d, cmd - } - return d, nil -} - -// handleMouseWheel handles mouse wheel scrolling inside the dialog -func (d *modelPickerDialog) handleMouseWheel(msg tea.MouseWheelMsg) (layout.Model, tea.Cmd) { - // Only scroll if mouse is inside the dialog - if !d.isMouseInDialog(msg.X, msg.Y) { - return d, nil - } - - buttonStr := msg.Button.String() - switch buttonStr { - case "wheelup": - d.scrollbar.ScrollUp() - d.scrollbar.ScrollUp() // Scroll 2 lines at a time - case "wheeldown": - d.scrollbar.ScrollDown() - d.scrollbar.ScrollDown() // Scroll 2 lines at a time - } - return d, nil -} - -// isMouseInDialog checks if the mouse position is inside the dialog bounds -func (d *modelPickerDialog) isMouseInDialog(x, y int) bool { - dialogRow, dialogCol := d.Position() - dialogWidth, maxHeight, _ := d.dialogSize() - - return x >= dialogCol && x < dialogCol+dialogWidth && - y >= dialogRow && y < dialogRow+maxHeight -} - -// isMouseOnScrollbar checks if the mouse position is on the scrollbar -// by delegating to the scrollbar component which knows its own position -func (d *modelPickerDialog) isMouseOnScrollbar(x, y int) bool { - // The scrollbar's position is set in View() via SetPosition() - // We check if the scrollbar would be visible (has content to scroll) - dialogWidth, maxHeight, _ := d.dialogSize() - maxItems := maxHeight - pickerListVerticalOverhead - - if len(d.filtered) <= maxItems { - return false // No scrollbar when content fits - } - - // Use a simple bounds check based on scrollbar position set in View() - dialogRow, dialogCol := d.Position() - scrollbarX := dialogCol + dialogWidth - pickerScrollbarXInset - scrollbar.Width - scrollbarY := dialogRow + pickerScrollbarYOffset - - return x >= scrollbarX && x < scrollbarX+scrollbar.Width && - y >= scrollbarY && y < scrollbarY+maxItems -} - // mouseYToModelIndex converts a mouse Y position to a model index. // Returns -1 if the position is not on a model (e.g., on a separator or outside the list). func (d *modelPickerDialog) mouseYToModelIndex(y int) int { dialogRow, _ := d.Position() - _, maxHeight, _ := d.dialogSize() - maxItems := maxHeight - pickerListVerticalOverhead + maxItems := d.scrollview.VisibleHeight() listStartY := dialogRow + pickerListStartOffset listEndY := listStartY + maxItems @@ -298,7 +188,7 @@ func (d *modelPickerDialog) mouseYToModelIndex(y int) int { // Calculate which line in the visible area was clicked lineInView := y - listStartY - scrollOffset := d.scrollbar.GetScrollOffset() + scrollOffset := d.scrollview.ScrollOffset() // Calculate the actual line index in allModelLines actualLine := scrollOffset + lineInView @@ -473,8 +363,8 @@ func (d *modelPickerDialog) filterModels() { if d.selected >= len(d.filtered) { d.selected = max(0, len(d.filtered)-1) } - // Reset scrollbar when filtering - d.scrollbar.SetScrollOffset(0) + // Reset scroll when filtering + d.scrollview.SetScrollOffset(0) } // Model picker dialog dimension constants @@ -501,17 +391,6 @@ const ( // border(1) + padding(1) + title(1) + space(1) + input(1) + separator(1) = 6 pickerListStartOffset = 6 - // pickerScrollbarYOffset is the Y offset from dialog top to where the scrollbar starts. - // The scrollbar is rendered horizontally alongside the model list (via JoinHorizontal), - // so they must start at the same Y position. - pickerScrollbarYOffset = pickerListStartOffset - - // pickerScrollbarXInset is the inset from dialog right edge for the scrollbar - pickerScrollbarXInset = 3 - - // pickerScrollbarGap is the space between content and the scrollbar - pickerScrollbarGap = 1 - // catalogSeparatorLabel is the text for the catalog section separator catalogSeparatorLabel = "── Other models " // customSeparatorLabel is the text for the custom models section separator @@ -521,17 +400,25 @@ const ( func (d *modelPickerDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { dialogWidth = max(min(d.Width()*pickerWidthPercent/100, pickerMaxWidth), pickerMinWidth) maxHeight = min(d.Height()*pickerHeightPercent/100, pickerMaxHeight) - contentWidth = dialogWidth - pickerDialogPadding - scrollbar.Width - pickerScrollbarGap + contentWidth = dialogWidth - pickerDialogPadding - d.scrollview.ReservedCols() return dialogWidth, maxHeight, contentWidth } +// SetSize sets the dialog dimensions and configures the scrollview. +func (d *modelPickerDialog) SetSize(width, height int) tea.Cmd { + cmd := d.BaseDialog.SetSize(width, height) + _, maxHeight, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + visLines := max(1, maxHeight-pickerListVerticalOverhead) + d.scrollview.SetSize(regionWidth, visLines) + return cmd +} + func (d *modelPickerDialog) View() string { - dialogWidth, maxHeight, contentWidth := d.dialogSize() + dialogWidth, _, contentWidth := d.dialogSize() d.textInput.SetWidth(contentWidth) - maxItems := maxHeight - pickerListVerticalOverhead - // Build all model lines first to calculate total height var allModelLines []string catalogSeparatorShown := false @@ -573,76 +460,29 @@ func (d *modelPickerDialog) View() string { allModelLines = append(allModelLines, d.renderModel(model, i == d.selected, contentWidth)) } - totalLines := len(allModelLines) - visibleLines := maxItems - - // Update scrollbar dimensions - d.scrollbar.SetDimensions(visibleLines, totalLines) - - // Only auto-scroll to selection when keyboard navigation occurred - if d.needsScrollToSel { - selectedLine := d.findSelectedLine(allModelLines) - scrollOffset := d.scrollbar.GetScrollOffset() - if selectedLine < scrollOffset { - d.scrollbar.SetScrollOffset(selectedLine) - } else if selectedLine >= scrollOffset+visibleLines { - d.scrollbar.SetScrollOffset(selectedLine - visibleLines + 1) - } - d.needsScrollToSel = false - } + regionWidth := contentWidth + d.scrollview.ReservedCols() - // Slice visible lines based on scroll offset - scrollOffset := d.scrollbar.GetScrollOffset() - visibleEnd := min(scrollOffset+visibleLines, totalLines) - visibleModelLines := allModelLines[scrollOffset:visibleEnd] + // Set scrollview position for mouse hit-testing (auto-computed from dialog position) + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+pickerListStartOffset) - // Pad with empty lines if content is shorter than visible area - for len(visibleModelLines) < visibleLines { - visibleModelLines = append(visibleModelLines, "") - } + d.scrollview.SetContent(allModelLines, len(allModelLines)) - // Handle empty state + var scrollableContent string if len(d.filtered) == 0 { - visibleModelLines = []string{"", styles.DialogContentStyle. - Italic(true). - Align(lipgloss.Center). - Width(contentWidth). + visLines := d.scrollview.VisibleHeight() + emptyLines := []string{"", styles.DialogContentStyle. + Italic(true).Align(lipgloss.Center).Width(contentWidth). Render("No models found")} - for len(visibleModelLines) < visibleLines { - visibleModelLines = append(visibleModelLines, "") + for len(emptyLines) < visLines { + emptyLines = append(emptyLines, "") } - } - - // Build model list with fixed width to keep scrollbar position stable - modelListStyle := lipgloss.NewStyle().Width(contentWidth) - var fixedWidthLines []string - for _, line := range visibleModelLines { - fixedWidthLines = append(fixedWidthLines, modelListStyle.Render(line)) - } - modelListContent := strings.Join(fixedWidthLines, "\n") - - // Set scrollbar position for mouse hit testing - dialogRow, dialogCol := d.Position() - scrollbarX := dialogCol + dialogWidth - pickerScrollbarXInset - scrollbar.Width - scrollbarY := dialogRow + pickerScrollbarYOffset - d.scrollbar.SetPosition(scrollbarX, scrollbarY) - - // Get scrollbar view - scrollbarView := d.scrollbar.View() - - // Combine content with scrollbar (gap between content and scrollbar) - // Always include the gap and scrollbar space to maintain consistent layout - var scrollableContent string - gap := strings.Repeat(" ", pickerScrollbarGap) - if scrollbarView != "" { - scrollableContent = lipgloss.JoinHorizontal(lipgloss.Top, modelListContent, gap, scrollbarView) + scrollableContent = d.scrollview.ViewWithLines(emptyLines) } else { - // No scrollbar needed, but still pad to maintain consistent width - scrollbarPlaceholder := strings.Repeat(" ", scrollbar.Width) - scrollableContent = lipgloss.JoinHorizontal(lipgloss.Top, modelListContent, gap, scrollbarPlaceholder) + scrollableContent = d.scrollview.View() } - contentBuilder := NewContent(contentWidth + pickerScrollbarGap + scrollbar.Width). + contentBuilder := NewContent(regionWidth). AddTitle("Select Model"). AddSpace(). AddContent(d.textInput.View()) @@ -715,11 +555,6 @@ func (d *modelPickerDialog) findSelectedLine(allModelLines []string) int { return min(lineIndex, len(allModelLines)-1) } -func (d *modelPickerDialog) pageSize() int { - _, maxHeight, _ := d.dialogSize() - return max(1, maxHeight-pickerListVerticalOverhead) -} - func (d *modelPickerDialog) renderModel(model runtime.ModelChoice, selected bool, maxWidth int) string { nameStyle, descStyle := styles.PaletteUnselectedActionStyle, styles.PaletteUnselectedDescStyle alloyBadgeStyle, defaultBadgeStyle, currentBadgeStyle := styles.BadgeAlloyStyle, styles.BadgeDefaultStyle, styles.BadgeCurrentStyle diff --git a/pkg/tui/dialog/model_picker_test.go b/pkg/tui/dialog/model_picker_test.go index accf7f042..ec5f426c8 100644 --- a/pkg/tui/dialog/model_picker_test.go +++ b/pkg/tui/dialog/model_picker_test.go @@ -3,7 +3,6 @@ package dialog import ( "testing" - "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -183,23 +182,18 @@ func TestModelPickerPageNavigation(t *testing.T) { d.Init() d.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) - pageSize := d.pageSize() - - // Page down + // Page down scrolls the viewport (handled by scrollview), not the selection pageDownKey := tea.KeyPressMsg{Code: tea.KeyPgDown} - require.True(t, key.Matches(pageDownKey, d.keyMap.PageDown), "pagedown key should match") - updated, _ := d.Update(pageDownKey) d = updated.(*modelPickerDialog) - require.Equal(t, pageSize, d.selected, "selection should advance by page size") + require.Equal(t, 0, d.selected, "selection should not move on page down (scrollview scrolls viewport)") + require.Positive(t, d.scrollview.ScrollOffset(), "scrollview should have scrolled") - // Page up + // Page up scrolls back pageUpKey := tea.KeyPressMsg{Code: tea.KeyPgUp} - require.True(t, key.Matches(pageUpKey, d.keyMap.PageUp), "pageup key should match") - updated, _ = d.Update(pageUpKey) d = updated.(*modelPickerDialog) - require.Equal(t, 0, d.selected, "selection should return to 0") + require.Equal(t, 0, d.scrollview.ScrollOffset(), "scrollview should scroll back to top") } func TestModelPickerEscape(t *testing.T) { diff --git a/pkg/tui/dialog/permissions.go b/pkg/tui/dialog/permissions.go index 083b20ab6..0d2ef7a34 100644 --- a/pkg/tui/dialog/permissions.go +++ b/pkg/tui/dialog/permissions.go @@ -1,13 +1,12 @@ package dialog import ( - "strings" - "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/runtime" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" @@ -18,12 +17,8 @@ type permissionsDialog struct { BaseDialog permissions *runtime.PermissionsInfo yoloEnabled bool - keyMap permissionsDialogKeyMap - offset int -} - -type permissionsDialogKeyMap struct { - Close, Up, Down, PageUp, PageDown key.Binding + closeKey key.Binding + scrollview *scrollview.Model } // NewPermissionsDialog creates a new dialog showing tool permission rules. @@ -31,13 +26,11 @@ func NewPermissionsDialog(perms *runtime.PermissionsInfo, yoloEnabled bool) Dial return &permissionsDialog{ permissions: perms, yoloEnabled: yoloEnabled, - keyMap: permissionsDialogKeyMap{ - Close: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")), - Up: key.NewBinding(key.WithKeys("up", "k")), - Down: key.NewBinding(key.WithKeys("down", "j")), - PageUp: key.NewBinding(key.WithKeys("pgup")), - PageDown: key.NewBinding(key.WithKeys("pgdown")), - }, + scrollview: scrollview.New( + scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()), + scrollview.WithReserveScrollbarSpace(true), + ), + closeKey: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")), } } @@ -46,31 +39,18 @@ func (d *permissionsDialog) Init() tea.Cmd { } func (d *permissionsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + switch msg := msg.(type) { case tea.WindowSizeMsg: cmd := d.SetSize(msg.Width, msg.Height) return d, cmd case tea.KeyPressMsg: - switch { - case key.Matches(msg, d.keyMap.Close): + if key.Matches(msg, d.closeKey) { return d, core.CmdHandler(CloseDialogMsg{}) - case key.Matches(msg, d.keyMap.Up): - d.offset = max(0, d.offset-1) - case key.Matches(msg, d.keyMap.Down): - d.offset++ - case key.Matches(msg, d.keyMap.PageUp): - d.offset = max(0, d.offset-d.pageSize()) - case key.Matches(msg, d.keyMap.PageDown): - d.offset += d.pageSize() - } - - case tea.MouseWheelMsg: - switch msg.Button.String() { - case "wheelup": - d.offset = max(0, d.offset-1) - case "wheeldown": - d.offset++ } } return d, nil @@ -79,15 +59,10 @@ func (d *permissionsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { func (d *permissionsDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { dialogWidth = d.ComputeDialogWidth(60, 40, 70) maxHeight = min(d.Height()*70/100, 30) - contentWidth = d.ContentWidth(dialogWidth, 2) + contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols() return dialogWidth, maxHeight, contentWidth } -func (d *permissionsDialog) pageSize() int { - _, maxHeight, _ := d.dialogSize() - return max(1, maxHeight-10) -} - func (d *permissionsDialog) Position() (row, col int) { dialogWidth, maxHeight, _ := d.dialogSize() return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight) @@ -181,29 +156,19 @@ func (d *permissionsDialog) applyScrolling(allLines []string, contentWidth, maxH visibleLines := max(1, maxHeight-headerLines-footerLines-4) contentLines := allLines[headerLines:] - totalContentLines := len(contentLines) - // Clamp offset - maxOffset := max(0, totalContentLines-visibleLines) - d.offset = min(d.offset, maxOffset) + regionWidth := contentWidth + d.scrollview.ReservedCols() + d.scrollview.SetSize(regionWidth, visibleLines) - // Extract visible portion - endIdx := min(d.offset+visibleLines, totalContentLines) - parts := append(allLines[:headerLines], contentLines[d.offset:endIdx]...) + // Set scrollview position for mouse hit-testing (auto-computed from dialog position) + // Y offset: border(1) + padding(1) + headerLines(3) = 5 + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines) - // Scroll indicator - if totalContentLines > visibleLines { - scrollInfo := lipgloss.NewStyle().Render("") - if d.offset > 0 { - scrollInfo = "↑ " - } - scrollInfo += styles.MutedStyle.Render("[" + strings.Repeat("─", 3) + "]") - if endIdx < totalContentLines { - scrollInfo += " ↓" - } - parts = append(parts, styles.MutedStyle.Render(scrollInfo)) - } + d.scrollview.SetContent(contentLines, len(contentLines)) - parts = append(parts, "", RenderHelpKeys(contentWidth, "↑↓", "scroll", "Esc", "close")) + scrollableContent := d.scrollview.View() + parts := append(allLines[:headerLines], scrollableContent) + parts = append(parts, "", RenderHelpKeys(regionWidth, "↑↓", "scroll", "Esc", "close")) return lipgloss.JoinVertical(lipgloss.Left, parts...) } diff --git a/pkg/tui/dialog/session_browser.go b/pkg/tui/dialog/session_browser.go index 3462fd12b..98ed07d68 100644 --- a/pkg/tui/dialog/session_browser.go +++ b/pkg/tui/dialog/session_browser.go @@ -13,6 +13,7 @@ import ( "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/tui/components/notification" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/messages" @@ -23,8 +24,6 @@ import ( type sessionBrowserKeyMap struct { Up key.Binding Down key.Binding - PageUp key.Binding - PageDown key.Binding Enter key.Binding Escape key.Binding Star key.Binding @@ -32,21 +31,11 @@ type sessionBrowserKeyMap struct { CopyID key.Binding } -// defaultSessionBrowserKeyMap returns default key bindings -func defaultSessionBrowserKeyMap() sessionBrowserKeyMap { - base := defaultCommandPaletteKeyMap() - return sessionBrowserKeyMap{ - Up: base.Up, - Down: base.Down, - PageUp: base.PageUp, - PageDown: base.PageDown, - Enter: base.Enter, - Escape: base.Escape, - Star: key.NewBinding(key.WithKeys("s")), - FilterStar: key.NewBinding(key.WithKeys("f")), - CopyID: key.NewBinding(key.WithKeys("c")), - } -} +// Session browser dialog dimension constants +const ( + sessionBrowserListOverhead = 12 // title(1) + space(1) + input(1) + separator(1) + separator(1) + id(1) + space(1) + help(1) + borders(2) + extra(2) + sessionBrowserListStartY = 6 // border(1) + padding(1) + title(1) + space(1) + input(1) + separator(1) +) type sessionBrowserDialog struct { BaseDialog @@ -54,10 +43,10 @@ type sessionBrowserDialog struct { sessions []session.Summary filtered []session.Summary selected int - offset int // scroll offset for viewport - keyMap sessionBrowserKeyMap // key bindings - openedAt time.Time // when dialog was opened, for stable time display - starFilter int // 0 = all, 1 = starred only, 2 = unstarred only + scrollview *scrollview.Model + keyMap sessionBrowserKeyMap + openedAt time.Time // when dialog was opened, for stable time display + starFilter int // 0 = all, 1 = starred only, 2 = unstarred only } // NewSessionBrowserDialog creates a new session browser dialog @@ -77,10 +66,19 @@ func NewSessionBrowserDialog(sessions []session.Summary) Dialog { } d := &sessionBrowserDialog{ - textInput: ti, - sessions: nonEmptySessions, - keyMap: defaultSessionBrowserKeyMap(), - openedAt: time.Now(), + textInput: ti, + sessions: nonEmptySessions, + scrollview: scrollview.New(scrollview.WithReserveScrollbarSpace(true)), + keyMap: sessionBrowserKeyMap{ + Up: key.NewBinding(key.WithKeys("up", "ctrl+k")), + Down: key.NewBinding(key.WithKeys("down", "ctrl+j")), + Enter: key.NewBinding(key.WithKeys("enter")), + Escape: key.NewBinding(key.WithKeys("esc")), + Star: key.NewBinding(key.WithKeys("s")), + FilterStar: key.NewBinding(key.WithKeys("f")), + CopyID: key.NewBinding(key.WithKeys("c")), + }, + openedAt: time.Now(), } // Initialize filtered list d.filterSessions() @@ -92,13 +90,17 @@ func (d *sessionBrowserDialog) Init() tea.Cmd { } func (d *sessionBrowserDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + // Scrollview handles mouse click/motion/release, wheel, and pgup/pgdn/home/end + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + switch msg := msg.(type) { case tea.WindowSizeMsg: cmd := d.SetSize(msg.Width, msg.Height) return d, cmd case tea.PasteMsg: - // Forward paste to text input var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) d.filterSessions() @@ -116,26 +118,14 @@ func (d *sessionBrowserDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Up): if d.selected > 0 { d.selected-- + d.scrollview.EnsureLineVisible(d.selected) } return d, nil case key.Matches(msg, d.keyMap.Down): if d.selected < len(d.filtered)-1 { d.selected++ - } - return d, nil - - case key.Matches(msg, d.keyMap.PageUp): - d.selected -= d.pageSize() - if d.selected < 0 { - d.selected = 0 - } - return d, nil - - case key.Matches(msg, d.keyMap.PageDown): - d.selected += d.pageSize() - if d.selected >= len(d.filtered) { - d.selected = max(0, len(d.filtered)-1) + d.scrollview.EnsureLineVisible(d.selected) } return d, nil @@ -151,7 +141,6 @@ func (d *sessionBrowserDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Star): if d.selected >= 0 && d.selected < len(d.filtered) { sessionID := d.filtered[d.selected].ID - // Toggle the starred state in our local data for i := range d.sessions { if d.sessions[i].ID == sessionID { d.sessions[i].Starred = !d.sessions[i].Starred @@ -169,7 +158,6 @@ func (d *sessionBrowserDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return d, nil case key.Matches(msg, d.keyMap.FilterStar): - // Cycle through filter modes: all -> starred -> unstarred -> all d.starFilter = (d.starFilter + 1) % 3 d.filterSessions() return d, nil @@ -198,19 +186,17 @@ func (d *sessionBrowserDialog) filterSessions() { d.filtered = nil for _, sess := range d.sessions { - // Apply star filter switch d.starFilter { - case 1: // Starred only + case 1: if !sess.Starred { continue } - case 2: // Unstarred only + case 2: if sess.Starred { continue } } - // Apply text search filter if query != "" { title := sess.Title if title == "" { @@ -227,48 +213,48 @@ func (d *sessionBrowserDialog) filterSessions() { if d.selected >= len(d.filtered) { d.selected = max(0, len(d.filtered)-1) } - d.offset = 0 + d.scrollview.SetScrollOffset(0) } func (d *sessionBrowserDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { dialogWidth = max(min(d.Width()*80/100, 80), 60) maxHeight = min(d.Height()*70/100, 30) - contentWidth = dialogWidth - 6 + contentWidth = dialogWidth - 6 - d.scrollview.ReservedCols() return dialogWidth, maxHeight, contentWidth } func (d *sessionBrowserDialog) View() string { - dialogWidth, maxHeight, contentWidth := d.dialogSize() - + dialogWidth, _, contentWidth := d.dialogSize() d.textInput.SetWidth(contentWidth) - var sessionLines []string - maxItems := maxHeight - 10 // Reduced to make room for ID footer - - // Adjust offset to keep selected item visible - if d.selected < d.offset { - d.offset = d.selected - } else if d.selected >= d.offset+maxItems { - d.offset = d.selected - maxItems + 1 + // Build all session lines + var allLines []string + for i, sess := range d.filtered { + allLines = append(allLines, d.renderSession(sess, i == d.selected, contentWidth)) } - // Render visible items based on offset - visibleEnd := min(d.offset+maxItems, len(d.filtered)) - for i := d.offset; i < visibleEnd; i++ { - sessionLines = append(sessionLines, d.renderSession(d.filtered[i], i == d.selected, contentWidth)) - } + // Configure scrollview and let it handle slicing + rendering + regionWidth := contentWidth + d.scrollview.ReservedCols() + visibleLines := d.scrollview.VisibleHeight() - // Show indicator if there are more items - if visibleEnd < len(d.filtered) { - sessionLines = append(sessionLines, styles.MutedStyle.Render(fmt.Sprintf(" … and %d more", len(d.filtered)-visibleEnd))) - } + // Set scrollview position for mouse hit-testing (auto-computed from dialog position) + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+sessionBrowserListStartY) + d.scrollview.SetContent(allLines, len(allLines)) + + var scrollableContent string if len(d.filtered) == 0 { - sessionLines = append(sessionLines, "", styles.DialogContentStyle. - Italic(true). - Align(lipgloss.Center). - Width(contentWidth). - Render("No sessions found")) + // Empty state: render manually so "No sessions found" is centered + emptyLines := []string{"", styles.DialogContentStyle. + Italic(true).Align(lipgloss.Center).Width(contentWidth). + Render("No sessions found")} + for len(emptyLines) < visibleLines { + emptyLines = append(emptyLines, "") + } + scrollableContent = d.scrollview.ViewWithLines(emptyLines) + } else { + scrollableContent = d.scrollview.View() } // Build title with filter indicator @@ -280,7 +266,6 @@ func (d *sessionBrowserDialog) View() string { title = "Sessions " + styles.UnstarredStyle.Render("☆") } - // Build filter description for help var filterDesc string switch d.starFilter { case 0: @@ -291,18 +276,17 @@ func (d *sessionBrowserDialog) View() string { filterDesc = "☆ only" } - // Build session ID footer for selected session var idFooter string if d.selected >= 0 && d.selected < len(d.filtered) { idFooter = styles.MutedStyle.Render("ID: ") + styles.SecondaryStyle.Render(d.filtered[d.selected].ID) } - content := NewContent(contentWidth). + content := NewContent(regionWidth). AddTitle(title). AddSpace(). AddContent(d.textInput.View()). AddSeparator(). - AddContent(strings.Join(sessionLines, "\n")). + AddContent(scrollableContent). AddSeparator(). AddContent(idFooter). AddSpace(). @@ -312,9 +296,14 @@ func (d *sessionBrowserDialog) View() string { return styles.DialogStyle.Width(dialogWidth).Render(content) } -func (d *sessionBrowserDialog) pageSize() int { - _, maxHeight, _ := d.dialogSize() - return max(1, maxHeight-10) // Match maxItems calculation in View +// SetSize sets the dialog dimensions and configures the scrollview region. +func (d *sessionBrowserDialog) SetSize(width, height int) tea.Cmd { + cmd := d.BaseDialog.SetSize(width, height) + _, maxHeight, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + visibleLines := max(1, maxHeight-sessionBrowserListOverhead) + d.scrollview.SetSize(regionWidth, visibleLines) + return cmd } func (d *sessionBrowserDialog) renderSession(sess session.Summary, selected bool, maxWidth int) string { @@ -328,9 +317,8 @@ func (d *sessionBrowserDialog) renderSession(sess session.Summary, selected bool title = "Untitled" } - // Account for star indicator in title length calculation - starWidth := 3 // star indicator - maxTitleLen := maxWidth - 25 - starWidth // 25 for time + star + starWidth := 3 + maxTitleLen := maxWidth - 25 - starWidth if len(title) > maxTitleLen { title = title[:maxTitleLen-1] + "…" } diff --git a/pkg/tui/dialog/session_browser_test.go b/pkg/tui/dialog/session_browser_test.go index 0151f6d9b..fd82a720c 100644 --- a/pkg/tui/dialog/session_browser_test.go +++ b/pkg/tui/dialog/session_browser_test.go @@ -170,7 +170,7 @@ func TestSessionBrowserScrolling(t *testing.T) { // Set a small window size to force scrolling d.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) - maxVisible := d.pageSize() + maxVisible := d.scrollview.VisibleHeight() t.Logf("Max visible items: %d", maxVisible) // Navigate down past the visible area @@ -185,11 +185,12 @@ func TestSessionBrowserScrolling(t *testing.T) { // Call View() to trigger offset adjustment (like the TUI does) view := d.View() - t.Logf("Selected: %d, Offset: %d", d.selected, d.offset) + scrollOffset := d.scrollview.ScrollOffset() + t.Logf("Selected: %d, ScrollOffset: %d", d.selected, scrollOffset) - // The offset should have adjusted so selected is visible - require.LessOrEqual(t, d.offset, d.selected, "offset should be <= selected") - require.Less(t, d.selected, d.offset+maxVisible, "selected should be within visible range") + // The scroll offset should have adjusted so selected is visible + require.LessOrEqual(t, scrollOffset, d.selected, "scroll offset should be <= selected") + require.Less(t, d.selected, scrollOffset+maxVisible, "selected should be within visible range") // Verify the view shows the selected session expectedTitle := fmt.Sprintf("Session %d", d.selected+1) diff --git a/pkg/tui/dialog/theme_picker.go b/pkg/tui/dialog/theme_picker.go index 8a72a17ba..61ecbe490 100644 --- a/pkg/tui/dialog/theme_picker.go +++ b/pkg/tui/dialog/theme_picker.go @@ -10,7 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/docker/cagent/pkg/tui/components/scrollbar" + "github.com/docker/cagent/pkg/tui/components/scrollview" "github.com/docker/cagent/pkg/tui/components/toolcommon" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" @@ -30,13 +30,12 @@ type ThemeChoice struct { // themePickerDialog is a dialog for selecting a theme. type themePickerDialog struct { BaseDialog - textInput textinput.Model - themes []ThemeChoice - filtered []ThemeChoice - selected int - keyMap commandPaletteKeyMap - scrollbar *scrollbar.Model - needsScrollToSel bool + textInput textinput.Model + themes []ThemeChoice + filtered []ThemeChoice + selected int + keyMap commandPaletteKeyMap + scrollview *scrollview.Model // Double-click detection lastClickTime time.Time @@ -93,7 +92,7 @@ func NewThemePickerDialog(themes []ThemeChoice, originalThemeRef string) Dialog themes: themes, filtered: nil, keyMap: defaultCommandPaletteKeyMap(), - scrollbar: scrollbar.New(), + scrollview: scrollview.New(scrollview.WithReserveScrollbarSpace(true)), originalThemeRef: originalThemeRef, } @@ -104,7 +103,7 @@ func NewThemePickerDialog(themes []ThemeChoice, originalThemeRef string) Dialog for i, t := range d.filtered { if t.IsCurrent { d.selected = i - d.needsScrollToSel = true // Scroll to current selection on open + d.scrollview.EnsureLineVisible(d.findSelectedLine(nil)) // Scroll to current selection on open break } } @@ -123,37 +122,51 @@ func (d *themePickerDialog) Init() tea.Cmd { } func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + // Scrollview handles mouse scrollbar, wheel, and pgup/pgdn/home/end + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + switch msg := msg.(type) { case tea.WindowSizeMsg: cmd := d.SetSize(msg.Width, msg.Height) return d, cmd case messages.ThemeChangedMsg: - // Theme changed (preview/hot reload) - update textinput styles d.textInput.SetStyles(styles.DialogInputStyle) return d, nil case tea.PasteMsg: - // Forward paste to text input var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) if selectionChanged := d.filterThemes(); selectionChanged { - d.needsScrollToSel = true + d.scrollview.EnsureLineVisible(d.findSelectedLine(nil)) return d, tea.Batch(cmd, d.emitPreview()) } return d, cmd case tea.MouseClickMsg: - return d.handleMouseClick(msg) - - case tea.MouseMotionMsg: - return d.handleMouseMotion(msg) - - case tea.MouseReleaseMsg: - return d.handleMouseRelease(msg) - - case tea.MouseWheelMsg: - return d.handleMouseWheel(msg) + // Scrollbar clicks handled above; this handles list item clicks + if msg.Button == tea.MouseLeft { + if themeIdx := d.mouseYToThemeIndex(msg.Y); themeIdx >= 0 { + now := time.Now() + if themeIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { + d.selected = themeIdx + d.lastClickTime = time.Time{} + cmd := d.handleSelection() + return d, cmd + } + oldSelected := d.selected + d.selected = themeIdx + d.lastClickTime = now + d.lastClickIndex = themeIdx + if d.selected != oldSelected { + cmd := d.emitPreview() + return d, cmd + } + } + } + return d, nil case tea.KeyPressMsg: if cmd := HandleQuit(msg); cmd != nil { @@ -162,7 +175,6 @@ func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch { case key.Matches(msg, d.keyMap.Escape): - // Restore original theme on cancel return d, tea.Sequence( core.CmdHandler(CloseDialogMsg{}), core.CmdHandler(messages.ThemeCancelPreviewMsg{OriginalRef: d.originalThemeRef}), @@ -171,7 +183,7 @@ func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Up): if d.selected > 0 { d.selected-- - d.needsScrollToSel = true + d.scrollview.EnsureLineVisible(d.findSelectedLine(nil)) cmd := d.emitPreview() return d, cmd } @@ -180,33 +192,7 @@ func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, d.keyMap.Down): if d.selected < len(d.filtered)-1 { d.selected++ - d.needsScrollToSel = true - cmd := d.emitPreview() - return d, cmd - } - return d, nil - - case key.Matches(msg, d.keyMap.PageUp): - oldSelected := d.selected - d.selected -= d.pageSize() - if d.selected < 0 { - d.selected = 0 - } - d.needsScrollToSel = true - if d.selected != oldSelected { - cmd := d.emitPreview() - return d, cmd - } - return d, nil - - case key.Matches(msg, d.keyMap.PageDown): - oldSelected := d.selected - d.selected += d.pageSize() - if d.selected >= len(d.filtered) { - d.selected = max(0, len(d.filtered)-1) - } - d.needsScrollToSel = true - if d.selected != oldSelected { + d.scrollview.EnsureLineVisible(d.findSelectedLine(nil)) cmd := d.emitPreview() return d, cmd } @@ -220,7 +206,7 @@ func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { var cmd tea.Cmd d.textInput, cmd = d.textInput.Update(msg) if selectionChanged := d.filterThemes(); selectionChanged { - d.needsScrollToSel = true + d.scrollview.EnsureLineVisible(d.findSelectedLine(nil)) return d, tea.Batch(cmd, d.emitPreview()) } return d, cmd @@ -230,107 +216,9 @@ func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return d, nil } -func (d *themePickerDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) { - // Check if click is on the scrollbar - if d.isMouseOnScrollbar(msg.X, msg.Y) { - sb, cmd := d.scrollbar.Update(msg) - d.scrollbar = sb - return d, cmd - } - - // Check if click is on a theme in the list - if msg.Button == tea.MouseLeft { - if themeIdx := d.mouseYToThemeIndex(msg.Y); themeIdx >= 0 { - now := time.Now() - - // Check for double-click - if themeIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { - d.selected = themeIdx - d.lastClickTime = time.Time{} - cmd := d.handleSelection() - return d, cmd - } - - // Single click: highlight and preview - oldSelected := d.selected - d.selected = themeIdx - d.lastClickTime = now - d.lastClickIndex = themeIdx - - // Emit preview if selection changed - if d.selected != oldSelected { - cmd := d.emitPreview() - return d, cmd - } - } - } - return d, nil -} - -func (d *themePickerDialog) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd) { - if d.scrollbar.IsDragging() { - sb, cmd := d.scrollbar.Update(msg) - d.scrollbar = sb - return d, cmd - } - return d, nil -} - -func (d *themePickerDialog) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.Cmd) { - if d.scrollbar.IsDragging() { - sb, cmd := d.scrollbar.Update(msg) - d.scrollbar = sb - return d, cmd - } - return d, nil -} - -func (d *themePickerDialog) handleMouseWheel(msg tea.MouseWheelMsg) (layout.Model, tea.Cmd) { - if !d.isMouseInDialog(msg.X, msg.Y) { - return d, nil - } - - buttonStr := msg.Button.String() - switch buttonStr { - case "wheelup": - d.scrollbar.ScrollUp() - d.scrollbar.ScrollUp() - case "wheeldown": - d.scrollbar.ScrollDown() - d.scrollbar.ScrollDown() - } - return d, nil -} - -func (d *themePickerDialog) isMouseInDialog(x, y int) bool { - dialogRow, dialogCol := d.Position() - dialogWidth, maxHeight, _ := d.dialogSize() - return x >= dialogCol && x < dialogCol+dialogWidth && - y >= dialogRow && y < dialogRow+maxHeight -} - -func (d *themePickerDialog) isMouseOnScrollbar(x, y int) bool { - dialogWidth, maxHeight, _ := d.dialogSize() - maxItems := maxHeight - pickerListVerticalOverhead - - // If the list fits, there is no scrollbar. - // Note: total lines include category separators (if any). - if d.totalLineCount() <= maxItems { - return false - } - - dialogRow, dialogCol := d.Position() - scrollbarX := dialogCol + dialogWidth - pickerScrollbarXInset - scrollbar.Width - scrollbarY := dialogRow + pickerScrollbarYOffset - - return x >= scrollbarX && x < scrollbarX+scrollbar.Width && - y >= scrollbarY && y < scrollbarY+maxItems -} - func (d *themePickerDialog) mouseYToThemeIndex(y int) int { dialogRow, _ := d.Position() - _, maxHeight, _ := d.dialogSize() - maxItems := maxHeight - pickerListVerticalOverhead + maxItems := d.scrollview.VisibleHeight() listStartY := dialogRow + pickerListStartOffset listEndY := listStartY + maxItems @@ -340,7 +228,7 @@ func (d *themePickerDialog) mouseYToThemeIndex(y int) int { } lineInView := y - listStartY - scrollOffset := d.scrollbar.GetScrollOffset() + scrollOffset := d.scrollview.ScrollOffset() actualLine := scrollOffset + lineInView return d.lineToThemeIndex(actualLine) @@ -379,17 +267,25 @@ func (d *themePickerDialog) emitPreview() tea.Cmd { const customThemesSeparatorLabel = "── Custom themes " func (d *themePickerDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { - // Match the model picker sizing for consistent UI. dialogWidth = max(min(d.Width()*pickerWidthPercent/100, pickerMaxWidth), pickerMinWidth) maxHeight = min(d.Height()*pickerHeightPercent/100, pickerMaxHeight) - contentWidth = dialogWidth - pickerDialogPadding - scrollbar.Width - pickerScrollbarGap + contentWidth = dialogWidth - pickerDialogPadding - d.scrollview.ReservedCols() return dialogWidth, maxHeight, contentWidth } +// SetSize sets the dialog dimensions and configures the scrollview. +func (d *themePickerDialog) SetSize(width, height int) tea.Cmd { + cmd := d.BaseDialog.SetSize(width, height) + _, maxHeight, contentWidth := d.dialogSize() + regionWidth := contentWidth + d.scrollview.ReservedCols() + visLines := max(1, maxHeight-pickerListVerticalOverhead) + d.scrollview.SetSize(regionWidth, visLines) + return cmd +} + func (d *themePickerDialog) View() string { - dialogWidth, maxHeight, contentWidth := d.dialogSize() + dialogWidth, _, contentWidth := d.dialogSize() d.textInput.SetWidth(contentWidth) - maxItems := maxHeight - pickerListVerticalOverhead // Build all theme lines var allLines []string @@ -417,74 +313,29 @@ func (d *themePickerDialog) View() string { allLines = append(allLines, d.renderTheme(theme, i == d.selected, contentWidth)) } - totalLines := len(allLines) - visibleLines := maxItems + regionWidth := contentWidth + d.scrollview.ReservedCols() - // Update scrollbar dimensions - d.scrollbar.SetDimensions(visibleLines, totalLines) - - // Auto-scroll to selection when keyboard navigation occurred - if d.needsScrollToSel { - selectedLine := d.findSelectedLine(allLines) - scrollOffset := d.scrollbar.GetScrollOffset() - if selectedLine < scrollOffset { - d.scrollbar.SetScrollOffset(selectedLine) - } else if selectedLine >= scrollOffset+visibleLines { - d.scrollbar.SetScrollOffset(selectedLine - visibleLines + 1) - } - d.needsScrollToSel = false - } - - // Slice visible lines based on scroll offset - scrollOffset := d.scrollbar.GetScrollOffset() - visibleEnd := min(scrollOffset+visibleLines, totalLines) - visibleThemeLines := allLines[scrollOffset:visibleEnd] + // Set scrollview position for mouse hit-testing (auto-computed from dialog position) + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+pickerListStartOffset) - // Pad with empty lines if content is shorter than visible area - for len(visibleThemeLines) < visibleLines { - visibleThemeLines = append(visibleThemeLines, "") - } + d.scrollview.SetContent(allLines, len(allLines)) - // Handle empty state + var scrollableContent string if len(d.filtered) == 0 { - visibleThemeLines = []string{"", styles.DialogContentStyle. - Italic(true). - Align(lipgloss.Center). - Width(contentWidth). + visLines := d.scrollview.VisibleHeight() + emptyLines := []string{"", styles.DialogContentStyle. + Italic(true).Align(lipgloss.Center).Width(contentWidth). Render("No themes found")} - for len(visibleThemeLines) < visibleLines { - visibleThemeLines = append(visibleThemeLines, "") + for len(emptyLines) < visLines { + emptyLines = append(emptyLines, "") } - } - - // Build theme list with fixed width - themeListStyle := lipgloss.NewStyle().Width(contentWidth) - var fixedWidthLines []string - for _, line := range visibleThemeLines { - fixedWidthLines = append(fixedWidthLines, themeListStyle.Render(line)) - } - themeListContent := strings.Join(fixedWidthLines, "\n") - - // Set scrollbar position for mouse hit testing - dialogRow, dialogCol := d.Position() - scrollbarX := dialogCol + dialogWidth - pickerScrollbarXInset - scrollbar.Width - scrollbarY := dialogRow + pickerScrollbarYOffset - d.scrollbar.SetPosition(scrollbarX, scrollbarY) - - // Get scrollbar view - scrollbarView := d.scrollbar.View() - - // Combine content with scrollbar - var scrollableContent string - gap := strings.Repeat(" ", pickerScrollbarGap) - if scrollbarView != "" { - scrollableContent = lipgloss.JoinHorizontal(lipgloss.Top, themeListContent, gap, scrollbarView) + scrollableContent = d.scrollview.ViewWithLines(emptyLines) } else { - scrollbarPlaceholder := strings.Repeat(" ", scrollbar.Width) - scrollableContent = lipgloss.JoinHorizontal(lipgloss.Top, themeListContent, gap, scrollbarPlaceholder) + scrollableContent = d.scrollview.View() } - content := NewContent(contentWidth+pickerScrollbarGap+scrollbar.Width). + content := NewContent(regionWidth). AddTitle("Select Theme"). AddSpace(). AddContent(d.textInput.View()). @@ -569,11 +420,6 @@ func (d *themePickerDialog) renderTheme(theme ThemeChoice, selected bool, maxWid return name } -func (d *themePickerDialog) pageSize() int { - _, maxHeight, _ := d.dialogSize() - return max(1, maxHeight-pickerListVerticalOverhead) -} - func (d *themePickerDialog) Position() (row, col int) { dialogWidth, maxHeight, _ := d.dialogSize() return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight) @@ -612,8 +458,8 @@ func (d *themePickerDialog) filterThemes() (selectionChanged bool) { } } - // Reset scrollbar when filtering. - d.scrollbar.SetScrollOffset(0) + // Reset scroll when filtering. + d.scrollview.SetScrollOffset(0) // Determine if selection changed. newRef := "" @@ -623,30 +469,6 @@ func (d *themePickerDialog) filterThemes() (selectionChanged bool) { return newRef != prevRef } -// totalLineCount returns the total number of visible list lines, including category separators. -func (d *themePickerDialog) totalLineCount() int { - if len(d.filtered) == 0 { - return 0 - } - - hasBuiltinThemes := false - hasCustomThemes := false - for _, t := range d.filtered { - if t.IsBuiltin { - hasBuiltinThemes = true - } else { - hasCustomThemes = true - } - } - - sepCount := 0 - if hasCustomThemes && hasBuiltinThemes { - sepCount++ - } - - return len(d.filtered) + sepCount -} - // lineToThemeIndex converts a line index (in the rendered list including separators) // to a theme index in d.filtered. Returns -1 if the line is a separator. func (d *themePickerDialog) lineToThemeIndex(lineIdx int) int { @@ -683,7 +505,7 @@ func (d *themePickerDialog) lineToThemeIndex(lineIdx int) int { } // findSelectedLine returns the line index (including separators) that corresponds to the selected theme. -func (d *themePickerDialog) findSelectedLine(allLines []string) int { +func (d *themePickerDialog) findSelectedLine(_ []string) int { if d.selected < 0 || d.selected >= len(d.filtered) { return 0 } @@ -715,5 +537,5 @@ func (d *themePickerDialog) findSelectedLine(allLines []string) int { lineIndex++ } - return min(lineIndex, len(allLines)-1) + return lineIndex } diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 89030f1e7..e8a756e21 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -774,14 +774,14 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { cmds = append(cmds, p.sidebar.SetSize(sl.sidebarWidth-toggleColumnWidth, sl.chatHeight), p.sidebar.SetPosition(styles.AppPaddingLeft+sl.sidebarStartX, 0), - p.messages.SetPosition(0, 0), + p.messages.SetPosition(styles.AppPaddingLeft, 0), ) case sidebarCollapsed, sidebarCollapsedNarrow: p.sidebar.SetMode(sidebar.ModeCollapsed) cmds = append(cmds, p.sidebar.SetSize(sl.sidebarWidth, sl.sidebarHeight), p.sidebar.SetPosition(styles.AppPaddingLeft, 0), - p.messages.SetPosition(0, sl.sidebarHeight), + p.messages.SetPosition(styles.AppPaddingLeft, sl.sidebarHeight), ) } diff --git a/pkg/tui/page/chat/input_handlers.go b/pkg/tui/page/chat/input_handlers.go index 7e5a85810..91680c6a7 100644 --- a/pkg/tui/page/chat/input_handlers.go +++ b/pkg/tui/page/chat/input_handlers.go @@ -193,6 +193,21 @@ func (p *chatPage) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea. return p, nil } + // During a scrollbar drag, forward motion to both scrollable components + // so the drag continues even when the cursor drifts outside the component. + // The scrollbar ignores motion if it isn't the one being dragged. + if p.isScrollbarDragging() { + var cmds []tea.Cmd + messagesModel, messagesCmd := p.messages.Update(msg) + p.messages = messagesModel.(messages.Model) + cmds = append(cmds, messagesCmd) + + sidebarModel, sidebarCmd := p.sidebar.Update(msg) + p.sidebar = sidebarModel.(sidebar.Model) + cmds = append(cmds, sidebarCmd) + return p, tea.Batch(cmds...) + } + hit := NewHitTest(p) p.isHoveringHandle = hit.IsOnResizeLine(msg.Y) @@ -201,6 +216,8 @@ func (p *chatPage) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea. } // handleMouseRelease handles mouse release events. +// Release is broadcast to all scrollable components so that a scrollbar drag +// that ends outside the component's bounds still terminates correctly. func (p *chatPage) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.Cmd) { if p.isDragging { p.isDragging = false @@ -210,8 +227,25 @@ func (p *chatPage) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, te p.isDraggingSidebar = false return p, nil } - cmd := p.routeMouseEvent(msg, msg.Y) - return p, cmd + + var cmds []tea.Cmd + + // Forward release to both messages and sidebar so any active scrollbar + // drag is properly ended, regardless of where the mouse was released. + messagesModel, messagesCmd := p.messages.Update(msg) + p.messages = messagesModel.(messages.Model) + cmds = append(cmds, messagesCmd) + + sidebarModel, sidebarCmd := p.sidebar.Update(msg) + p.sidebar = sidebarModel.(sidebar.Model) + cmds = append(cmds, sidebarCmd) + + return p, tea.Batch(cmds...) +} + +// isScrollbarDragging returns true if any scrollable component has an active scrollbar drag. +func (p *chatPage) isScrollbarDragging() bool { + return p.messages.IsScrollbarDragging() || p.sidebar.IsScrollbarDragging() } // handleMouseWheel handles mouse wheel events.