fix(tui): keep every terminal read on a mouse report boundary - #122
Merged
Merged
Conversation
Bubble Tea reads input 256 bytes at a time and parses each read on its own, so a read that ends inside a mouse report is not incomplete from its side: the head is a finished CSI and the bytes behind the cut are decoded as typed runes. Reported as odd characters splicing into the composer while scrolling, reproducible in Terminal.app and not in VSCode. Terminal.app never negotiates SGR mouse mode 1006 -- the system's xterm-256color terminfo advertises kmous=\E[M and no XM -- so a wheel burst is a stream of legacy 6-byte ESC [ M reports, and ESC [ M is a complete sequence on its own. A burst past the 256-byte read was therefore cut mid-report and typed garbage into the draft: 60 reports arrived as 42 mouse events, with the torn head surfacing as an alt+[ keypress. The reassembler now holds two invariants: a read from the terminal never fetches more than the caller's room (bytes read ahead are invisible to the kqueue/epoll wait that gates the next Read, so holding one back stalls the rest of the burst), and a release never ends inside an escape sequence, which covers a non-file source reading its full window and a caller whose buffer shrinks mid-stream. A mouse-shaped head that fills the buffer is dropped rather than streamed as text, a release that stops short of a held head keeps it at offset 0 instead of letting the tail out unheld, and Read with no room is a no-op. Six boundary tests failed before the change and pass after; reverting the release clamp alone fails the small-buffer test at 64 and 256 bytes.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
bodek | 41c0e70 | Commit Preview URL Branch Preview URL |
Sep 14 2026, 09:47 AM |
A read from the watched descriptor is bounded by the caller's room; the non-file path a test constructs reads its full window and relies on the release clamp in emit instead.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The composer kept picking up odd characters in Terminal.app on macOS — repeating
fragments like
;1;1M/64;5;13M, sometimes[, spliced into the draft whilescrolling. It reproduced in Terminal.app and never in VSCode's integrated
terminal.
Root cause
Bubble Tea reads input 256 bytes at a time and parses each read on its own
(
key.go:var buf [256]byte;leftOverFromPrevIterationis only consultedwhen
detectOneMsgreportsw == 0). A read that ends in the middle of a mousereport is therefore not "incomplete" from Bubble Tea's side: the head in front of
the cut is a finished CSI, and the bytes behind it are decoded as typed runes.
That is fatal for the legacy X10 encoding, where
ESC [ M(the head) is matchedby Bubble Tea's
unknownCSIReon its own —Mis a final byte — so the threecoordinate bytes that follow become single typed characters. And Terminal.app
never negotiates SGR mouse mode 1006: the system's
xterm-256colorterminfoadvertises
kmous=\E[Mand noXM, so a wheel burst is a stream of 6-byteESC [ Mreports. Any burst longer than 256 bytes gets cut mid-report, and everysuch cut types one stray character into the composer.
AssembleInputalready reassembled reports between reads, but nothing kept aread from ending inside one: the reader filled its own 4096-byte buffer and let
the caller's 256-byte buffer clamp the release inside
copy.Measured before the fix (60 wheel reports = 360 bytes, one write):
Fix
Two invariants in
internal/tui/input_reassembler.go:Bytes read ahead but not released in the same call sit where the
kqueue/epoll readiness wait that gates every
Readcannot see them, soholding one back would stall the rest of the burst until the next keystroke.
(The first attempt clamped only the release; input then stalled mid-burst —
that is why the bound belongs on the read.)
cutBeforeSequence),which covers what the read bound cannot: a non-file source reading its full
window, and a caller whose buffer shrinks mid-stream. Reverting this clamp
alone fails
TestAssembleInputSmallCallerBufferNeverTearsAReportat 64 and256 bytes — it is load-bearing, not decoration.
Plus three guards found in review: a mouse-shaped head that fills the caller's
whole buffer is dropped rather than streamed as text; a release that stops
short of a held head keeps the head at offset 0 (a negative offset would read as
"no head" and let the tail out unheld); and
Readwith no room is a no-op.Tests (RED-first)
internal/tui/input_release_boundary_test.go— the boundary invariant from bothends: the byte stream through the real
os.Pipeharness, and the message mixthrough Bubble Tea's real parser.
TestAssembleInputReleaseNeverSplitsAReportTestAssembleInputSmallCallerBufferNeverTearsAReportTestProgramKeepsLegacyX10BurstIntactTestProgramKeepsSGRBurstIntactTestProgramKeepsLongX10WheelBurstIntactTestProgramDeliversLargePasteWholePastemessageTestProgramKeepsTypingThroughABurstTestReadDropsAReportFillingTheCallersBufferroom <= 0drop pathTestReadWithNoRoomIsANoOpRead(nil)TestCutBeforeSequenceClampsAtTheStraddlingReportRED proof: all six original tests failed on
main(boundary 256 landing insidethe report at 252..258; 42/60 mouse events; the
alt+[leak). Green after thefix. Boundary tests are timing-free — they assert byte positions and message
counts, never spinner frames or sleeps.
Verification
make fmt,make vet,make lint(0 issues) andmake test(go test -race,all packages) all green.
internal/tuicoverage is unchanged by this diff.Reviewed by a three-agent adversarial panel (one read-only reviewer, two
falsification runs against the Bubble Tea and cancelreader sources). Their
findings — the unpinned clamp, the room-
<= 0drop, the negative head offset,and the untested guards — are what the final commit incorporates. The
falsification run's verdict was NOT FALSIFIED, with the one counterexample to the
stated invariant (a report split by more than the 10 ms settle window)
reproducing identically on
main.Docs: the
AGENTS.mdinput-path contract now states both invariants and why thebound is on the read.