Skip to content

Commit d102dd2

Browse files
authored
feat(tui): mouse click and double-click support (#293)
## Summary - Enables `MouseModeCellMotion` on all three TUI model `View()` methods so mouse events are delivered to the app - Left-click on the tab bar switches views (fires same init commands as keyboard — fixes Tasks showing blank on first click) - Left-click in list/tree area moves cursor across all views (sessions, tasks, messages, docs, store); clicks in preview/detail panes are correctly ignored using each view's actual split-ratio math - Double-click within 300ms at the same cell synthesizes an Enter key press (activate session, open doc, etc.) - `PasteMsg` now routes through `handleFallthrough` so bracketed paste works in text inputs (new session form, rename modal, etc.) - Notification modal scrolls on mouse wheel; all other wheel events fall through unchanged - `switchToView()` consolidates tab-switching logic — keyboard and mouse tab-bar click now share one path, eliminating duplication ## Bug fixes caught during review - `messages.Controller.SelectAt` was validating `idx` against `displayed` instead of `filteredAt` — with an active filter this put `cursor` out of bounds for `filteredAt`, making `Selected()` return `nil` - `messages.View.SelectAtRow` used wrong split-ratio defaults vs the renderer (bounds `> 90`, default `40` vs `> 80`, default `25` with `min-20` floor); click hit regions were misaligned from what was rendered - `messages.View.SelectAtRow` ignored the filter line when computing the row offset, causing off-by-one clicks whenever a filter was active - `sessions.View.SelectAtRow` bounds-checked against `Items()` instead of `VisibleItems()`, allowing clicks to select hidden items when the list was filtered - `kv_view.SelectAtRow` accepted clicks anywhere in the row, including the preview pane; now guards against clicks right of the key-list pane - Tab bar click previously bypassed the `isModalActive()` guard, silently switching views while modals were open - Double-click state now resets on view switch to prevent a stale cross-view double-click ## Test plan - [ ] `mise run check` passes (0 lint issues, all tests green) - [ ] Click a session row to select it; double-click to activate (open in terminal) - [ ] Click the Tasks/Messages/Docs/Store tab labels to switch views - [ ] Click a task row; detail pane updates; clicking in the detail pane does not move the cursor - [ ] Click a doc in the Docs tree; preview updates in right pane - [ ] Paste text into the new-session form using terminal paste (Cmd+V / bracketed paste) - [ ] Open notification modal, scroll with mouse wheel - [ ] With a modal open (rename, confirm), click a tab → view should NOT switch
1 parent cfb61c9 commit d102dd2

21 files changed

Lines changed: 1309 additions & 44 deletions

.github/workflows/partial-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: golangci-lint
2727
uses: golangci/golangci-lint-action@v9
2828
with:
29-
version: latest
29+
version: v2.11.4
3030
args: --timeout=6m
3131

3232
- name: Build

internal/tui/hc_only.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,5 +368,7 @@ func (m HoneycombOnlyModel) View() tea.View {
368368
if m.helpDialog != nil {
369369
content = m.helpDialog.Overlay(content, m.width, m.height)
370370
}
371-
return tea.NewView(content)
371+
v := tea.NewView(content)
372+
v.MouseMode = tea.MouseModeCellMotion
373+
return v
372374
}

internal/tui/kv_view.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strings"
66
"time"
77

8+
tea "charm.land/bubbletea/v2"
89
"github.com/charmbracelet/x/ansi"
910
"github.com/colonyops/hive/internal/core/kv"
1011
"github.com/colonyops/hive/internal/core/styles"
@@ -78,6 +79,34 @@ func (v *KVView) SelectedKey() string {
7879
return v.keys[v.filtered[v.cursor]]
7980
}
8081

82+
// SelectAtRow moves the cursor to the key at contentY rows from the view top.
83+
// The key list renders a one-line header and, when filtering is active, a filter line.
84+
// Clicks in the preview pane (right of the key list) are ignored.
85+
func (v *KVView) SelectAtRow(x, contentY int) tea.Cmd {
86+
// Key list occupies 20% of width (min 15), matching View().
87+
listWidth := int(float64(v.width) * 0.20)
88+
if listWidth < 15 {
89+
listWidth = 15
90+
}
91+
if x >= listWidth {
92+
return nil
93+
}
94+
headerRows := 1 // "Keys" header
95+
if v.filtering || v.filter != "" {
96+
headerRows = 2 // header + filter line
97+
}
98+
listRow := contentY - headerRows
99+
if listRow < 0 || len(v.filtered) == 0 {
100+
return nil
101+
}
102+
idx := v.offset + listRow
103+
if idx >= len(v.filtered) {
104+
return nil
105+
}
106+
v.cursor = idx
107+
return nil
108+
}
109+
81110
// MoveUp moves the cursor up in the key list.
82111
func (v *KVView) MoveUp() {
83112
if v.cursor > 0 {

internal/tui/kv_view_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// newKVViewWithKeys creates a KVView preloaded with the given keys.
11+
func newKVViewWithKeys(keys []string) *KVView {
12+
v := NewKVView()
13+
v.SetSize(100, 24)
14+
v.SetKeys(keys)
15+
return v
16+
}
17+
18+
// --- SelectAtRow ---
19+
20+
func TestKVView_SelectAtRow_ClickInPreview_NoOp(t *testing.T) {
21+
v := newKVViewWithKeys([]string{"key-a", "key-b", "key-c"})
22+
// listWidth = int(100 * 0.20) = 20; x=50 >= 20 → no-op
23+
v.SelectAtRow(50, 1)
24+
assert.Equal(t, 0, v.cursor, "click in preview pane should be no-op")
25+
}
26+
27+
func TestKVView_SelectAtRow_HeaderRow_NoOp(t *testing.T) {
28+
v := newKVViewWithKeys([]string{"key-a", "key-b", "key-c"})
29+
// headerRows=1 (no filter); listRow = contentY - 1 = -1 → no-op
30+
v.SelectAtRow(0, 0)
31+
assert.Equal(t, 0, v.cursor, "clicking header row (contentY=0) should be no-op")
32+
}
33+
34+
func TestKVView_SelectAtRow_HappyPath(t *testing.T) {
35+
v := newKVViewWithKeys([]string{"key-a", "key-b", "key-c"})
36+
// headerRows=1; contentY=1 → listRow=0, idx=offset+0=0
37+
v.SelectAtRow(0, 1)
38+
assert.Equal(t, 0, v.cursor, "contentY=1 should select cursor=0")
39+
40+
// contentY=2 → listRow=1, idx=1
41+
v.SelectAtRow(0, 2)
42+
assert.Equal(t, 1, v.cursor, "contentY=2 should select cursor=1")
43+
}
44+
45+
func TestKVView_SelectAtRow_FilterActive_FilterLineIsNoOp(t *testing.T) {
46+
v := newKVViewWithKeys([]string{"alpha", "beta", "gamma"})
47+
v.StartFilter()
48+
v.AddFilterRune('a')
49+
50+
// headerRows=2 (header + filter line); contentY=1 → listRow=-1 → no-op
51+
v.SelectAtRow(0, 1)
52+
assert.Equal(t, 0, v.cursor, "contentY=1 (filter line) should be no-op when filter active")
53+
}
54+
55+
func TestKVView_SelectAtRow_FilterActive_ContentY2_SelectsFirst(t *testing.T) {
56+
v := newKVViewWithKeys([]string{"alpha", "beta", "gamma"})
57+
v.StartFilter()
58+
v.AddFilterRune('a') // matches "alpha" and "gamma" (2 items)
59+
60+
require.NotEmpty(t, v.filtered, "filter should produce at least one match")
61+
62+
// headerRows=2; contentY=2 → listRow=0, idx=0
63+
v.SelectAtRow(0, 2)
64+
assert.Equal(t, 0, v.cursor, "contentY=2 should select first filtered item")
65+
}
66+
67+
func TestKVView_SelectAtRow_EmptyFiltered_NoOp(t *testing.T) {
68+
v := newKVViewWithKeys([]string{"alpha", "beta"})
69+
v.StartFilter()
70+
v.AddFilterRune('z') // no matches
71+
72+
require.Empty(t, v.filtered, "filter should produce no matches")
73+
74+
v.SelectAtRow(0, 1)
75+
assert.Equal(t, 0, v.cursor, "no-op when filtered list is empty")
76+
}
77+
78+
func TestKVView_SelectAtRow_BeyondFiltered_NoOp(t *testing.T) {
79+
v := newKVViewWithKeys([]string{"key-a"})
80+
// filtered has 1 entry; contentY=10 → idx=9 >= 1 → no-op
81+
v.SelectAtRow(0, 10)
82+
assert.Equal(t, 0, v.cursor, "row beyond filtered items should be no-op")
83+
}

internal/tui/model.go

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ type Model struct {
154154

155155
// Startup warnings to show as toasts after init
156156
startupWarnings []string
157+
158+
// Double-click tracking: last left-button click position and time.
159+
lastClickX, lastClickY int
160+
lastClickTime time.Time
157161
}
158162

159163
// actionCompleteMsg is sent when an action completes.
@@ -626,6 +630,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
626630
model, cmd = m.handleKeyMsg(msg)
627631
case spinner.TickMsg:
628632
model, cmd = m.handleSpinnerTick(msg)
633+
case tea.MouseClickMsg:
634+
model, cmd = m.handleMouseClick(msg)
635+
case tea.MouseWheelMsg:
636+
if m.state == stateShowingNotifications && m.modals.Notification != nil {
637+
if msg.Button == tea.MouseWheelUp {
638+
m.modals.Notification.ScrollUp()
639+
} else {
640+
m.modals.Notification.ScrollDown()
641+
}
642+
model, cmd = m, nil
643+
} else {
644+
model, cmd = m.handleFallthrough(msg)
645+
}
646+
case tea.PasteMsg:
647+
model, cmd = m.handleFallthrough(msg)
629648

630649
default:
631650
model, cmd = m.handleFallthrough(msg)
@@ -1418,35 +1437,7 @@ func (m Model) handleTabKey(direction int) (tea.Model, tea.Cmd) {
14181437
}
14191438

14201439
next := (current + direction + len(tabs)) % len(tabs)
1421-
m.activeView = tabs[next]
1422-
1423-
m.handler.SetActiveView(m.activeView)
1424-
m.sessionsView.SetActive(m.activeView == ViewSessions)
1425-
if m.msgView != nil {
1426-
m.msgView.SetActive(m.activeView == ViewMessages)
1427-
}
1428-
if m.tasksView != nil {
1429-
m.tasksView.SetActive(m.activeView == ViewTasks)
1430-
}
1431-
1432-
// Load data when switching to Store or Tasks tab
1433-
switch m.activeView {
1434-
case ViewStore:
1435-
return m, m.loadKVKeys()
1436-
case ViewTasks:
1437-
if cmd := m.syncTasksRepoFromSessions(); cmd != nil {
1438-
return m, cmd
1439-
}
1440-
return m, func() tea.Msg { return tasks.RefreshTasksMsg{} }
1441-
case ViewReview:
1442-
if cmd := m.syncDocsRepoFromSessions(); cmd != nil {
1443-
return m, cmd
1444-
}
1445-
case ViewSessions, ViewMessages:
1446-
// No special load action needed
1447-
}
1448-
1449-
return m, nil
1440+
return m.switchToView(tabs[next])
14501441
}
14511442

14521443
// renameCompleteMsg is sent when a rename operation completes.

internal/tui/model_handlers.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,137 @@ func (m Model) handleFallthrough(msg tea.Msg) (tea.Model, tea.Cmd) {
10671067
return m, tea.Batch(cmds...)
10681068
}
10691069

1070+
const doubleClickWindow = 300 * time.Millisecond
1071+
1072+
// handleMouseClick routes left-button clicks to the active view or tab bar.
1073+
// Two clicks within doubleClickWindow at the same cell synthesize an Enter key press.
1074+
func (m Model) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
1075+
if msg.Button != tea.MouseLeft || m.isModalActive() {
1076+
return m, nil
1077+
}
1078+
1079+
// Tab bar is at terminal row 1 (topDivider=0, header=1, headerDivider=2).
1080+
if msg.Y == 1 {
1081+
return m.handleTabClick(msg.X)
1082+
}
1083+
1084+
const tabChrome = 3
1085+
contentY := msg.Y - tabChrome
1086+
if contentY < 0 {
1087+
return m, nil
1088+
}
1089+
1090+
// Detect double-click: same cell within the window.
1091+
now := time.Now()
1092+
isDouble := msg.X == m.lastClickX && msg.Y == m.lastClickY &&
1093+
now.Sub(m.lastClickTime) <= doubleClickWindow
1094+
m.lastClickX = msg.X
1095+
m.lastClickY = msg.Y
1096+
m.lastClickTime = now
1097+
1098+
if isDouble {
1099+
return m.handleKeyMsg(tea.KeyPressMsg{Code: tea.KeyEnter})
1100+
}
1101+
1102+
var cmd tea.Cmd
1103+
switch m.activeView {
1104+
case ViewSessions:
1105+
if m.sessionsView != nil {
1106+
cmd = m.sessionsView.SelectAtRow(msg.X, contentY)
1107+
}
1108+
case ViewTasks:
1109+
if m.tasksView != nil {
1110+
cmd = m.tasksView.SelectAtRow(msg.X, contentY)
1111+
}
1112+
case ViewMessages:
1113+
if m.msgView != nil {
1114+
cmd = m.msgView.SelectAtRow(msg.X, contentY)
1115+
}
1116+
case ViewReview:
1117+
if m.reviewView != nil {
1118+
cmd = m.reviewView.SelectAtRow(msg.X, contentY)
1119+
}
1120+
case ViewStore:
1121+
if m.kvView != nil {
1122+
cmd = m.kvView.SelectAtRow(msg.X, contentY)
1123+
}
1124+
}
1125+
return m, cmd
1126+
}
1127+
1128+
// switchToView activates the given view, updating all per-view active flags and
1129+
// firing any data-load commands required on first entry. This is the single
1130+
// source of truth for view switching used by both keyboard (tab key) and mouse
1131+
// (tab bar click).
1132+
func (m Model) switchToView(view ViewType) (tea.Model, tea.Cmd) {
1133+
m.lastClickTime = time.Time{} // reset double-click state across view changes
1134+
m.activeView = view
1135+
m.handler.SetActiveView(view)
1136+
m.sessionsView.SetActive(view == ViewSessions)
1137+
if m.msgView != nil {
1138+
m.msgView.SetActive(view == ViewMessages)
1139+
}
1140+
if m.tasksView != nil {
1141+
m.tasksView.SetActive(view == ViewTasks)
1142+
}
1143+
1144+
switch view {
1145+
case ViewStore:
1146+
return m, m.loadKVKeys()
1147+
case ViewTasks:
1148+
if cmd := m.syncTasksRepoFromSessions(); cmd != nil {
1149+
return m, cmd
1150+
}
1151+
return m, func() tea.Msg { return tasks.RefreshTasksMsg{} }
1152+
case ViewReview:
1153+
if cmd := m.syncDocsRepoFromSessions(); cmd != nil {
1154+
return m, cmd
1155+
}
1156+
case ViewSessions, ViewMessages:
1157+
// No data load needed on switch.
1158+
}
1159+
return m, nil
1160+
}
1161+
1162+
// handleTabClick switches the active view based on which tab label was clicked at column x.
1163+
// Tab labels start at column 1 (one-space left margin) and are separated by " | " (3 cols).
1164+
func (m Model) handleTabClick(x int) (tea.Model, tea.Cmd) {
1165+
showStoreTab := m.kvStore != nil && m.cfg.TUI.Store
1166+
1167+
type tabEntry struct {
1168+
view ViewType
1169+
label string
1170+
}
1171+
tabs := []tabEntry{{ViewSessions, "Sessions"}}
1172+
if m.tasksView != nil {
1173+
tabs = append(tabs, tabEntry{ViewTasks, "Tasks"})
1174+
}
1175+
if m.reviewView != nil {
1176+
tabs = append(tabs, tabEntry{ViewReview, "Docs"})
1177+
}
1178+
tabs = append(tabs, tabEntry{ViewMessages, "Messages"})
1179+
if showStoreTab || m.activeView == ViewStore {
1180+
tabs = append(tabs, tabEntry{ViewStore, "Store"})
1181+
}
1182+
1183+
const (
1184+
leftMargin = 1
1185+
sepWidth = 3 // " | "
1186+
)
1187+
pos := leftMargin
1188+
for i, t := range tabs {
1189+
w := len(t.label) // styles use no padding/borders, so visual width == len
1190+
if x >= pos && x < pos+w {
1191+
return m.switchToView(t.view)
1192+
}
1193+
pos += w
1194+
if i < len(tabs)-1 {
1195+
pos += sepWidth
1196+
}
1197+
}
1198+
return m, nil
1199+
}
1200+
10701201
// --- Helper for repo header opening ---
10711202

10721203
func (m Model) openRepoHeaderByRemote(name, remote string) (tea.Model, tea.Cmd) {

0 commit comments

Comments
 (0)