Skip to content

Commit 0948255

Browse files
eric-anderson-gleanscalvertclaude
authored
feat: Improve mouse handling (#66)
Two improvements: 1) notify the user in the status bar when it looks like they're trying to do that to let them know shift+select works. 2) add ctrl-o (mOuse) to toggle mouse scrolling/selection. ctrl-m doesn't work since that's a newline. --------- Co-authored-by: Steve Calvert <steve.calvert@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f451f4f commit 0948255

3 files changed

Lines changed: 153 additions & 15 deletions

File tree

‎internal/tui/model.go‎

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ type Model struct {
9999
currentStage string // Glean thinking stage shown while streaming: "Searching", "Reading", etc.
100100
currentDetail string // optional detail for the current stage
101101
streamCh chan tea.Msg // channel from the API goroutine; nil when not streaming
102+
103+
// Mouse state
104+
mouseEnabled bool // when true, mouse scrolling works but text selection requires Shift+drag
105+
showMouseHint bool // show hint about Shift+drag and ctrl+o toggle when user tries to select
102106
}
103107

104108
// New creates a fully-initialized TUI model.
@@ -131,19 +135,20 @@ func New(cfg *config.Config, session *Session, identity, version string, ctx con
131135
}
132136

133137
m := &Model{
134-
viewport: vp,
135-
textarea: ta,
136-
spinner: sp,
137-
stopwatch: stopwatch.New(),
138-
renderer: renderer,
139-
cfg: cfg,
140-
session: session,
141-
identity: identity,
142-
version: version,
143-
ctx: ctx,
144-
startTime: time.Now(),
145-
historyIdx: -1,
146-
agentMode: components.AgentEnumAuto,
138+
viewport: vp,
139+
textarea: ta,
140+
spinner: sp,
141+
stopwatch: stopwatch.New(),
142+
renderer: renderer,
143+
cfg: cfg,
144+
session: session,
145+
identity: identity,
146+
version: version,
147+
ctx: ctx,
148+
startTime: time.Now(),
149+
historyIdx: -1,
150+
agentMode: components.AgentEnumAuto,
151+
mouseEnabled: true,
147152
}
148153

149154
for _, turn := range session.Turns {
@@ -162,7 +167,7 @@ func (m *Model) Session() *Session {
162167

163168
// Init implements tea.Model.
164169
func (m *Model) Init() tea.Cmd {
165-
return textarea.Blink
170+
return tea.Batch(textarea.Blink, tea.EnableMouseCellMotion)
166171
}
167172

168173
// Update implements tea.Model.
@@ -255,6 +260,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255260
m.showHelp = !m.showHelp
256261
return m, nil
257262

263+
case "ctrl+o":
264+
m.mouseEnabled = !m.mouseEnabled
265+
m.showMouseHint = false
266+
var cmd tea.Cmd
267+
if m.mouseEnabled {
268+
cmd = tea.EnableMouseCellMotion
269+
} else {
270+
cmd = tea.DisableMouse
271+
}
272+
return m, cmd
273+
258274
case "ctrl+l":
259275
m.lastErr = nil
260276
m.viewport.SetContent(m.renderConversation())
@@ -456,6 +472,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
456472

457473
// Mouse scroll events go to the viewport when there is content.
458474
case tea.MouseMsg:
475+
// Detect potential text selection attempts (click/drag, not scroll).
476+
// Show hint about Shift+drag and ctrl+o toggle when user drags with left button.
477+
if m.mouseEnabled && msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionMotion {
478+
m.showMouseHint = true
479+
}
480+
// Hide hint when button is released
481+
if msg.Action == tea.MouseActionRelease {
482+
m.showMouseHint = false
483+
}
484+
459485
if m.session != nil && len(m.session.Turns) > 0 {
460486
m.viewport, vpCmd = m.viewport.Update(msg)
461487
return m, vpCmd

‎internal/tui/tui_test.go‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,107 @@ func TestClosePickerResetsState(t *testing.T) {
507507
assert.Nil(t, m.filePickerItems)
508508
assert.Equal(t, 0, m.filePickerIdx)
509509
}
510+
511+
// TestMouseEnabledByDefault verifies that mouse is enabled on init.
512+
func TestMouseEnabledByDefault(t *testing.T) {
513+
m := newTestModel(t)
514+
assert.True(t, m.mouseEnabled, "mouse should be enabled by default")
515+
}
516+
517+
// TestCtrlOTogglesMouse verifies that ctrl+o toggles mouseEnabled state.
518+
func TestCtrlOTogglesMouse(t *testing.T) {
519+
m := newTestModel(t)
520+
assert.True(t, m.mouseEnabled, "starts enabled")
521+
522+
// First toggle: disable
523+
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
524+
r := updated.(*Model)
525+
assert.False(t, r.mouseEnabled, "ctrl+o should disable mouse")
526+
assert.NotNil(t, cmd, "should return DisableMouse command")
527+
528+
// Second toggle: re-enable
529+
updated2, cmd2 := r.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
530+
r2 := updated2.(*Model)
531+
assert.True(t, r2.mouseEnabled, "ctrl+o should re-enable mouse")
532+
assert.NotNil(t, cmd2, "should return EnableMouseCellMotion command")
533+
}
534+
535+
// TestMouseHintShowsWhileSelecting verifies the selection hint behavior.
536+
func TestMouseHintShowsWhileSelecting(t *testing.T) {
537+
m := newTestModel(t)
538+
m.session.Turns = []Turn{{Role: roleUser, Content: "test"}}
539+
assert.False(t, m.showMouseHint, "hint should start hidden")
540+
541+
// Simulate left-button drag motion - should show hint
542+
dragMsg := tea.MouseMsg{
543+
Button: tea.MouseButtonLeft,
544+
Action: tea.MouseActionMotion,
545+
}
546+
updated, _ := m.Update(dragMsg)
547+
r := updated.(*Model)
548+
assert.True(t, r.showMouseHint, "dragging with left button should show hint")
549+
550+
// Simulate mouse button release - should hide hint
551+
releaseMsg := tea.MouseMsg{
552+
Action: tea.MouseActionRelease,
553+
}
554+
updated2, _ := r.Update(releaseMsg)
555+
r2 := updated2.(*Model)
556+
assert.False(t, r2.showMouseHint, "releasing mouse should hide hint")
557+
}
558+
559+
// TestMouseHintNotShownWhenDisabled verifies hint doesn't show when mouse is disabled.
560+
func TestMouseHintNotShownWhenDisabled(t *testing.T) {
561+
m := newTestModel(t)
562+
m.mouseEnabled = false
563+
m.session.Turns = []Turn{{Role: roleUser, Content: "test"}}
564+
565+
// Simulate left-button drag motion
566+
msg := tea.MouseMsg{
567+
Button: tea.MouseButtonLeft,
568+
Action: tea.MouseActionMotion,
569+
}
570+
updated, _ := m.Update(msg)
571+
r := updated.(*Model)
572+
573+
assert.False(t, r.showMouseHint, "hint should not show when mouse is disabled")
574+
}
575+
576+
// TestCtrlOClearsMouseHint verifies that toggling mouse mode clears the hint.
577+
func TestCtrlOClearsMouseHint(t *testing.T) {
578+
m := newTestModel(t)
579+
m.showMouseHint = true
580+
581+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
582+
r := updated.(*Model)
583+
584+
assert.False(t, r.showMouseHint, "ctrl+o should clear mouse hint")
585+
}
586+
587+
// TestViewShowsMouseHint verifies the hint is displayed in the view.
588+
func TestViewShowsMouseHint(t *testing.T) {
589+
m := newTestModel(t)
590+
m.showMouseHint = true
591+
592+
view := m.View()
593+
assert.Contains(t, view, "To select text: hold Shift+drag")
594+
assert.Contains(t, view, "ctrl+o to toggle mouse mode")
595+
}
596+
597+
// TestStatusBarShowsMouseIndicator verifies mouse-off indicator in status bar.
598+
func TestStatusBarShowsMouseIndicator(t *testing.T) {
599+
m := newTestModel(t)
600+
m.mouseEnabled = false
601+
602+
status := m.statusLine()
603+
assert.Contains(t, status, "🖱️ off", "status bar should show mouse indicator when disabled")
604+
}
605+
606+
// TestStatusBarNoMouseIndicatorWhenEnabled verifies no indicator when mouse is enabled.
607+
func TestStatusBarNoMouseIndicatorWhenEnabled(t *testing.T) {
608+
m := newTestModel(t)
609+
m.mouseEnabled = true
610+
611+
status := m.statusLine()
612+
assert.NotContains(t, status, "🖱️ off", "status bar should not show indicator when mouse is enabled")
613+
}

‎internal/tui/view.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ func (m *Model) View() string {
3636
if m.showExitHint {
3737
bottom = styleExitHint.Render(" Press ctrl+c again to exit · esc to cancel")
3838
}
39+
if m.showMouseHint && !m.showExitHint {
40+
bottom = styleExitHint.Render(" To select text: hold Shift+drag · ctrl+o to toggle mouse mode")
41+
}
3942

4043
// Chip shows attached files above the input box (always).
4144
chip := m.attachedFilesView()
@@ -157,7 +160,11 @@ func (m *Model) statusLine() string {
157160
left = modeLabel
158161
}
159162

160-
right := styleStatusBar.Render("ctrl+r new ctrl+l clear ctrl+y copy ctrl+h help ctrl+c quit")
163+
mouseIndicator := ""
164+
if !m.mouseEnabled {
165+
mouseIndicator = " 🖱️ off"
166+
}
167+
right := styleStatusBar.Render("ctrl+r new ctrl+l clear ctrl+y copy ctrl+h help ctrl+c quit" + mouseIndicator)
161168

162169
leftW := lipgloss.Width(left)
163170
rightW := lipgloss.Width(right)
@@ -176,6 +183,7 @@ func (m *Model) helpView() string {
176183
{"↑ / ↓ or pgup / pgdn", "Scroll history"},
177184
{"ctrl+r", "New session (clear history)"},
178185
{"ctrl+l", "Clear screen"},
186+
{"ctrl+o", "Toggle mouse scrolling/selection"},
179187
{"ctrl+c / esc", "Quit"},
180188
{"ctrl+h", "Toggle this help"},
181189
{"", ""},

0 commit comments

Comments
 (0)