Skip to content

Commit bab75c1

Browse files
committed
fix: filter mouse escape fragments leaking into dialogs on Linux
Add universal mouse fragment filter early in handleKeyMsg() using tty.LooksLikeMouseFragment() and a recentMouseEvent flag for bare bracket time-gating. This catches SGR mouse sequences that Linux terminals split across read boundaries, which Bubble Tea delivers as KeyMsg instead of MouseMsg. Remove the now-redundant per-modal isMouseEscapeSequence() calls and the function itself, centralizing the defense in one place.
1 parent 36670d1 commit bab75c1

File tree

3 files changed

+24
-51
lines changed

3 files changed

+24
-51
lines changed

internal/app/model.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ type Model struct {
250250

251251
// Intro animation
252252
intro IntroModel
253+
254+
// Mouse fragment suppression (Linux SGR sequence splitting)
255+
recentMouseEvent bool
253256
}
254257

255258
// New creates a new application model.

internal/app/project_add_modal.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,6 @@ func (m *Model) projectAddNameUpdate(msg tea.Msg, focusID string) (string, tea.C
116116
return "", nil
117117
}
118118

119-
// Filter out unparsed mouse escape sequences
120-
if isMouseEscapeSequence(keyMsg) {
121-
return "", nil
122-
}
123-
124119
// Clear error on typing
125120
m.projectAdd.errorMessage = ""
126121
m.projectAddModalWidth = 0 // Force rebuild to hide error
@@ -187,11 +182,6 @@ func (m *Model) projectAddPathUpdate(msg tea.Msg, focusID string) (string, tea.C
187182
return "", nil
188183
}
189184

190-
// Filter out unparsed mouse escape sequences
191-
if isMouseEscapeSequence(keyMsg) {
192-
return "", nil
193-
}
194-
195185
// Clear error on typing
196186
m.projectAdd.errorMessage = ""
197187
m.projectAddModalWidth = 0 // Force rebuild to hide error

internal/app/update.go

Lines changed: 21 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/atotto/clipboard"
1414
tea "github.com/charmbracelet/bubbletea"
1515
"github.com/marcus/sidecar/internal/community"
16+
"github.com/marcus/sidecar/internal/tty"
1617
"github.com/marcus/sidecar/internal/config"
1718
"github.com/marcus/sidecar/internal/mouse"
1819
"github.com/marcus/sidecar/internal/palette"
@@ -23,23 +24,6 @@ import (
2324
"github.com/marcus/sidecar/internal/version"
2425
)
2526

26-
// isMouseEscapeSequence returns true if the key message appears to be
27-
// an unparsed mouse escape sequence (SGR format: [<...M or [<...m)
28-
func isMouseEscapeSequence(msg tea.KeyMsg) bool {
29-
s := msg.String()
30-
// SGR mouse sequences contain [< and end with M or m
31-
if strings.Contains(s, "[<") && (strings.HasSuffix(s, "M") || strings.HasSuffix(s, "m")) {
32-
return true
33-
}
34-
// Check for semicolon-separated coordinate patterns typical of mouse sequences
35-
if strings.Contains(s, ";") && strings.ContainsAny(s, "0123456789") {
36-
if strings.HasSuffix(s, "M") || strings.HasSuffix(s, "m") {
37-
return true
38-
}
39-
}
40-
return false
41-
}
42-
4327
// Update handles all messages and returns the updated model and commands.
4428
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4529
var cmds []tea.Cmd
@@ -80,6 +64,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8064
return m, tea.Batch(cmds...)
8165

8266
case tea.MouseMsg:
67+
m.recentMouseEvent = true
8368
// Route mouse events to active modal (priority order)
8469
switch m.activeModal() {
8570
case ModalPalette:
@@ -514,6 +499,25 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
514499
}
515500
}
516501

502+
// Universal mouse fragment filter (Linux SGR sequence splitting).
503+
// On Linux, terminals split mouse escape sequences across reads, causing
504+
// Bubble Tea to deliver fragments like "[<67;78;9M" as KeyMsg.
505+
// Fast path checks runes directly to avoid string allocation on every keystroke.
506+
if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 {
507+
// Fast path: single "[" after a mouse event is a CSI fragment
508+
if len(msg.Runes) == 1 && msg.Runes[0] == '[' && m.recentMouseEvent {
509+
m.recentMouseEvent = false
510+
return m, nil
511+
}
512+
// Multi-char fragments: only allocate string when first rune suggests mouse data
513+
if r := msg.Runes[0]; len(msg.Runes) > 1 && (r == '[' || r == '<' || r == ';' || (r >= '0' && r <= '9') || r == 'M' || r == 'm') {
514+
if tty.LooksLikeMouseFragment(string(msg.Runes)) {
515+
return m, nil
516+
}
517+
}
518+
}
519+
m.recentMouseEvent = false
520+
517521
if m.showQuitConfirm {
518522
action, cmd := m.quitModal.HandleKey(msg)
519523
switch action {
@@ -700,11 +704,6 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
700704
return m, nil
701705
}
702706

703-
// Filter out unparsed mouse escape sequences
704-
if isMouseEscapeSequence(msg) {
705-
return m, nil
706-
}
707-
708707
// Forward other keys to text input for filtering
709708
var cmd tea.Cmd
710709
m.worktreeSwitcherInput, cmd = m.worktreeSwitcherInput.Update(msg)
@@ -817,11 +816,6 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
817816
return m, nil
818817
}
819818

820-
// Filter out unparsed mouse escape sequences
821-
if isMouseEscapeSequence(msg) {
822-
return m, nil
823-
}
824-
825819
// Forward other keys to text input for filtering
826820
var cmd tea.Cmd
827821
m.projectSwitcherInput, cmd = m.projectSwitcherInput.Update(msg)
@@ -947,11 +941,6 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
947941
return m, nil
948942
}
949943

950-
// Filter out unparsed mouse escape sequences
951-
if isMouseEscapeSequence(msg) {
952-
return m, nil
953-
}
954-
955944
// Forward other keys to text input for filtering
956945
var cmd tea.Cmd
957946
m.themeSwitcherInput, cmd = m.themeSwitcherInput.Update(msg)
@@ -1033,10 +1022,6 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
10331022
return m, nil
10341023
}
10351024

1036-
if isMouseEscapeSequence(msg) {
1037-
return m, nil
1038-
}
1039-
10401025
// Forward key to text input, then clear modal cache so it rebuilds
10411026
var cmd tea.Cmd
10421027
m.issueInputInput, cmd = m.issueInputInput.Update(msg)
@@ -1850,11 +1835,6 @@ func (m *Model) handleProjectAddThemePickerKeys(msg tea.KeyMsg) (tea.Model, tea.
18501835
return m, nil
18511836
}
18521837

1853-
// Filter out unparsed mouse escape sequences
1854-
if isMouseEscapeSequence(msg) {
1855-
return m, nil
1856-
}
1857-
18581838
// Forward to filter input
18591839
var cmd tea.Cmd
18601840
m.projectAddThemeInput, cmd = m.projectAddThemeInput.Update(msg)

0 commit comments

Comments
 (0)