Skip to content
Closed
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
3 changes: 2 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
136 changes: 136 additions & 0 deletions packages/opencode/test/cli/tui/frag-pty.py
Original file line number Diff line number Diff line change
@@ -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()
69 changes: 69 additions & 0 deletions packages/opencode/test/cli/tui/test-stdin-parser.mjs
Original file line number Diff line number Diff line change
@@ -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.");
77 changes: 77 additions & 0 deletions patches/@opentui%2Fcore@0.1.90.patch
Original file line number Diff line number Diff line change
@@ -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;
Loading