Skip to content

Commit dde7edd

Browse files
fix(tui): patch @opentui/core to prevent mouse garbling on exit and fragmented input
Two bugs cause SGR mouse escape sequences to appear as garbled text: 1. **Post-exit garbling** (new fix): cleanupBeforeDestroy() calls setRawMode(false) before mouse tracking is disabled, creating a window where mouse events echo as raw bytes. Fixed by adding disableMouse() + stdin drain before setRawMode(false), matching the correct ordering already used in suspend(). 2. **In-session garbling** (ported from anomalyco#19520): StdinParser timeout fires mid-mouse-sequence during heavy event loop pressure, leaking individual bytes as KEY events. Fixed by patching three timeout paths with recovery flags and deferred processing. Also bumps DEFAULT_TIMEOUT_MS from 20 to 25ms for defense-in-depth. Patch targets @opentui/core@0.1.95 (current dev dependency). Upstream fix: anomalyco/opentui#905 Closes anomalyco#20458 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 159ede2 commit dde7edd

File tree

6 files changed

+413
-1
lines changed

6 files changed

+413
-1
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
117117
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
118118
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
119-
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
119+
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch",
120+
"@opentui/core@0.1.95": "patches/@opentui%2Fcore@0.1.95.patch"
120121
}
121122
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/usr/bin/env python3
2+
"""
3+
PTY wrapper that fragments mouse escape sequences before they reach opencode.
4+
Every mouse event is split byte-by-byte with 12ms delays between bytes.
5+
Keyboard input passes through instantly.
6+
7+
Usage: python3 frag-pty.py opencode [args...]
8+
"""
9+
10+
import os
11+
import pty
12+
import sys
13+
import select
14+
import time
15+
import struct
16+
import fcntl
17+
import termios
18+
import signal
19+
20+
FRAGMENT_DELAY = 0.012 # 12ms — exceeds opentui's 10ms StdinParser timeout
21+
ESC = 0x1b
22+
23+
def set_winsize(fd):
24+
"""Copy terminal size to PTY."""
25+
try:
26+
size = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b'\x00' * 8)
27+
fcntl.ioctl(fd, termios.TIOCSWINSZ, size)
28+
except:
29+
pass
30+
31+
def contains_mouse_seq(data):
32+
"""Check if data contains SGR mouse sequence start: ESC [ <"""
33+
for i in range(len(data) - 2):
34+
if data[i] == ESC and data[i+1] == ord('[') and data[i+2] == ord('<'):
35+
return True
36+
return False
37+
38+
def main():
39+
if len(sys.argv) < 2:
40+
print("Usage: python3 frag-pty.py opencode [args...]")
41+
sys.exit(1)
42+
43+
# Create PTY
44+
master_fd, slave_fd = pty.openpty()
45+
set_winsize(master_fd)
46+
47+
pid = os.fork()
48+
if pid == 0:
49+
# Child: run opencode on the slave PTY
50+
os.close(master_fd)
51+
os.setsid()
52+
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
53+
os.dup2(slave_fd, 0)
54+
os.dup2(slave_fd, 1)
55+
os.dup2(slave_fd, 2)
56+
if slave_fd > 2:
57+
os.close(slave_fd)
58+
os.execvp(sys.argv[1], sys.argv[1:])
59+
60+
# Parent: proxy between terminal and master PTY
61+
os.close(slave_fd)
62+
63+
# Save and set raw mode
64+
old_attrs = termios.tcgetattr(sys.stdin.fileno())
65+
try:
66+
raw = termios.tcgetattr(sys.stdin.fileno())
67+
raw[0] = 0 # iflag
68+
raw[1] = 0 # oflag
69+
raw[2] = raw[2] & ~(termios.CSIZE | termios.PARENB) | termios.CS8 # cflag
70+
raw[3] = 0 # lflag
71+
raw[6][termios.VMIN] = 1
72+
raw[6][termios.VTIME] = 0
73+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, raw)
74+
except:
75+
pass
76+
77+
# Handle SIGWINCH — resize PTY when terminal resizes
78+
def on_resize(signum, frame):
79+
set_winsize(master_fd)
80+
os.kill(pid, signal.SIGWINCH)
81+
signal.signal(signal.SIGWINCH, on_resize)
82+
83+
# Handle child exit
84+
done = False
85+
def on_child(signum, frame):
86+
nonlocal done
87+
done = True
88+
signal.signal(signal.SIGCHLD, on_child)
89+
90+
stdin_fd = sys.stdin.fileno()
91+
stdout_fd = sys.stdout.fileno()
92+
93+
try:
94+
while not done:
95+
try:
96+
rlist, _, _ = select.select([stdin_fd, master_fd], [], [], 0.1)
97+
except (select.error, InterruptedError):
98+
continue
99+
100+
if master_fd in rlist:
101+
# PTY output → terminal (pass through immediately)
102+
try:
103+
data = os.read(master_fd, 65536)
104+
if not data:
105+
break
106+
os.write(stdout_fd, data)
107+
except OSError:
108+
break
109+
110+
if stdin_fd in rlist:
111+
# Terminal input → check for mouse, fragment if needed
112+
try:
113+
data = os.read(stdin_fd, 65536)
114+
if not data:
115+
break
116+
117+
if contains_mouse_seq(data):
118+
# Fragment byte-by-byte with delay
119+
for byte in data:
120+
os.write(master_fd, bytes([byte]))
121+
time.sleep(FRAGMENT_DELAY)
122+
else:
123+
# Pass through immediately
124+
os.write(master_fd, data)
125+
except OSError:
126+
break
127+
finally:
128+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_attrs)
129+
try:
130+
os.kill(pid, signal.SIGTERM)
131+
os.waitpid(pid, 0)
132+
except:
133+
pass
134+
135+
if __name__ == "__main__":
136+
main()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Test: Verify that cleanupBeforeDestroy() disables mouse tracking before
3+
* disabling raw mode. Without the fix, setRawMode(false) re-enables terminal
4+
* ECHO while mouse tracking is still active, causing mouse events to appear
5+
* as garbled text.
6+
*
7+
* This test verifies the fix by reading the patched source and checking that
8+
* disableMouse() is called before setRawMode(false) in cleanupBeforeDestroy().
9+
* A full integration test would require a PTY, but this structural test catches
10+
* regressions in the patch ordering.
11+
*/
12+
13+
import { readFileSync } from "fs";
14+
import { resolve, dirname } from "path";
15+
import { fileURLToPath } from "url";
16+
17+
const __dirname = dirname(fileURLToPath(import.meta.url));
18+
19+
// Read the patched @opentui/core source
20+
const corePath = resolve(
21+
__dirname,
22+
"../../../../../node_modules/.bun/@opentui+core@0.1.95+8e67d58793ed4a15/node_modules/@opentui/core/index-wv534m5j.js",
23+
);
24+
const source = readFileSync(corePath, "utf-8");
25+
26+
let passed = 0;
27+
let failed = 0;
28+
29+
function check(name, condition) {
30+
if (condition) {
31+
console.log(` OK | ${name}`);
32+
passed++;
33+
} else {
34+
console.log(`BUG | ${name}`);
35+
failed++;
36+
}
37+
}
38+
39+
console.log("Testing @opentui/core destroy-path mouse cleanup ordering\n");
40+
41+
// Test 1: cleanupBeforeDestroy calls disableMouse
42+
check(
43+
"cleanupBeforeDestroy() contains disableMouse() call",
44+
/cleanupBeforeDestroy\(\)\s*\{[\s\S]*?this\.disableMouse\(\)[\s\S]*?this\.stdin\.setRawMode\(false\)[\s\S]*?\n\s*\}/.test(
45+
source,
46+
),
47+
);
48+
49+
// Test 2: disableMouse comes BEFORE setRawMode(false) in cleanupBeforeDestroy
50+
{
51+
// Extract cleanupBeforeDestroy method body
52+
const methodMatch = source.match(
53+
/cleanupBeforeDestroy\(\)\s*\{([\s\S]*?)(?=\n\s{2}\w|\n\s{2}(?:get |set |async ))/,
54+
);
55+
if (methodMatch) {
56+
const body = methodMatch[1];
57+
const disableMouseIdx = body.indexOf("this.disableMouse()");
58+
const setRawModeIdx = body.indexOf("this.stdin.setRawMode(false)");
59+
check(
60+
"disableMouse() is called BEFORE setRawMode(false)",
61+
disableMouseIdx !== -1 &&
62+
setRawModeIdx !== -1 &&
63+
disableMouseIdx < setRawModeIdx,
64+
);
65+
} else {
66+
check("Found cleanupBeforeDestroy method body", false);
67+
}
68+
}
69+
70+
// Test 3: stdin drain exists between disableMouse and setRawMode
71+
{
72+
const methodMatch = source.match(
73+
/cleanupBeforeDestroy\(\)\s*\{([\s\S]*?)(?=\n\s{2}\w|\n\s{2}(?:get |set |async ))/,
74+
);
75+
if (methodMatch) {
76+
const body = methodMatch[1];
77+
const drainIdx = body.indexOf("while (this.stdin.read() !== null)");
78+
const setRawModeIdx = body.indexOf("this.stdin.setRawMode(false)");
79+
check(
80+
"stdin drain exists before setRawMode(false)",
81+
drainIdx !== -1 && setRawModeIdx !== -1 && drainIdx < setRawModeIdx,
82+
);
83+
} else {
84+
check("Found cleanupBeforeDestroy method body for drain check", false);
85+
}
86+
}
87+
88+
// Test 4: suspend() also has correct ordering (regression guard)
89+
{
90+
const suspendMatch = source.match(
91+
/suspend\(\)\s*\{([\s\S]*?)(?=\n\s{2}resume\(\)|\n\s{2}\w)/,
92+
);
93+
if (suspendMatch) {
94+
const body = suspendMatch[1];
95+
const disableMouseIdx = body.indexOf("this.disableMouse()");
96+
const setRawModeIdx = body.indexOf("this.stdin.setRawMode(false)");
97+
check(
98+
"suspend() still has correct ordering (disableMouse before setRawMode)",
99+
disableMouseIdx !== -1 &&
100+
setRawModeIdx !== -1 &&
101+
disableMouseIdx < setRawModeIdx,
102+
);
103+
} else {
104+
check("Found suspend method body", false);
105+
}
106+
}
107+
108+
// Test 5: Verify DEFAULT_TIMEOUT_MS is bumped
109+
check(
110+
"DEFAULT_TIMEOUT_MS is >= 25",
111+
/var DEFAULT_TIMEOUT_MS = (\d+)/.test(source) &&
112+
parseInt(source.match(/var DEFAULT_TIMEOUT_MS = (\d+)/)[1]) >= 25,
113+
);
114+
115+
console.log(`\n${passed} passed, ${failed} failed`);
116+
if (failed > 0) process.exit(1);
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Direct import from bun's cache — avoids loading native bindings, only the JS parser is needed.
2+
// This path is populated by `bun install` with the patch applied.
3+
import { StdinParser } from "../../../../../node_modules/.bun/@opentui+core@0.1.95+8e67d58793ed4a15/node_modules/@opentui/core/index-wv534m5j.js";
4+
5+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6+
7+
async function test(name, chunks, delayMs) {
8+
const events = [];
9+
const parser = new StdinParser({
10+
timeoutMs: 10,
11+
armTimeouts: true,
12+
onTimeoutFlush: () => {
13+
parser.drain((e) => events.push(e));
14+
},
15+
});
16+
17+
for (let i = 0; i < chunks.length; i++) {
18+
parser.push(Buffer.from(chunks[i]));
19+
parser.drain((e) => events.push(e));
20+
if (i < chunks.length - 1) await sleep(delayMs);
21+
}
22+
// Final drain after all timeouts settle
23+
await sleep(delayMs + 5);
24+
parser.drain((e) => events.push(e));
25+
26+
const summary = events.map((e) => {
27+
if (e.type === "key") return `KEY:"${e.key?.name || e.raw}"`;
28+
if (e.type === "mouse") return `MOUSE:${e.event?.type}`;
29+
if (e.type === "response") return `RESP:${e.protocol}`;
30+
if (e.type === "paste") return `PASTE`;
31+
return `${e.type}`;
32+
});
33+
34+
const hasKeyLeak = events.some((e) => e.type === "key" && !["escape"].includes(e.key?.name));
35+
console.log(`${hasKeyLeak ? "BUG" : " OK"} | ${name}`);
36+
console.log(` events: [${summary.join(", ")}]`);
37+
console.log();
38+
parser.destroy();
39+
}
40+
41+
console.log("Testing opentui StdinParser v0.1.95 SGR mouse fragmentation\n");
42+
console.log("If any line shows BUG — mouse bytes leaked as key events.\n");
43+
44+
// Complete sequence — should always work
45+
await test("Complete sequence in one push", ["\x1b[<0;50;15M"], 0);
46+
47+
// Split mid-coordinates — deferred state should handle
48+
await test("Split mid-coords, 15ms gap", ["\x1b[<0;50;1", "5M"], 15);
49+
50+
// ESC alone then rest — timeout flushes ESC
51+
await test("ESC alone, rest after 15ms", ["\x1b", "[<0;50;15M"], 15);
52+
53+
// Triple split — ESC, [, rest
54+
await test("ESC / [ / rest, 15ms gaps", ["\x1b", "[", "<0;50;15M"], 15);
55+
56+
// Quadruple split — ESC, [, <, rest
57+
await test("ESC / [ / < / rest, 15ms gaps", ["\x1b", "[", "<", "0;50;15M"], 15);
58+
59+
// Every byte separate with 15ms gaps
60+
const fullSeq = "\x1b[<0;50;15M";
61+
const byteByByte = [...fullSeq].map((c) => c);
62+
await test("Every byte separate, 15ms gaps", byteByByte, 15);
63+
64+
// Scroll event
65+
await test("Scroll event complete", ["\x1b[<65;50;15M"], 0);
66+
await test("Scroll event split", ["\x1b[<65;5", "0;15M"], 15);
67+
await test("Scroll ESC alone", ["\x1b", "[<65;50;15M"], 15);
68+
69+
console.log("Done.");

0 commit comments

Comments
 (0)