Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,23 @@ feat(tui): compact tool steps with Ctrl+E details toggle
Ctrl+C as CSI once those modes are on. `RestoreEnhancedKeys` clears
leftovers on startup and shutdown. A terminal read can end mid-sequence:
`AssembleInput` (wired in `buildProgramOptions`, non-Windows) reassembles
it before Bubble Tea parses each read. Partial `ESC [ < …` heads wait for
their tail; a mouse-shaped head whose tail never arrives is dropped — never
echoed into the composer. Only the SGR form is disposable: the legacy X10
it before Bubble Tea parses each read, under two invariants: a read from
the terminal never fetches more than the room its caller offers, and a
release never ends inside an escape sequence. Bubble Tea parses every read
on its own, so a chunk that stops inside a mouse report turns the head into
a finished CSI and the coordinate bytes behind it into typed runes — exactly
what a wheel burst from a terminal without mode 1006 produces, since
Terminal.app's `xterm-256color` advertises `kmous=\E[M` and answers with
legacy 6-byte `ESC [ M` reports that any burst past Bubble Tea's 256-byte
read cuts mid-report. Bounding the read (not the release) is what keeps
input flowing: bytes read ahead but not released are invisible to the
kqueue/epoll readiness wait that gates the next `Read`, so holding one back
stalls the rest of the burst until the next keystroke. The release clamp
covers what the read bound cannot — a non-file source (tests) reading its
full window, and a caller whose buffer shrinks mid-stream.
Partial `ESC [ < …` heads wait for their tail; a mouse-shaped head whose
tail never arrives is dropped — never echoed into the composer. Only the SGR
form is disposable: the legacy X10
form is joined but never dropped, because its coordinate bytes are
indistinguishable from typed text. Bracket-paste bodies are user data and
are never dropped, whatever they contain. A mouse head a fresh read cannot
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,10 +667,11 @@ never become the weakest link:
reports the mouse so the wheel can scroll. Click a reply card to copy
it, or use `^Y` / `alt+y`. `--plain` keeps the terminal's own scrollback.
- **Garbage like `65;75;25M[<65;75;25M` appears in the composer while
scrolling** — the terminal split a mouse report across reads. bodek
reassembles the input stream before Bubble Tea parses it, so every split
point is covered: press `^U` to clear any text from an older build, and
`bodek upgrade` if you are not on the latest release.
scrolling** — the terminal split a mouse report across reads. bodek now keeps
every read on a report boundary, so no split point can splice a report into
the draft; the old builds showed this most on terminals that never negotiate
SGR mouse mode (Terminal.app). Press `^U` to clear text left by an older
build, and `bodek upgrade`.
- **Colors look wrong** — try `/theme classic`, check `TERM`; `NO_COLOR=1`
forces a colorless render everywhere.
- **Connection dropped mid-turn** — bodek retries with backoff (5 attempts)
Expand Down
142 changes: 125 additions & 17 deletions internal/tui/input_reassembler.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ const inputSettle = 10 * time.Millisecond
// text would type garbage into the composer.
const mouseAbandon = 250 * time.Millisecond

// inputReadLen sizes each source read. Large enough that a whole wheel
// burst lands in one read, small enough to bound memory.
// inputReadLen caps one source read: a whole wheel burst lands in one read,
// while memory stays bounded. A read from the watched terminal is further
// bounded by the caller's room (see readNext).
const inputReadLen = 4096

// stringSeqCap bounds how much of a string sequence (OSC / DCS / APC) is held
Expand Down Expand Up @@ -195,8 +196,22 @@ func (r *inputReassembler) isClosed() bool {
// kqueue/epoll wakeups coherent: a pre-draining pump goroutine would strand
// already-read bytes where a kevent can never see them, freezing input until
// an unrelated later keystroke lands.
//
// For the same reason a read from the terminal never fetches more than the
// room in p: whatever is read but not released in this call sits here where the
// reader's own readiness wait — it waits on the descriptor before every Read —
// cannot see it. Holding a byte back would stall every remaining byte of the
// burst until the next keystroke. With the read bounded by p, a release is
// either the whole buffer or everything up to a held head, and the only bytes
// left behind are an incomplete sequence whose tail is still arriving. A
// non-file source has no readiness wait to strand bytes behind (the wrapper is
// only ever attached to terminals) and reads its full window instead.
func (r *inputReassembler) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
for {
room := len(p) - len(r.buf)
if r.headAt > 0 {
return r.emit(p, r.headAt), nil
}
Expand All @@ -207,23 +222,36 @@ func (r *inputReassembler) Read(p []byte) (int, error) {
if r.srcDone {
return r.finish(p)
}
if n, ok := r.held(p); ok {
if room <= 0 {
// The head fills the caller's whole buffer. A head that can only
// be a mouse report is noise even here: streaming a truncated
// report as text is the bug being fixed, so drop it.
if r.droppable() {
r.drop()
continue
}
// A real sequence longer than the caller's buffer (a long string
// sequence) is streamed rather than held for a tail that could
// never fit.
return r.emit(p, len(r.buf)), nil
}
if n, ok := r.held(p, room); ok {
return n, nil
}
continue
}
if r.srcDone || r.isClosed() {
return r.finish(p)
}
c, _ := r.readNext(-1)
c, _ := r.readNext(-1, room)
r.take(c)
}
}

// held waits for the tail of the head that occupies the whole buffer. It
// reports false when the head was resolved (dropped or flushed), in which case
// the caller re-plans.
func (r *inputReassembler) held(p []byte) (int, bool) {
// the caller re-plans. room is how many bytes the caller can still take.
func (r *inputReassembler) held(p []byte, room int) (int, bool) {
if !r.holding {
r.holding, r.since, r.heldLen = true, time.Now(), len(r.buf)
}
Expand All @@ -241,7 +269,7 @@ func (r *inputReassembler) held(p []byte) (int, bool) {
budget = r.abandon - time.Since(r.since)
}
if budget > 0 {
if c, ok := r.readNext(budget); ok {
if c, ok := r.readNext(budget, room); ok {
r.take(c)
if r.srcDone {
n, err := r.finish(p)
Expand Down Expand Up @@ -269,29 +297,45 @@ func (r *inputReassembler) take(c inputChunk) {
}

// readNext performs one read from the source, waiting no longer than budget
// for input to arrive. A negative budget blocks until input or the source
// ends. It reports false only when the budget lapsed without any data —
// nothing was consumed, so the caller may retry or give up on the head.
// for input to arrive. A read from the watched descriptor never fetches more
// than room bytes; a non-file source (tests only) reads its full window. A
// negative budget blocks until input or the source ends. It reports false only
// when the budget lapsed without any data — nothing was consumed, so the
// caller may retry or give up on the head.
//
// For a file source the wait is poll(2) on the descriptor followed by the read
// itself, so unread bytes stay in the kernel buffer and kqueue/epoll wakeups
// remain coherent. Non-file sources only occur in unit tests (the wrapper
// itself is attached to terminals only); for those, a single buffered read
// goroutine provides the bounded wait without ever losing a read.
func (r *inputReassembler) readNext(budget time.Duration) (inputChunk, bool) {
func (r *inputReassembler) readNext(budget time.Duration, room int) (inputChunk, bool) {
if room <= 0 && r.file != nil {
return inputChunk{}, false
}
// A read from the watched descriptor never exceeds the caller's room:
// bytes read ahead of an unfilled buffer sit where the readiness wait that
// gates every Read (kqueue/epoll, see waitReadable) cannot see them, which
// would stall the rest of the burst until the next keystroke. A non-file
// source has no such wait — the wrapper is only ever attached to terminals,
// so that path exists for tests — and reads its full window instead; the
// release clamp in emit is what keeps its sequences whole.
size := inputReadLen
if r.file != nil {
size = min(inputReadLen, room)
}
if r.file != nil {
if budget >= 0 && !r.waitReadable(budget) {
return inputChunk{}, false
}
b := make([]byte, inputReadLen)
b := make([]byte, size)
n, err := r.file.Read(b)
return inputChunk{data: b[:n], err: err}, true
}
if r.pending == nil {
ch := make(chan inputChunk, 1)
r.pending = ch
go func() {
b := make([]byte, inputReadLen)
b := make([]byte, size)
n, err := r.src.Read(b)
ch <- inputChunk{data: b[:n], err: err}
}()
Expand Down Expand Up @@ -346,22 +390,50 @@ func (r *inputReassembler) absorb(c inputChunk) {
}

// emit copies n buffered bytes into p, tracks paste state across them, and
// never ends a release inside a bracketed-paste marker: a marker torn by the
// caller's buffer size would be released as text and desync paste tracking.
// never ends a release inside an escape sequence or a bracketed-paste marker.
//
// The caller's buffer is what forces the cut: Bubble Tea reads input 256 bytes
// at a time and parses every read on its own, so a chunk that stops inside a
// mouse report is not "incomplete" from its side. The head in front of the cut
// is a finished CSI (ESC [ M is the legacy form's final byte) and the bytes
// behind it are decoded as typed runes — one stray character per boundary,
// repeated through a wheel burst. A torn marker would likewise desync paste
// tracking.
func (r *inputReassembler) emit(p []byte, n int) int {
if n > len(r.buf) {
n = len(r.buf)
}
// The caller's buffer is the hard cut, and it is applied before the
// sequence rules below: a clamp inside copy would silently tear whatever
// sequence happens to sit at the boundary.
if n > len(p) {
n = len(p)
}
if len(p) >= len(pasteStart) {
if cut := cutBeforeMarker(r.buf, n); cut > 0 {
if cut := cutBeforeMarker(r.buf, n); cut > 0 && cut < n {
n = cut
}
}
if cut := cutBeforeSequence(r.buf, n); cut > 0 && cut < n {
n = cut
}
written := copy(p, r.buf[:n])
r.trackPaste(r.buf[:written])
r.buf = r.buf[written:]
if r.headAt >= 0 {
r.headAt -= written
// A release can stop short of the head's start — the rules above cut
// it back — and the remainder is then the head's own tail, so the
// head begins at offset 0. It must never go negative: a negative
// offset reads as "no head", and the tail would be released unheld
// on the next call.
if r.headAt >= written {
r.headAt -= written
} else {
r.headAt = 0
}
}
if len(r.buf) == 0 {
r.headAt = -1
}
r.holding = false
return written
Expand Down Expand Up @@ -572,3 +644,39 @@ func cutBeforeMarker(b []byte, n int) int {
}
return n
}

// cutBeforeSequence shortens n so a release never ends inside an escape
// sequence. It returns the offset the release must stop at, or 0 when no
// sequence straddles n.
//
// The room-bounded read (see Read) already lands releases from a watched
// descriptor on sequence boundaries; this is the release-side guarantee, and
// it is load-bearing wherever the buffer can hold more than the caller asked
// for — a non-file source reading its full window, and a caller that hands a
// smaller buffer than the read that filled the buffer. Bubble Tea cannot
// recover from the split itself: ESC [ M is a valid final byte on its own, so
// it consumes a torn legacy X10 report's head as an unknown CSI and decodes
// the coordinate bytes behind it as text, while a torn SGR report leaves
// ESC [ < … with no final byte and is reported as an Alt+[ keypress with the
// digits behind it as text. Either way the bytes reach the composer. The tail
// is held here instead and released with the next read.
//
// Only the last escape before n can straddle it, so that is the one examined.
// An escape at offset 0 is left alone: stopping there would return an empty
// read and stall the caller — a sequence longer than the caller's buffer (a
// long OSC string) is streamed instead, which Bubble Tea already handles by
// waiting for its terminator.
func cutBeforeSequence(b []byte, n int) int {
if n <= 1 || n >= len(b) {
return 0
}
i := bytes.LastIndexByte(b[:n], 0x1b)
if i <= 0 {
return 0
}
length, complete := escapeLen(b[i:])
if !complete || i+length > n {
return i
}
return 0
}
Loading