From b24c08783c73814946d8dab508dccc3dd19ea7a6 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 9 Jun 2026 16:21:32 -0300 Subject: [PATCH 1/3] feat(ui): add mouse wheel scrolling to models and sessions dialogs Enable mouse scroll in both dialogs with cursor clamping to the visible range. Fix FilterableList.Render() resetting scroll offset on every frame. Assisted-by: Crush:qwen3.7-max --- internal/ui/dialog/dialog.go | 2 ++ internal/ui/dialog/models.go | 14 ++++++++++++++ internal/ui/dialog/sessions.go | 25 +++++++++++++++++-------- internal/ui/list/filterable.go | 19 ++++++++++++++++++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index dc5ce3913d..b601d32c9d 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -20,6 +20,8 @@ const ( titleContentHeight = 1 // inputContentHeight is the height of the input content line. inputContentHeight = 1 + // mouseScrollLines is the number of lines to scroll on mouse wheel events. + mouseScrollLines = 3 ) // CloseKey is the default key binding to close dialogs. diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 76ec4dbbf2..813cf61fd1 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -164,6 +164,20 @@ func (m *Models) ID() string { // HandleMsg implements Dialog. func (m *Models) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + m.list.ScrollBy(-mouseScrollLines) + case tea.MouseWheelDown: + m.list.ScrollBy(mouseScrollLines) + } + start, end := m.list.VisibleItemIndices() + sel := m.list.Selected() + if sel < start { + m.list.SetSelected(start) + } else if sel > end { + m.list.SetSelected(end) + } case tea.KeyPressMsg: switch { case key.Matches(msg, m.keyMap.Close): diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 3b0a033218..f634b219fb 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -141,6 +141,23 @@ func (s *Session) ID() string { // HandleMsg implements Dialog. func (s *Session) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseWheelMsg: + if s.sessionsMode == sessionsModeNormal { + switch msg.Button { + case tea.MouseWheelUp: + s.list.ScrollBy(-mouseScrollLines) + case tea.MouseWheelDown: + s.list.ScrollBy(mouseScrollLines) + } + start, end := s.list.VisibleItemIndices() + sel := s.list.Selected() + if sel < start { + s.list.SetSelected(start) + } else if sel > end { + s.list.SetSelected(end) + } + } + return nil case tea.KeyPressMsg: switch s.sessionsMode { case sessionsModeDeleting: @@ -243,14 +260,6 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { s.list.SetSize(listWidth, listHeight) s.help.SetWidth(innerWidth) - // This makes it so we do not scroll the list if we don't have to - start, end := s.list.VisibleItemIndices() - - // if selected index is outside visible range, scroll to it - if s.selectedSessionInx < start || s.selectedSessionInx > end { - s.list.ScrollToSelected() - } - var cur *tea.Cursor rc := NewRenderContext(t, width) rc.Title = "Sessions" diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index 1c66cf0f13..b26263a698 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -120,6 +120,23 @@ func (f *FilterableList) FilteredItems() []Item { // Render renders the filterable list. func (f *FilterableList) Render() string { - f.List.SetItems(f.FilteredItems()...) + filtered := f.FilteredItems() + if !f.itemsMatch(filtered) { + f.List.SetItems(filtered...) + } return f.List.Render() } + +// itemsMatch reports whether the current list items match the given slice +// by pointer identity and length. +func (f *FilterableList) itemsMatch(items []Item) bool { + if f.List.Len() != len(items) { + return false + } + for i, item := range items { + if f.List.ItemAt(i) != item { + return false + } + } + return true +} From f577b56ce4dffda6cf154f853f7eb616ec064c17 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 9 Jun 2026 17:32:31 -0300 Subject: [PATCH 2/3] feat(ui): add mouse hover selection to models and sessions dialogs Assisted-by: Crush:qwen3.7-max --- internal/ui/dialog/models.go | 34 +++++++++++++++++++++++++++++++--- internal/ui/dialog/sessions.go | 16 ++++++++++++++++ internal/ui/model/ui.go | 6 +++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 813cf61fd1..67809126c0 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -89,9 +89,10 @@ type Models struct { Previous key.Binding Close key.Binding } - list *ModelsList - input textinput.Model - help help.Model + list *ModelsList + input textinput.Model + help help.Model + listScreenY int } var _ Dialog = (*Models)(nil) @@ -164,6 +165,11 @@ func (m *Models) ID() string { // HandleMsg implements Dialog. func (m *Models) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseMotionMsg: + idx, _ := m.list.ItemIndexAtPosition(0, msg.Y-m.listScreenY) + if idx >= 0 { + m.list.SetSelected(idx) + } case tea.MouseWheelMsg: switch msg.Button { case tea.MouseWheelUp: @@ -309,6 +315,28 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { rc.Help = m.help.View(m) + // Compute list screen Y for mouse hover detection. + view := rc.Render() + viewWidth, viewHeight := lipgloss.Size(view) + var dialogMinY int + if m.isOnboarding { + blRect := common.BottomLeftRect(area, viewWidth, viewHeight) + dialogMinY = blRect.Min.Y + } else { + centerRect := common.CenterRect(area, viewWidth, viewHeight) + dialogMinY = centerRect.Min.Y + } + // heightOffset includes dialogViewFrameSize (top+bottom) and help. + // List Y from dialog top = borderTop+paddingTop + title + input. + listYFromDialogTop := heightOffset - + t.Dialog.View.GetBorderBottomSize() - t.Dialog.View.GetPaddingBottom() - + t.Dialog.HelpView.GetVerticalFrameSize() + if m.isOnboarding { + // Onboarding renders without dialog view wrapping. + listYFromDialogTop -= t.Dialog.View.GetBorderTopSize() + t.Dialog.View.GetPaddingTop() + } + m.listScreenY = dialogMinY + listYFromDialogTop + cur := m.Cursor() if m.isOnboarding { diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index f634b219fb..7383eced1a 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -36,6 +36,7 @@ type Session struct { input textinput.Model selectedSessionInx int sessions []session.Session + listScreenY int sessionsMode sessionsMode @@ -141,6 +142,13 @@ func (s *Session) ID() string { // HandleMsg implements Dialog. func (s *Session) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseMotionMsg: + if s.sessionsMode == sessionsModeNormal { + idx, _ := s.list.ItemIndexAtPosition(0, msg.Y-s.listScreenY) + if idx >= 0 { + s.list.SetSelected(idx) + } + } case tea.MouseWheelMsg: if s.sessionsMode == sessionsModeNormal { switch msg.Button { @@ -330,6 +338,14 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { view := rc.Render() + // Compute list screen Y for mouse hover detection. + viewWidth, viewHeight := lipgloss.Size(view) + centerRect := common.CenterRect(area, viewWidth, viewHeight) + listYFromDialogTop := heightOffset - + t.Dialog.View.GetBorderBottomSize() - t.Dialog.View.GetPaddingBottom() - + t.Dialog.HelpView.GetVerticalFrameSize() + s.listScreenY = centerRect.Min.Y + listYFromDialogTop + DrawCenterCursor(scr, area, view, cur) return cur } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9dfe722796..88805e2f71 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2334,7 +2334,11 @@ func (m *UI) View() tea.View { if !m.isTransparent { v.BackgroundColor = m.com.Styles.Background } - v.MouseMode = tea.MouseModeCellMotion + if m.dialog.HasDialogs() { + v.MouseMode = tea.MouseModeAllMotion + } else { + v.MouseMode = tea.MouseModeCellMotion + } v.ReportFocus = m.caps.ReportFocusEvents v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir()) From 50c086a0d1214614bd24ca1b8482f0fcc3ddc534 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 9 Jun 2026 17:38:00 -0300 Subject: [PATCH 3/3] feat(ui): add mouse click selection to models and sessions dialogs Assisted-by: Crush:qwen3.7-max --- internal/ui/dialog/models.go | 18 ++++++++++++++++++ internal/ui/dialog/sessions.go | 11 +++++++++++ internal/ui/model/ui.go | 16 ++++++++++++---- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 67809126c0..0c4e0ccf4b 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -165,6 +165,24 @@ func (m *Models) ID() string { // HandleMsg implements Dialog. func (m *Models) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseClickMsg: + idx, _ := m.list.ItemIndexAtPosition(0, msg.Y-m.listScreenY) + if idx >= 0 { + m.list.SetSelected(idx) + selectedItem := m.list.SelectedItem() + if selectedItem == nil { + break + } + modelItem, ok := selectedItem.(*ModelItem) + if !ok { + break + } + return ActionSelectModel{ + Provider: modelItem.prov, + Model: modelItem.SelectedModel(), + ModelType: modelItem.SelectedModelType(), + } + } case tea.MouseMotionMsg: idx, _ := m.list.ItemIndexAtPosition(0, msg.Y-m.listScreenY) if idx >= 0 { diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 7383eced1a..d75b455daf 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -142,6 +142,17 @@ func (s *Session) ID() string { // HandleMsg implements Dialog. func (s *Session) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseClickMsg: + if s.sessionsMode == sessionsModeNormal { + idx, _ := s.list.ItemIndexAtPosition(0, msg.Y-s.listScreenY) + if idx >= 0 { + s.list.SetSelected(idx) + if item := s.list.SelectedItem(); item != nil { + sessionItem := item.(*SessionItem) + return ActionSelectSession{sessionItem.Session} + } + } + } case tea.MouseMotionMsg: if s.sessionsMode == sessionsModeNormal { idx, _ := s.list.ItemIndexAtPosition(0, msg.Y-s.listScreenY) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 88805e2f71..76a7de4742 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -771,7 +771,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseClickMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -798,7 +800,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMotionMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -836,7 +840,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseReleaseMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -858,7 +864,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseWheelMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) }