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
1 change: 1 addition & 0 deletions 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 @@ -116,6 +116,7 @@
"@standard-community/[email protected]": "patches/@standard-community%[email protected]",
"[email protected]": "patches/[email protected]",
"@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
"@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]"
"@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
"@opentui/[email protected]": "patches/@opentui%[email protected]"
}
}
22 changes: 22 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ export function tui(input: {

const renderer = await createCliRenderer(rendererConfig(input.config))

// Handle external SIGTSTP (e.g., code-server terminal management, job control)
// to properly disable mouse tracking before suspension. Without this, the shell
// receives mouse events as garbled escape sequences.
// Guard resume() with a flag to prevent duplicate stdin listeners when SIGCONT
// arrives without a prior SIGTSTP through our handler (e.g., terminal refocus).
let suspendedBySigtstp = false
const sigtstpHandler = () => {
suspendedBySigtstp = true
renderer.suspend()
process.removeListener("SIGTSTP", sigtstpHandler)
process.kill(process.pid, "SIGTSTP")
}
const sigcontHandler = () => {
process.on("SIGTSTP", sigtstpHandler)
if (suspendedBySigtstp) {
suspendedBySigtstp = false
renderer.resume()
}
}
process.on("SIGTSTP", sigtstpHandler)
process.on("SIGCONT", sigcontHandler)

await render(() => {
return (
<ErrorBoundary
Expand Down
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()
116 changes: 116 additions & 0 deletions packages/opencode/test/cli/tui/test-destroy-mouse-cleanup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Test: Verify that cleanupBeforeDestroy() disables mouse tracking before
* disabling raw mode. Without the fix, setRawMode(false) re-enables terminal
* ECHO while mouse tracking is still active, causing mouse events to appear
* as garbled text.
*
* This test verifies the fix by reading the patched source and checking that
* disableMouse() is called before setRawMode(false) in cleanupBeforeDestroy().
* A full integration test would require a PTY, but this structural test catches
* regressions in the patch ordering.
*/

import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));

// Read the patched @opentui/core source
const corePath = resolve(
__dirname,
"../../../../../node_modules/.bun/@[email protected]+8e67d58793ed4a15/node_modules/@opentui/core/index-wv534m5j.js",
);
const source = readFileSync(corePath, "utf-8");

let passed = 0;
let failed = 0;

function check(name, condition) {
if (condition) {
console.log(` OK | ${name}`);
passed++;
} else {
console.log(`BUG | ${name}`);
failed++;
}
}

console.log("Testing @opentui/core destroy-path mouse cleanup ordering\n");

// Test 1: cleanupBeforeDestroy calls disableMouse
check(
"cleanupBeforeDestroy() contains disableMouse() call",
/cleanupBeforeDestroy\(\)\s*\{[\s\S]*?this\.disableMouse\(\)[\s\S]*?this\.stdin\.setRawMode\(false\)[\s\S]*?\n\s*\}/.test(
source,
),
);

// Test 2: disableMouse comes BEFORE setRawMode(false) in cleanupBeforeDestroy
{
// Extract cleanupBeforeDestroy method body
const methodMatch = source.match(
/cleanupBeforeDestroy\(\)\s*\{([\s\S]*?)(?=\n\s{2}\w|\n\s{2}(?:get |set |async ))/,
);
if (methodMatch) {
const body = methodMatch[1];
const disableMouseIdx = body.indexOf("this.disableMouse()");
const setRawModeIdx = body.indexOf("this.stdin.setRawMode(false)");
check(
"disableMouse() is called BEFORE setRawMode(false)",
disableMouseIdx !== -1 &&
setRawModeIdx !== -1 &&
disableMouseIdx < setRawModeIdx,
);
} else {
check("Found cleanupBeforeDestroy method body", false);
}
}

// Test 3: stdin drain exists between disableMouse and setRawMode
{
const methodMatch = source.match(
/cleanupBeforeDestroy\(\)\s*\{([\s\S]*?)(?=\n\s{2}\w|\n\s{2}(?:get |set |async ))/,
);
if (methodMatch) {
const body = methodMatch[1];
const drainIdx = body.indexOf("while (this.stdin.read() !== null)");
const setRawModeIdx = body.indexOf("this.stdin.setRawMode(false)");
check(
"stdin drain exists before setRawMode(false)",
drainIdx !== -1 && setRawModeIdx !== -1 && drainIdx < setRawModeIdx,
);
} else {
check("Found cleanupBeforeDestroy method body for drain check", false);
}
}

// Test 4: suspend() also has correct ordering (regression guard)
{
const suspendMatch = source.match(
/suspend\(\)\s*\{([\s\S]*?)(?=\n\s{2}resume\(\)|\n\s{2}\w)/,
);
if (suspendMatch) {
const body = suspendMatch[1];
const disableMouseIdx = body.indexOf("this.disableMouse()");
const setRawModeIdx = body.indexOf("this.stdin.setRawMode(false)");
check(
"suspend() still has correct ordering (disableMouse before setRawMode)",
disableMouseIdx !== -1 &&
setRawModeIdx !== -1 &&
disableMouseIdx < setRawModeIdx,
);
} else {
check("Found suspend method body", false);
}
}

// Test 5: Verify DEFAULT_TIMEOUT_MS is bumped
check(
"DEFAULT_TIMEOUT_MS is >= 25",
/var DEFAULT_TIMEOUT_MS = (\d+)/.test(source) &&
parseInt(source.match(/var DEFAULT_TIMEOUT_MS = (\d+)/)[1]) >= 25,
);

console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
39 changes: 39 additions & 0 deletions packages/opencode/test/cli/tui/test-sigtstp-mouse.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# Reproduction script for SIGTSTP mouse garbling.
#
# Usage:
# 1. In Terminal 1: Run opencode normally
# 2. In Terminal 2: Run this script with the opencode PID:
# ./test-sigtstp-mouse.sh <PID>
# 3. In Terminal 1: Move the mouse after opencode is suspended
#
# Without the fix: garbled escape sequences appear (35;89;19M35;84;20M...)
# With the fix: clean shell prompt, no garbled text
#
# You can also test without opencode by using any TUI that enables mouse:
# python3 -c "import sys; sys.stdout.write('\x1b[?1003h\x1b[?1006h'); input()"
# Then send SIGTSTP from another terminal.

set -e

if [ -z "$1" ]; then
echo "Usage: $0 <PID>"
echo ""
echo "Sends SIGTSTP to the given PID to test mouse cleanup."
echo "Run opencode in another terminal first, then pass its PID."
echo ""
echo "Find the PID with: pgrep -f opencode"
exit 1
fi

PID="$1"

echo "Sending SIGTSTP to PID $PID..."
echo "Switch to the opencode terminal and move the mouse."
echo "If you see garbled text like '35;89;19M35;84;20M...' the bug is present."
echo ""

kill -TSTP "$PID"

echo "Done. Resume with: kill -CONT $PID (or 'fg' in the opencode terminal)"
Loading
Loading