Skip to content

Commit b6179ab

Browse files
mariusvniekerkwesmclaude
authored
Add global config flag to disable mouse interactions (#465)
## Summary - add a persisted global `mouse_enabled` config value that defaults to true - expose `Mouse interactions` in the TUI options menu and apply the toggle immediately in-session - disable Bubble Tea mouse capture and ignore mouse events when the setting is off, with regression coverage for config and TUI behavior ## Test Plan - go fmt ./... - go vet ./... - go test ./... Closes #446. --------- Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 71ff1f2 commit b6179ab

8 files changed

Lines changed: 265 additions & 16 deletions

File tree

cmd/roborev/tui/handlers_queue.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ func (m model) handleColumnOptionsKey() (tea.Model, tea.Cmd) {
185185
name: "Column borders",
186186
enabled: m.colBordersOn,
187187
})
188+
opts = append(opts, columnOption{
189+
id: colOptionMouse,
190+
name: "Mouse interactions",
191+
enabled: m.mouseEnabled,
192+
})
188193
opts = append(opts, columnOption{
189194
id: colOptionTasksWorkflow,
190195
name: "Tasks workflow",
@@ -257,6 +262,11 @@ func (m model) handleColumnOptionsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
257262
m.colOptionsDirty = true
258263
m.queueColGen++
259264
m.taskColGen++
265+
} else if opt.id == colOptionMouse {
266+
opt.enabled = !opt.enabled
267+
m.mouseEnabled = opt.enabled
268+
m.colOptionsDirty = true
269+
return m, mouseCaptureCmd(m.currentView, m.mouseEnabled)
260270
} else if opt.id == colOptionTasksWorkflow {
261271
opt.enabled = !opt.enabled
262272
m.tasksEnabled = opt.enabled

cmd/roborev/tui/queue_test.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2108,12 +2108,16 @@ func TestColumnOptionsModalOpenClose(t *testing.T) {
21082108
t.Fatal("expected non-empty colOptionsList")
21092109
}
21102110
// Trailing items should include the settings toggles.
2111-
if len(m2.colOptionsList) < 2 {
2111+
if len(m2.colOptionsList) < 3 {
21122112
t.Fatalf("expected settings toggles at end of colOptionsList, got %d items", len(m2.colOptionsList))
21132113
}
2114-
borders := m2.colOptionsList[len(m2.colOptionsList)-2]
2114+
borders := m2.colOptionsList[len(m2.colOptionsList)-3]
21152115
if borders.id != colOptionBorders || borders.name != "Column borders" {
2116-
t.Errorf("expected penultimate item to be borders toggle, got id=%d name=%q", borders.id, borders.name)
2116+
t.Errorf("expected third-from-last item to be borders toggle, got id=%d name=%q", borders.id, borders.name)
2117+
}
2118+
mouse := m2.colOptionsList[len(m2.colOptionsList)-2]
2119+
if mouse.id != colOptionMouse || mouse.name != "Mouse interactions" {
2120+
t.Errorf("expected second-from-last item to be mouse toggle, got id=%d name=%q", mouse.id, mouse.name)
21172121
}
21182122
tasks := m2.colOptionsList[len(m2.colOptionsList)-1]
21192123
if tasks.id != colOptionTasksWorkflow || tasks.name != "Tasks workflow" {
@@ -2163,6 +2167,31 @@ func TestColumnOptionsToggle(t *testing.T) {
21632167
}
21642168
}
21652169

2170+
func TestMouseDisabledIgnoresQueueMouseInput(t *testing.T) {
2171+
m := newTuiModel("http://localhost")
2172+
m.currentView = tuiViewQueue
2173+
m.mouseEnabled = false
2174+
m.width = 120
2175+
m.height = 20
2176+
m.jobs = []storage.ReviewJob{
2177+
makeJob(1),
2178+
makeJob(2),
2179+
makeJob(3),
2180+
}
2181+
m.selectedIdx = 0
2182+
m.selectedJobID = 1
2183+
2184+
m2, _ := updateModel(t, m, mouseLeftClick(4, 6))
2185+
if m2.selectedIdx != 0 || m2.selectedJobID != 1 {
2186+
t.Fatalf("expected click to be ignored when mouse disabled, got idx=%d id=%d", m2.selectedIdx, m2.selectedJobID)
2187+
}
2188+
2189+
m3, _ := updateModel(t, m2, mouseWheelDown())
2190+
if m3.selectedIdx != 0 || m3.selectedJobID != 1 {
2191+
t.Fatalf("expected wheel to be ignored when mouse disabled, got idx=%d id=%d", m3.selectedIdx, m3.selectedJobID)
2192+
}
2193+
}
2194+
21662195
func TestHiddenColumnNotRendered(t *testing.T) {
21672196
m := newTuiModel("localhost:7373")
21682197
m.jobs = []storage.ReviewJob{

cmd/roborev/tui/render_queue.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,7 @@ func (m model) visibleColumns() []int {
917917
func (m model) saveColumnOptions() tea.Cmd {
918918
hidden := hiddenColumnsToNames(m.hiddenColumns)
919919
borders := m.colBordersOn
920+
mouseEnabled := m.mouseEnabled
920921
tasksEnabled := m.tasksWorkflowEnabled()
921922
var colOrd []string
922923
if !slices.Equal(m.columnOrder, toggleableColumns) {
@@ -933,6 +934,7 @@ func (m model) saveColumnOptions() tea.Cmd {
933934
}
934935
cfg.HiddenColumns = hidden
935936
cfg.ColumnBorders = borders
937+
cfg.MouseEnabled = mouseEnabled
936938
cfg.ColumnOrder = colOrd
937939
cfg.TaskColumnOrder = taskColOrd
938940
cfg.Advanced.TasksEnabled = tasksEnabled
@@ -962,7 +964,7 @@ func (m model) renderColumnOptionsView() string {
962964
line = selectedStyle.Render(line)
963965
}
964966
// Separator before settings/toggles
965-
if (opt.id == colOptionBorders || opt.id == colOptionTasksWorkflow) && i > 0 {
967+
if opt.id == colOptionBorders && i > 0 {
966968
b.WriteString("\n")
967969
}
968970
b.WriteString(prefix)

cmd/roborev/tui/tui.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ type model struct {
369369
distractionFree bool // hide status line, headers, footer, scroll indicator
370370
clipboard ClipboardWriter
371371
tasksEnabled bool // Enables advanced tasks workflow in the TUI
372+
mouseEnabled bool // Enables mouse capture and mouse-driven interactions in the TUI
372373

373374
// Review view navigation
374375
reviewFromView viewKind // View to return to when exiting review (queue or tasks)
@@ -437,6 +438,7 @@ func newModel(serverAddr string, opts ...option) model {
437438
hideClosed := false
438439
autoFilterRepo := false
439440
autoFilterBranch := false
441+
mouseEnabled := true
440442
tabWidth := 2
441443
columnBorders := false
442444
tasksEnabled := false
@@ -456,6 +458,7 @@ func newModel(serverAddr string, opts ...option) model {
456458
hideClosed = cfg.HideClosedByDefault
457459
autoFilterRepo = cfg.AutoFilterRepo
458460
autoFilterBranch = cfg.AutoFilterBranch
461+
mouseEnabled = cfg.MouseEnabled
459462
if cfg.TabWidth > 0 {
460463
tabWidth = cfg.TabWidth
461464
}
@@ -539,6 +542,7 @@ func newModel(serverAddr string, opts ...option) model {
539542
clipboard: &realClipboard{},
540543
mdCache: newMarkdownCache(tabWidth),
541544
tasksEnabled: tasksEnabled,
545+
mouseEnabled: mouseEnabled,
542546
colBordersOn: columnBorders,
543547
hiddenColumns: hiddenCols,
544548
columnOrder: colOrder,
@@ -659,6 +663,17 @@ func mouseDisabledView(v viewKind) bool {
659663
return false
660664
}
661665

666+
func mouseCaptureEnabled(v viewKind, mouseEnabled bool) bool {
667+
return mouseEnabled && !mouseDisabledView(v)
668+
}
669+
670+
func mouseCaptureCmd(v viewKind, mouseEnabled bool) tea.Cmd {
671+
if mouseCaptureEnabled(v, mouseEnabled) {
672+
return tea.EnableMouseCellMotion
673+
}
674+
return tea.DisableMouse
675+
}
676+
662677
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
663678
prevView := m.currentView
664679

@@ -669,6 +684,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
669684
case tea.KeyMsg:
670685
result, cmd = m.handleKeyMsg(msg)
671686
case tea.MouseMsg:
687+
if !m.mouseEnabled {
688+
return m, nil
689+
}
672690
result, cmd = m.handleMouseMsg(msg)
673691
case tea.WindowSizeMsg:
674692
result, cmd = m.handleWindowSizeMsg(msg)
@@ -750,14 +768,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
750768
return result, cmd
751769
}
752770
newView := updated.currentView
753-
if newView != prevView {
754-
wasDisabled := mouseDisabledView(prevView)
755-
nowDisabled := mouseDisabledView(newView)
756-
if nowDisabled && !wasDisabled {
757-
cmd = tea.Batch(cmd, tea.DisableMouse)
758-
} else if !nowDisabled && wasDisabled {
759-
cmd = tea.Batch(cmd, tea.EnableMouseCellMotion)
760-
}
771+
prevCapture := mouseCaptureEnabled(prevView, m.mouseEnabled)
772+
newCapture := mouseCaptureEnabled(newView, updated.mouseEnabled)
773+
if prevCapture != newCapture {
774+
cmd = tea.Batch(cmd, mouseCaptureCmd(newView, updated.mouseEnabled))
761775
}
762776

763777
return result, cmd
@@ -811,6 +825,16 @@ type Config struct {
811825
BranchFilter string
812826
}
813827

828+
func programOptionsForModel(m model) []tea.ProgramOption {
829+
programOpts := []tea.ProgramOption{
830+
tea.WithAltScreen(),
831+
}
832+
if m.mouseEnabled {
833+
programOpts = append(programOpts, tea.WithMouseCellMotion())
834+
}
835+
return programOpts
836+
}
837+
814838
// Run starts the interactive TUI.
815839
func Run(cfg Config) error {
816840
var opts []option
@@ -820,10 +844,10 @@ func Run(cfg Config) error {
820844
if cfg.BranchFilter != "" {
821845
opts = append(opts, withBranchFilter(cfg.BranchFilter))
822846
}
847+
m := newModel(cfg.ServerAddr, opts...)
823848
p := tea.NewProgram(
824-
newModel(cfg.ServerAddr, opts...),
825-
tea.WithAltScreen(),
826-
tea.WithMouseCellMotion(),
849+
m,
850+
programOptionsForModel(m)...,
827851
)
828852
_, err := p.Run()
829853
return err

cmd/roborev/tui/tui_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,147 @@ func TestTUIColumnOptionsCanEnableTasksWorkflow(t *testing.T) {
10881088
t.Fatal("expected advanced.tasks_enabled to persist as true")
10891089
}
10901090
}
1091+
1092+
func TestTUIColumnOptionsCanDisableMouse(t *testing.T) {
1093+
setupTuiTestEnv(t)
1094+
1095+
m := newModel(testServerAddr, withExternalIODisabled())
1096+
m.currentView = viewQueue
1097+
1098+
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
1099+
updated := result.(model)
1100+
if updated.currentView != viewColumnOptions {
1101+
t.Fatalf("expected column options view, got %v", updated.currentView)
1102+
}
1103+
1104+
idx := -1
1105+
for i, opt := range updated.colOptionsList {
1106+
if opt.id == colOptionMouse {
1107+
idx = i
1108+
break
1109+
}
1110+
}
1111+
if idx < 0 {
1112+
t.Fatal("expected mouse option in column options")
1113+
}
1114+
updated.colOptionsIdx = idx
1115+
1116+
result, cmd := updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
1117+
toggled := result.(model)
1118+
if toggled.mouseEnabled {
1119+
t.Fatal("expected mouse to be disabled after toggle")
1120+
}
1121+
if cmd == nil {
1122+
t.Fatal("expected mouse toggle command after disabling mouse")
1123+
}
1124+
msgs := collectMsgs(cmd)
1125+
if !hasMsgType(msgs, "tea.disableMouseMsg") {
1126+
t.Fatalf("expected disableMouseMsg after disabling mouse, got %v", msgs)
1127+
}
1128+
1129+
result, cmd = toggled.Update(tea.KeyMsg{Type: tea.KeyEsc})
1130+
closed := result.(model)
1131+
if closed.currentView != viewQueue {
1132+
t.Fatalf("expected to return to queue view, got %v", closed.currentView)
1133+
}
1134+
if cmd == nil {
1135+
t.Fatal("expected save command after closing column options")
1136+
}
1137+
msgs = collectMsgs(cmd)
1138+
if len(msgs) > 0 {
1139+
if last := msgs[len(msgs)-1]; last != nil {
1140+
if errMsg, ok := last.(configSaveErrMsg); ok {
1141+
t.Fatalf("save config failed: %v", errMsg.err)
1142+
}
1143+
}
1144+
}
1145+
1146+
cfg, err := config.LoadGlobal()
1147+
if err != nil {
1148+
t.Fatalf("LoadGlobal failed: %v", err)
1149+
}
1150+
if cfg.MouseEnabled {
1151+
t.Fatal("expected mouse_enabled to persist as false")
1152+
}
1153+
}
1154+
1155+
func TestTUIColumnOptionsCanReEnableMouse(t *testing.T) {
1156+
setupTuiTestEnv(t)
1157+
1158+
m := newModel(testServerAddr, withExternalIODisabled())
1159+
m.currentView = viewQueue
1160+
m.width = 120
1161+
m.height = 20
1162+
m.jobs = []storage.ReviewJob{
1163+
makeJob(1),
1164+
makeJob(2),
1165+
makeJob(3),
1166+
}
1167+
m.selectedIdx = 0
1168+
m.selectedJobID = 1
1169+
1170+
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
1171+
updated := result.(model)
1172+
1173+
idx := -1
1174+
for i, opt := range updated.colOptionsList {
1175+
if opt.id == colOptionMouse {
1176+
idx = i
1177+
break
1178+
}
1179+
}
1180+
if idx < 0 {
1181+
t.Fatal("expected mouse option in column options")
1182+
}
1183+
updated.colOptionsIdx = idx
1184+
1185+
result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
1186+
disabled := result.(model)
1187+
if disabled.mouseEnabled {
1188+
t.Fatal("expected mouse to be disabled after first toggle")
1189+
}
1190+
1191+
result, cmd := disabled.Update(tea.KeyMsg{Type: tea.KeyEnter})
1192+
reenabled := result.(model)
1193+
if !reenabled.mouseEnabled {
1194+
t.Fatal("expected mouse to be enabled after second toggle")
1195+
}
1196+
if cmd == nil {
1197+
t.Fatal("expected mouse toggle command after enabling mouse")
1198+
}
1199+
msgs := collectMsgs(cmd)
1200+
if !hasMsgType(msgs, "tea.enableMouseCellMotionMsg") {
1201+
t.Fatalf("expected enableMouseCellMotionMsg after enabling mouse, got %v", msgs)
1202+
}
1203+
1204+
result, _ = reenabled.Update(tea.KeyMsg{Type: tea.KeyEsc})
1205+
closed := result.(model)
1206+
if closed.currentView != viewQueue {
1207+
t.Fatalf("expected to return to queue view, got %v", closed.currentView)
1208+
}
1209+
1210+
m2, _ := updateModel(t, closed, mouseWheelDown())
1211+
if m2.selectedIdx != 1 || m2.selectedJobID != 2 {
1212+
t.Fatalf("expected wheel to work after re-enabling mouse, got idx=%d id=%d", m2.selectedIdx, m2.selectedJobID)
1213+
}
1214+
}
1215+
1216+
func TestNewModelLoadsMouseDisabledFromConfig(t *testing.T) {
1217+
tmpDir := setupTuiTestEnv(t)
1218+
1219+
configPath := filepath.Join(tmpDir, "config.toml")
1220+
if err := os.WriteFile(configPath, []byte("mouse_enabled = false\n"), 0644); err != nil {
1221+
t.Fatalf("write config: %v", err)
1222+
}
1223+
1224+
m := newModel(testServerAddr)
1225+
if m.mouseEnabled {
1226+
t.Fatal("expected newModel to load mouse_enabled = false from config")
1227+
}
1228+
if len(programOptionsForModel(m)) != 1 {
1229+
t.Fatalf("expected startup options without mouse capture when disabled, got %d options", len(programOptionsForModel(m)))
1230+
}
1231+
}
10911232
func TestTUISelection(t *testing.T) {
10921233
t.Run("MaintainedOnInsert", func(t *testing.T) {
10931234
m := newModel(testServerAddr, withExternalIODisabled())

cmd/roborev/tui/types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ type colWidthCache struct {
8383
// Sentinel IDs for non-column toggles in the column options modal.
8484
const (
8585
colOptionBorders = -1
86-
colOptionTasksWorkflow = -2
86+
colOptionMouse = -2
87+
colOptionTasksWorkflow = -3
8788
)
8889

8990
// columnOption represents an item in the column options modal.

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ type Config struct {
143143
HideAddressedByDefault bool `toml:"hide_addressed_by_default"` // deprecated: use hide_closed_by_default
144144
AutoFilterRepo bool `toml:"auto_filter_repo"`
145145
AutoFilterBranch bool `toml:"auto_filter_branch"`
146+
MouseEnabled bool `toml:"mouse_enabled"` // Enable mouse capture and mouse-driven TUI interactions
146147
TabWidth int `toml:"tab_width"` // Tab expansion width for TUI rendering (default: 2)
147148
HiddenColumns []string `toml:"hidden_columns"` // Column names to hide in queue table (e.g. ["branch", "agent"])
148149
ColumnBorders bool `toml:"column_borders"` // Show ▕ separators between columns
@@ -639,6 +640,7 @@ func DefaultConfig() *Config {
639640
ClaudeCodeCmd: "claude",
640641
CursorCmd: "agent",
641642
PiCmd: "pi",
643+
MouseEnabled: true,
642644
}
643645
cfg.CI.ThrottleBypassUsers = []string{
644646
"wesm", "mariusvniekerk",

0 commit comments

Comments
 (0)