From 07550a5e92961f3f1e77a8a802b789f1acbf5e0e Mon Sep 17 00:00:00 2001 From: Alec Gutmanstein Date: Sat, 28 Mar 2026 00:22:22 +0000 Subject: [PATCH] fix(tui): patch StdinParser to prevent garbled text from fragmented mouse sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SGR mouse escape sequences arrive fragmented across multiple stdin chunks (due to event loop pressure from LLM streaming + TUI rendering), opentui's StdinParser timeout fires mid-sequence and leaks individual bytes as KEY events, inserting garbled characters into the input box. Three timeout paths in the parser could trigger this: 1. esc_recovery timeout: emitted '[' as a KEY event via emitKeyOrResponse. Fixed to use emitOpaqueResponse (dropped) and set justFlushedEscBracket flag so subsequent '<' routes to mouse handling. 2. CSI timeout: dropped '\x1b[' correctly but lost recovery context. Fixed to set justFlushedEscBracket so '<' arriving in ground state routes to esc_less_mouse. 3. esc_less_mouse timeout: had no deferred mechanism — flushed accumulated bytes and returned to ground, causing remaining bytes to leak as KEY events. Fixed to clear forceFlush and wait for more bytes, matching the csi_sgr_mouse_deferred pattern. Also increases DEFAULT_TIMEOUT_MS from 10 to 25ms (matches kitty/alacritty defaults) for defense-in-depth. Includes test-stdin-parser.mjs (parser unit test) and frag-pty.py (PTY reproducer with 12ms byte-by-byte delay). Verified end-to-end: frag-pty.py no longer produces garbled text. Fixes #3199 Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 3 +- package.json | 3 +- packages/opencode/test/cli/tui/frag-pty.py | 136 ++++++++++++++++++ .../test/cli/tui/test-stdin-parser.mjs | 69 +++++++++ patches/@opentui%2Fcore@0.1.90.patch | 77 ++++++++++ 5 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/cli/tui/frag-pty.py create mode 100644 packages/opencode/test/cli/tui/test-stdin-parser.mjs create mode 100644 patches/@opentui%2Fcore@0.1.90.patch diff --git a/bun.lock b/bun.lock index 783abe289398..9faecce1afaa 100644 --- a/bun.lock +++ b/bun.lock @@ -599,10 +599,11 @@ "tree-sitter-bash", ], "patchedDependencies": { + "@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch", - "@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch", + "@opentui/core@0.1.90": "patches/@opentui%2Fcore@0.1.90.patch", }, "overrides": { "@types/bun": "catalog:", diff --git a/package.json b/package.json index 2b1c15fb6988..eb07f8d29cb1 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch", - "@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch" + "@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch", + "@opentui/core@0.1.90": "patches/@opentui%2Fcore@0.1.90.patch" } } diff --git a/packages/opencode/test/cli/tui/frag-pty.py b/packages/opencode/test/cli/tui/frag-pty.py new file mode 100644 index 000000000000..9aea3901fd6c --- /dev/null +++ b/packages/opencode/test/cli/tui/frag-pty.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +PTY wrapper that fragments mouse escape sequences before they reach opencode. +Every mouse event is split byte-by-byte with 12ms delays between bytes. +Keyboard input passes through instantly. + +Usage: python3 frag-pty.py opencode [args...] +""" + +import os +import pty +import sys +import select +import time +import struct +import fcntl +import termios +import signal + +FRAGMENT_DELAY = 0.012 # 12ms — exceeds opentui's 10ms StdinParser timeout +ESC = 0x1b + +def set_winsize(fd): + """Copy terminal size to PTY.""" + try: + size = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b'\x00' * 8) + fcntl.ioctl(fd, termios.TIOCSWINSZ, size) + except: + pass + +def contains_mouse_seq(data): + """Check if data contains SGR mouse sequence start: ESC [ <""" + for i in range(len(data) - 2): + if data[i] == ESC and data[i+1] == ord('[') and data[i+2] == ord('<'): + return True + return False + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 frag-pty.py opencode [args...]") + sys.exit(1) + + # Create PTY + master_fd, slave_fd = pty.openpty() + set_winsize(master_fd) + + pid = os.fork() + if pid == 0: + # Child: run opencode on the slave PTY + os.close(master_fd) + os.setsid() + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + os.dup2(slave_fd, 0) + os.dup2(slave_fd, 1) + os.dup2(slave_fd, 2) + if slave_fd > 2: + os.close(slave_fd) + os.execvp(sys.argv[1], sys.argv[1:]) + + # Parent: proxy between terminal and master PTY + os.close(slave_fd) + + # Save and set raw mode + old_attrs = termios.tcgetattr(sys.stdin.fileno()) + try: + raw = termios.tcgetattr(sys.stdin.fileno()) + raw[0] = 0 # iflag + raw[1] = 0 # oflag + raw[2] = raw[2] & ~(termios.CSIZE | termios.PARENB) | termios.CS8 # cflag + raw[3] = 0 # lflag + raw[6][termios.VMIN] = 1 + raw[6][termios.VTIME] = 0 + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, raw) + except: + pass + + # Handle SIGWINCH — resize PTY when terminal resizes + def on_resize(signum, frame): + set_winsize(master_fd) + os.kill(pid, signal.SIGWINCH) + signal.signal(signal.SIGWINCH, on_resize) + + # Handle child exit + done = False + def on_child(signum, frame): + nonlocal done + done = True + signal.signal(signal.SIGCHLD, on_child) + + stdin_fd = sys.stdin.fileno() + stdout_fd = sys.stdout.fileno() + + try: + while not done: + try: + rlist, _, _ = select.select([stdin_fd, master_fd], [], [], 0.1) + except (select.error, InterruptedError): + continue + + if master_fd in rlist: + # PTY output → terminal (pass through immediately) + try: + data = os.read(master_fd, 65536) + if not data: + break + os.write(stdout_fd, data) + except OSError: + break + + if stdin_fd in rlist: + # Terminal input → check for mouse, fragment if needed + try: + data = os.read(stdin_fd, 65536) + if not data: + break + + if contains_mouse_seq(data): + # Fragment byte-by-byte with delay + for byte in data: + os.write(master_fd, bytes([byte])) + time.sleep(FRAGMENT_DELAY) + else: + # Pass through immediately + os.write(master_fd, data) + except OSError: + break + finally: + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_attrs) + try: + os.kill(pid, signal.SIGTERM) + os.waitpid(pid, 0) + except: + pass + +if __name__ == "__main__": + main() diff --git a/packages/opencode/test/cli/tui/test-stdin-parser.mjs b/packages/opencode/test/cli/tui/test-stdin-parser.mjs new file mode 100644 index 000000000000..53041abcd807 --- /dev/null +++ b/packages/opencode/test/cli/tui/test-stdin-parser.mjs @@ -0,0 +1,69 @@ +// Direct import from bun's cache — avoids loading native bindings, only the JS parser is needed. +// This path is populated by `bun install` with the patch applied. +import { StdinParser } from "../../../../../node_modules/.bun/@opentui+core@0.1.90+8e67d58793ed4a15/node_modules/@opentui/core/index-e89anq5x.js"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function test(name, chunks, delayMs) { + const events = []; + const parser = new StdinParser({ + timeoutMs: 10, + armTimeouts: true, + onTimeoutFlush: () => { + parser.drain((e) => events.push(e)); + }, + }); + + for (let i = 0; i < chunks.length; i++) { + parser.push(Buffer.from(chunks[i])); + parser.drain((e) => events.push(e)); + if (i < chunks.length - 1) await sleep(delayMs); + } + // Final drain after all timeouts settle + await sleep(delayMs + 5); + parser.drain((e) => events.push(e)); + + const summary = events.map((e) => { + if (e.type === "key") return `KEY:"${e.key?.name || e.raw}"`; + if (e.type === "mouse") return `MOUSE:${e.event?.type}`; + if (e.type === "response") return `RESP:${e.protocol}`; + if (e.type === "paste") return `PASTE`; + return `${e.type}`; + }); + + const hasKeyLeak = events.some((e) => e.type === "key" && !["escape"].includes(e.key?.name)); + console.log(`${hasKeyLeak ? "BUG" : " OK"} | ${name}`); + console.log(` events: [${summary.join(", ")}]`); + console.log(); + parser.destroy(); +} + +console.log("Testing opentui StdinParser v0.1.90 SGR mouse fragmentation\n"); +console.log("If any line shows BUG — mouse bytes leaked as key events.\n"); + +// Complete sequence — should always work +await test("Complete sequence in one push", ["\x1b[<0;50;15M"], 0); + +// Split mid-coordinates — deferred state should handle +await test("Split mid-coords, 15ms gap", ["\x1b[<0;50;1", "5M"], 15); + +// ESC alone then rest — timeout flushes ESC +await test("ESC alone, rest after 15ms", ["\x1b", "[<0;50;15M"], 15); + +// Triple split — ESC, [, rest +await test("ESC / [ / rest, 15ms gaps", ["\x1b", "[", "<0;50;15M"], 15); + +// Quadruple split — ESC, [, <, rest +await test("ESC / [ / < / rest, 15ms gaps", ["\x1b", "[", "<", "0;50;15M"], 15); + +// Every byte separate with 15ms gaps +const fullSeq = "\x1b[<0;50;15M"; +const byteByByte = [...fullSeq].map((c) => c); +await test("Every byte separate, 15ms gaps", byteByByte, 15); + +// Scroll event +await test("Scroll event complete", ["\x1b[<65;50;15M"], 0); +await test("Scroll event split", ["\x1b[<65;5", "0;15M"], 15); +await test("Scroll ESC alone", ["\x1b", "[<65;50;15M"], 15); + +console.log("Done."); diff --git a/patches/@opentui%2Fcore@0.1.90.patch b/patches/@opentui%2Fcore@0.1.90.patch new file mode 100644 index 000000000000..2030180dec5e --- /dev/null +++ b/patches/@opentui%2Fcore@0.1.90.patch @@ -0,0 +1,77 @@ +diff --git a/index-e89anq5x.js b/index-e89anq5x.js +index e3b6cf9f90b13698966eec991fa7a1b2e1e22d96..b57056beaa8659c0a294bf2778c03d24a422e9ac 100644 +--- a/index-e89anq5x.js ++++ b/index-e89anq5x.js +@@ -6843,7 +6843,7 @@ var env = new Proxy({}, { + + // src/lib/stdin-parser.ts + import { Buffer as Buffer3 } from "buffer"; +-var DEFAULT_TIMEOUT_MS = 10; ++var DEFAULT_TIMEOUT_MS = 25; + var DEFAULT_MAX_PENDING_BYTES = 64 * 1024; + var INITIAL_PENDING_CAPACITY = 256; + var ESC = 27; +@@ -7133,6 +7133,7 @@ class StdinParser { + pendingSinceMs = null; + forceFlush = false; + justFlushedEsc = false; ++ justFlushedEscBracket = false; + state = { tag: "ground" }; + cursor = 0; + unitStart = 0; +@@ -7271,6 +7272,15 @@ class StdinParser { + } + this.justFlushedEsc = false; + } ++ if (this.justFlushedEscBracket) { ++ if (byte === 60) { ++ this.justFlushedEscBracket = false; ++ this.cursor += 1; ++ this.state = { tag: "esc_less_mouse" }; ++ continue; ++ } ++ this.justFlushedEscBracket = false; ++ } + if (byte === ESC) { + this.cursor += 1; + this.state = { tag: "esc" }; +@@ -7397,7 +7407,8 @@ class StdinParser { + this.markPending(); + return; + } +- this.emitKeyOrResponse("unknown", decodeUtf8(bytes.subarray(this.unitStart, this.cursor))); ++ this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)); ++ this.justFlushedEscBracket = true; + this.state = { tag: "ground" }; + this.consumePrefix(this.cursor); + continue; +@@ -7424,6 +7435,7 @@ class StdinParser { + return; + } + this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)); ++ this.justFlushedEscBracket = true; + this.state = { tag: "ground" }; + this.consumePrefix(this.cursor); + continue; +@@ -7894,10 +7906,9 @@ class StdinParser { + this.markPending(); + return; + } +- this.emitOpaqueResponse("unknown", bytes.subarray(this.unitStart, this.cursor)); +- this.state = { tag: "ground" }; +- this.consumePrefix(this.cursor); +- continue; ++ this.pendingSinceMs = null; ++ this.forceFlush = false; ++ return; + } + if (byte >= 48 && byte <= 57 || byte === 59) { + this.cursor += 1; +@@ -8098,6 +8109,7 @@ class StdinParser { + this.pendingSinceMs = null; + this.forceFlush = false; + this.justFlushedEsc = false; ++ this.justFlushedEscBracket = false; + this.state = { tag: "ground" }; + this.cursor = 0; + this.unitStart = 0;