▍ humdrum codex / glint v1.0.2
license AGPL-3.0

fix(app): drop leaked mouse-wheel escape residue from text input (TASK-029)

0a0b819db8aac59e326c27ae4e204e1de95bb170
humdrum <me@humdrum.me> · 2026-06-29 21:08

parent 4ec44590

fix(app): drop leaked mouse-wheel escape residue from text input (TASK-029)

Fast wheel-scrolling could insert literal `<64;68;26M…` SGR mouse
sequences into the buffer: Bubble Tea's input parser splits a burst of
mouse events across read boundaries and emits the leftover as a KeyRunes
text event. Mouse is enabled and wheel events are handled — this is
parser leakage, and v1.3.10 is already the latest.

Guard handleKey: a KeyRunes whose content is entirely an SGR mouse
sequence (^(<?\d+;\d+;\d+[Mm])+$) is dropped before insertion. A genuine
keystroke never delivers a full digits;digits;digits[Mm] group as one
rune event, so normal typing is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

4 files changed

- → Mouse-wheel-scroll-sequences-leak-into-buffer-as-text.md +60 −0
@@ -0,0 +1,61 @@
+<<<<<<< conflict 1 of 1
++++++++ ssysuoqq 836fac3b "fix(app): drop leaked mouse-wheel escape residue from text input (TASK-029)" (squash destination)
+---
+id: TASK-029
+title: Mouse-wheel scroll sequences leak into buffer as text
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-06-30 04:05'
+updated_date: '2026-06-30 04:08'
+labels:
+  - bug
+dependencies: []
+priority: high
+ordinal: 29000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Fast wheel-scrolling (esp. near the top of a doc, Ghostty) inserts literal SGR mouse escape residue into the buffer, e.g. '<64;68;26M<64;68;26M...'. Cause: Bubble Tea's input parser splits a burst of SGR mouse sequences across read boundaries and emits the leftover as a KeyRunes text event, which the editor inserts. Mouse is enabled (WithMouseCellMotion) and wheel events ARE handled; this is parser leakage, not missing handling. bubbletea v1.3.10 is already latest. Fix: drop KeyRunes events that are entirely a leaked mouse sequence at the input boundary.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 A KeyRunes event whose content is entirely SGR mouse residue is ignored, never inserted
+- [x] #2 Normal typing (including a lone '<' or text containing '<' / ';') is unaffected
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Added looksLikeMouseLeak (mouseleak.go): regexp ^(<?\d+;\d+;\d+[Mm])+$ matches a KeyRunes that is entirely SGR mouse residue; guarded at top of app.handleKey to drop it. Defense against Bubble Tea v1.3.10 input parser splitting a burst of wheel-scroll SGR sequences and emitting the tail as text. Normal input (lone '<', text with '<'/';', comparisons) unaffected. TDD, all tests pass.
+<!-- SECTION:NOTES:END -->
+%%%%%%% diff from: nmqwytxt 4ec44590 "docs: note font embedding in the export section (TASK-021)" (parents of squashed revision)
+\\\\\\\        to: rvqpptop 66279105 (squashed revision)
++---
++id: TASK-029
++title: Mouse-wheel scroll sequences leak into buffer as text
++status: "\U0001F7E2 In progress"
++assignee: []
++created_date: '2026-06-30 04:05'
++updated_date: '2026-06-30 04:06'
++labels:
++  - bug
++dependencies: []
++priority: high
++ordinal: 29000
++---
++
++## Description
++
++<!-- SECTION:DESCRIPTION:BEGIN -->
++Fast wheel-scrolling (esp. near the top of a doc, Ghostty) inserts literal SGR mouse escape residue into the buffer, e.g. '<64;68;26M<64;68;26M...'. Cause: Bubble Tea's input parser splits a burst of SGR mouse sequences across read boundaries and emits the leftover as a KeyRunes text event, which the editor inserts. Mouse is enabled (WithMouseCellMotion) and wheel events ARE handled; this is parser leakage, not missing handling. bubbletea v1.3.10 is already latest. Fix: drop KeyRunes events that are entirely a leaked mouse sequence at the input boundary.
++<!-- SECTION:DESCRIPTION:END -->
++
++## Acceptance Criteria
++<!-- AC:BEGIN -->
++- [ ] #1 A KeyRunes event whose content is entirely SGR mouse residue is ignored, never inserted
++- [ ] #2 Normal typing (including a lone '<' or text containing '<' / ';') is unaffected
++<!-- AC:END -->
+>>>>>>> conflict 1 of 1 ends
internal/app/app.go +6 −0
@@ -244,6 +244,12 @@ 	return a, nil
 }
 
 func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	// Drop leaked SGR mouse residue: a burst of wheel events can be split by
+	// the input parser and surface as a KeyRunes of `<64;68;26M…` text that
+	// would otherwise be typed into the buffer (TASK-029).
+	if msg.Type == tea.KeyRunes && looksLikeMouseLeak(string(msg.Runes)) {
+		return a, nil
+	}
 	// Any key other than a second Ctrl+Q disarms the quit confirmation.
 	if msg.Type != tea.KeyCtrlQ {
 		a.quitArmed = false
internal/app/mouseleak.go +19 −0
@@ -0,0 +1,19 @@
+package app
+
+import "regexp"
+
+// mouseLeakRE matches a string that is entirely SGR mouse-event residue —
+// one or more `<button;col;row` groups ending in M (press) or m (release),
+// with the leading `<` sometimes consumed by the parser.
+var mouseLeakRE = regexp.MustCompile(`^(<?\d+;\d+;\d+[Mm])+$`)
+
+// looksLikeMouseLeak reports whether a KeyRunes payload is leaked mouse data
+// rather than typed text. Bubble Tea's input parser can split a burst of SGR
+// mouse sequences (fast wheel scrolling) across read boundaries and emit the
+// leftover as a single KeyRunes text event; without this guard those bytes get
+// inserted into the buffer as literal `<64;68;26M…` junk (TASK-029). A genuine
+// keystroke never delivers a full `digits;digits;digits[Mm]` group as one rune
+// event, so this only ever drops parser residue.
+func looksLikeMouseLeak(s string) bool {
+	return mouseLeakRE.MatchString(s)
+}
internal/app/mouseleak_test.go +27 −0
@@ -0,0 +1,27 @@
+package app
+
+import "testing"
+
+func TestLooksLikeMouseLeak(t *testing.T) {
+	leak := []string{
+		"<64;68;26M",
+		"<64;68;26M<64;68;26M<64;68;26M",
+		"64;68;26M",       // leading '<' consumed by the parser
+		"<65;68;26m",      // release form (lowercase m)
+		"<0;1;1M<0;2;2M",
+	}
+	for _, s := range leak {
+		if !looksLikeMouseLeak(s) {
+			t.Errorf("looksLikeMouseLeak(%q) = false, want true", s)
+		}
+	}
+	ok := []string{
+		"hello", "# glint", "<", "<3", "i <3 you", "a;b;c",
+		"64;68", "", ";;", "<64;68;26", "3 < 5 && 2 > 1",
+	}
+	for _, s := range ok {
+		if looksLikeMouseLeak(s) {
+			t.Errorf("looksLikeMouseLeak(%q) = true, want false (normal input)", s)
+		}
+	}
+}