Skip to content

Commit 0933cdd

Browse files
authored
fix(tui): reassemble fragmented escape sequences before Bubble Tea parses them (#107)
* fix(tui): reassemble fragmented escape sequences before Bubble Tea parses them A terminal read can end in the middle of an escape sequence — most visibly an SGR mouse report. Bubble Tea v1 parses each read independently, so the pending ESC[ becomes an Alt+[ keypress and the printable tail is delivered as ordinary text, typing garbage like 65;75;25M into the composer. #106 only stripped tails of reports split after a lone ESC; measured, 19 of 21 two-way split points still leaked. AssembleInput (internal/tui) wraps the terminal input stream and holds an undecidable head until the bytes that complete it arrive, so Bubble Tea sees whole sequences: SGR and X10 mouse reports, SS3 keys, CSI, and OSC/ DCS-style string sequences. A mouse-shaped head whose tail never arrives is dropped instead of echoed (keeping any bytes typed after it), a lone ESC is released after 10ms so esc still closes popups, bracket-paste bodies are never altered, and releases never cut a paste marker. The wrapper presents itself as a term.File with the real descriptor — Bubble Tea only puts a recognised file into raw mode, and cancelreader probes the same shape — while Close stops the pump without closing stdin, which the program does not own. Only a terminal file is wrapped; pipes and redirects keep Bubble Tea's own handling. Wired in buildProgramOptions, non-Windows. FilterShiftEnter's regex strip stays as a backstop. Tests cover every two- and three-way split of every report shape, bursts, typing around split reports, pastes (incl. 240KB), stalled and slow reports, EOF, SS3/OSC joins, and end-to-end parsing through a real tea.Program. * fix(tui): consume input synchronously so kqueue never strands keystrokes The reassembler's pump goroutine drained the terminal eagerly into a channel. Bubble Tea's kqueue/epoll cancelreader waits on the file descriptor, so bytes already drained were invisible to it: input that arrived while the program was between reads (delivering the previous message) was stranded until an unrelated later keystroke woke the loop. Reads now consume the source synchronously one read at a time, waiting via poll(2) for the settle/abandon windows, so bytes stay in the kernel buffer until they are read and every wakeup stays coherent. The test harness drives real tea.Program runs over an os.Pipe, which Linux's epoll cancelreader requires; each script chunk is written as its own read to keep split points real.
1 parent d709635 commit 0933cdd

9 files changed

Lines changed: 1512 additions & 9 deletions

File tree

‎AGENTS.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ feat(tui): compact tool steps with Ctrl+E details toggle
148148
wall latency.
149149
- `internal/tui` is split by responsibility: `model.go` holds the core
150150
model, `inspect.go` owns transcript item focus and bounded tool paging, `events.go` event handling, `input.go` key/text input,
151-
`approval.go` the approval flow, `chrome.go` drawer-sheet / shelf /
151+
`input_reassembler.go` the raw terminal input stream, `approval.go`
152+
the approval flow, `chrome.go` drawer-sheet / shelf /
152153
header-instrument / session-home layout, `reconnect.go` socket recovery.
153154
Put new code in the matching file instead of growing `model.go`.
154155
- First-run home (`welcome` in `banner.go`) is the working directory plus
@@ -162,7 +163,22 @@ feat(tui): compact tool steps with Ctrl+E details toggle
162163
keys". `FilterShiftEnter` rewrites those CSI sequences (and the
163164
remapped `^C` / `^K` / esc) back into KeyMsgs — Codium/Cursor encode
164165
Ctrl+C as CSI once those modes are on. `RestoreEnhancedKeys` clears
165-
leftovers on startup and shutdown. Alt-screen always enables mouse
166+
leftovers on startup and shutdown. A terminal read can end mid-sequence:
167+
`AssembleInput` (wired in `buildProgramOptions`, non-Windows) reassembles
168+
it before Bubble Tea parses each read. Partial `ESC [ < …` heads wait for
169+
their tail; a mouse-shaped head whose tail never arrives is dropped — never
170+
echoed into the composer. Only the SGR form is disposable: the legacy X10
171+
form is joined but never dropped, because its coordinate bytes are
172+
indistinguishable from typed text. Bracket-paste bodies are user data and
173+
are never dropped, whatever they contain. A mouse head a fresh read cannot
174+
continue is a truncated report and is discarded so the read is not eaten.
175+
The wrapper only wraps a terminal file; a pipe or redirected stdin keeps
176+
Bubble Tea's own handling. It also presents itself as a `term.File` with the
177+
real descriptor, because Bubble Tea enables and restores raw mode only for
178+
input it recognises as a file (`tty_unix.go` `initInput`) — a plain wrapper
179+
would leave the program in cooked mode. One pump goroutine per program
180+
instance; `Close` stops it without closing stdin, which the program does not
181+
own. Alt-screen always enables mouse
166182
cell-motion so the wheel scrolls (and clicks hit turn heads / answer
167183
cards / steps / the queue). A left-click on an answer card (or a
168184
collapsed summary) copies that turn's `msg.content` — final or the

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,11 @@ never become the weakest link:
656656
- **Mouse wheel blocks native text selection** — the alt-screen always
657657
reports the mouse so the wheel can scroll. Click a reply card to copy
658658
it, or use `^Y` / `alt+y`. `--plain` keeps the terminal's own scrollback.
659+
- **Garbage like `65;75;25M[<65;75;25M` appears in the composer while
660+
scrolling** — the terminal split a mouse report across reads. bodek
661+
reassembles the input stream before Bubble Tea parses it, so every split
662+
point is covered: press `^U` to clear any text from an older build, and
663+
`bodek upgrade` if you are not on the latest release.
659664
- **Colors look wrong** — try `/theme classic`, check `TERM`; `NO_COLOR=1`
660665
forces a colorless render everywhere.
661666
- **Connection dropped mid-turn** — bodek retries with backoff (5 attempts)

‎cmd/bodek/main.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"os"
1111
"os/signal"
12+
"runtime"
1213
"strings"
1314
"syscall"
1415

@@ -114,6 +115,14 @@ func buildProgramOptions(plain bool) []tea.ProgramOption {
114115
// Filter first so Shift+Enter CSI sequences become a KeyMsg before
115116
// any other option's machinery sees them.
116117
opts := []tea.ProgramOption{tea.WithFilter(tui.FilterShiftEnter)}
118+
// A terminal read can end in the middle of an escape sequence — most
119+
// visibly a mouse report. Bubble Tea parses each read independently, so
120+
// a split report would land in the composer as text: reassemble the
121+
// stream before it gets there. Windows keeps the console input reader
122+
// Bubble Tea owns (coninput), which this wrapper would bypass.
123+
if runtime.GOOS != "windows" {
124+
opts = append(opts, tea.WithInput(tui.AssembleInput(os.Stdin)))
125+
}
117126
if !plain {
118127
// The alt-screen transcript is the default surface. Linear mode
119128
// (--plain) stays on the main buffer so printed lines persist in

‎cmd/bodek/main_test.go‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"os"
99
"path/filepath"
10+
"runtime"
1011
"strings"
1112
"testing"
1213

@@ -101,8 +102,14 @@ func TestParseConfigHelp(t *testing.T) {
101102

102103
func TestBuildProgramOptionsDefault(t *testing.T) {
103104
opts := buildProgramOptions(false)
104-
if len(opts) != 3 {
105-
t.Fatalf("expected 3 default program options (filter, alt-screen, mouse), got %d", len(opts))
105+
// filter + input assembler + alt-screen + mouse (the assembler is
106+
// skipped on Windows, where Bubble Tea owns the console reader).
107+
want := 4
108+
if runtime.GOOS == "windows" {
109+
want = 3
110+
}
111+
if len(opts) != want {
112+
t.Fatalf("expected %d default program options (filter, assembler, alt-screen, mouse), got %d", want, len(opts))
106113
}
107114
// Sanity check: the option is callable like a real tea.ProgramOption.
108115
var p tea.Program
@@ -111,8 +118,14 @@ func TestBuildProgramOptionsDefault(t *testing.T) {
111118
}
112119

113120
func TestBuildProgramOptionsPlain(t *testing.T) {
114-
if opts := buildProgramOptions(true); len(opts) != 1 {
115-
t.Fatalf("plain mode must skip alt-screen and mouse (filter only), got %d options", len(opts))
121+
// Plain mode keeps the filter and the input assembler, and skips
122+
// alt-screen and mouse.
123+
want := 2
124+
if runtime.GOOS == "windows" {
125+
want = 1
126+
}
127+
if opts := buildProgramOptions(true); len(opts) != want {
128+
t.Fatalf("plain mode must skip alt-screen and mouse (filter + assembler only), got %d options", len(opts))
116129
}
117130
}
118131

‎go.mod‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ require (
88
github.com/charmbracelet/glamour v1.0.0
99
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
1010
github.com/charmbracelet/x/ansi v0.11.8
11+
github.com/charmbracelet/x/term v0.2.2
12+
github.com/muesli/cancelreader v0.2.2
1113
github.com/muesli/termenv v0.16.0
1214
golang.org/x/net v0.58.0
15+
golang.org/x/sys v0.47.0
1316
)
1417

1518
require (
@@ -20,7 +23,6 @@ require (
2023
github.com/charmbracelet/colorprofile v0.4.3 // indirect
2124
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
2225
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // indirect
23-
github.com/charmbracelet/x/term v0.2.2 // indirect
2426
github.com/clipperhouse/displaywidth v0.11.0 // indirect
2527
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
2628
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
@@ -32,13 +34,11 @@ require (
3234
github.com/mattn/go-runewidth v0.0.28 // indirect
3335
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3436
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
35-
github.com/muesli/cancelreader v0.2.2 // indirect
3637
github.com/muesli/reflow v0.3.0 // indirect
3738
github.com/rivo/uniseg v0.4.7 // indirect
3839
github.com/xo/terminfo v1.0.0 // indirect
3940
github.com/yuin/goldmark v1.8.5 // indirect
4041
github.com/yuin/goldmark-emoji v1.0.6 // indirect
41-
golang.org/x/sys v0.47.0 // indirect
4242
golang.org/x/term v0.45.0 // indirect
4343
golang.org/x/text v0.41.0 // indirect
4444
)

0 commit comments

Comments
 (0)