From 80e60037a0773e3005db00e95fc260fcbe48f05b Mon Sep 17 00:00:00 2001 From: Lijun Zhu Date: Mon, 16 Mar 2026 21:30:04 -0500 Subject: [PATCH 1/4] fix: drain escape sequences in cbreak listeners & disable mouse tracking leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #244 — After prolonged use, mouse actions in the terminal send garbled escape sequence characters into the Code Puppy prompt. Root causes fixed: 1. _listen_for_ctrl_x_posix (base_agent.py, command_runner.py): Reads stdin byte-by-byte in cbreak mode but never drains multi-byte escape sequences (mouse events, arrow keys, etc.). When the listener stops mid-sequence, remaining bytes leak into the stdin buffer and appear as garbled input in prompt_toolkit. Fixed by detecting ESC (0x1b) and draining all subsequent bytes within a 10ms timeout, plus a final drain in the finally block before restoring terminal attrs. 2. custom_server_form.py: The only TUI component with mouse_support=True. If cleanup fails (threading race, exception), mouse tracking stays permanently enabled, flooding stdin with escape sequences on every mouse action. Changed to mouse_support=False (consistent with all other TUI components) and added explicit disable_mouse_tracking() call in the finally block as a safety net. 3. terminal_utils.py: Added disable_mouse_tracking() and drain_stdin_escape_sequence() helpers for reuse across cleanup paths. --- code_puppy/agents/base_agent.py | 14 +++++ .../command_line/mcp/custom_server_form.py | 9 +++- code_puppy/terminal_utils.py | 53 +++++++++++++++++++ code_puppy/tools/command_runner.py | 14 +++++ tests/agents/test_base_agent_full_coverage.py | 8 +-- .../test_model_settings_menu_coverage.py | 11 ++-- 6 files changed, 101 insertions(+), 8 deletions(-) diff --git a/code_puppy/agents/base_agent.py b/code_puppy/agents/base_agent.py index ac94d6e2e..a53f9d5e5 100644 --- a/code_puppy/agents/base_agent.py +++ b/code_puppy/agents/base_agent.py @@ -1772,6 +1772,13 @@ def _listen_for_ctrl_x_posix( data = stdin.read(1) if not data: break + # Drain multi-byte escape sequences (mouse events, arrow + # keys, etc.) so partial sequences don't leak into the + # stdin buffer for the next reader (e.g., prompt_toolkit). + if data == "\x1b": + while select.select([stdin], [], [], 0.01)[0]: + stdin.read(1) + continue if data == "\x18": # Ctrl+X try: on_escape() @@ -1787,6 +1794,13 @@ def _listen_for_ctrl_x_posix( except Exception: emit_warning("Cancel agent handler raised unexpectedly.") finally: + # Drain any remaining escape sequence bytes before restoring + # terminal attrs, so fragments don't leak to prompt_toolkit. + try: + while select.select([stdin], [], [], 0.01)[0]: + stdin.read(1) + except Exception: + pass termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs) async def run_with_mcp( diff --git a/code_puppy/command_line/mcp/custom_server_form.py b/code_puppy/command_line/mcp/custom_server_form.py index 10e949aae..0c9a6c0c0 100644 --- a/code_puppy/command_line/mcp/custom_server_form.py +++ b/code_puppy/command_line/mcp/custom_server_form.py @@ -611,7 +611,7 @@ def _(event): layout=layout, key_bindings=kb, full_screen=False, - mouse_support=True, + mouse_support=False, ) set_awaiting_user_input(True) @@ -634,6 +634,13 @@ def _(event): app.run(in_thread=True) finally: + # Explicitly disable mouse tracking as a safety net — if any + # previous prompt_toolkit Application left tracking enabled + # (e.g., due to an exception or threading race), this ensures + # the terminal stops sending mouse escape sequences. + from code_puppy.terminal_utils import disable_mouse_tracking + + disable_mouse_tracking() # Exit alternate screen buffer sys.stdout.write("\033[?1049l") sys.stdout.flush() diff --git a/code_puppy/terminal_utils.py b/code_puppy/terminal_utils.py index 6efb37e02..f01aedb71 100644 --- a/code_puppy/terminal_utils.py +++ b/code_puppy/terminal_utils.py @@ -126,6 +126,59 @@ def reset_windows_terminal_full() -> None: flush_windows_keyboard_buffer() +def disable_mouse_tracking() -> None: + """Explicitly disable all mouse tracking escape sequences. + + Sends the standard VT100 escape sequences to disable mouse click, + any-event, urxvt, and SGR mouse tracking modes. This is a safety net + for when prompt_toolkit or other TUI components fail to clean up + mouse tracking on exit (e.g., due to exceptions or threading issues). + + Safe to call even if mouse tracking was never enabled. + """ + if platform.system() == "Windows": + return + + try: + sys.stdout.write( + "\x1b[?1000l" # Disable mouse click tracking + "\x1b[?1003l" # Disable any-event mouse tracking + "\x1b[?1015l" # Disable urxvt mouse mode + "\x1b[?1006l" # Disable SGR mouse mode + ) + sys.stdout.flush() + except Exception: + pass # Best effort — silently ignore errors + + +def drain_stdin_escape_sequence() -> None: + """Drain any pending multi-byte escape sequence bytes from stdin. + + When reading stdin byte-by-byte in cbreak/raw mode, mouse events and + other terminal escape sequences produce multi-byte sequences (e.g., + ``\\x1b[<0;15;20M`` for a mouse click). If a reader stops mid-sequence, + the remaining bytes stay in the stdin buffer and appear as garbled + characters when the next reader (e.g., prompt_toolkit) takes over. + + This function drains all currently pending bytes from stdin within a + short timeout window, preventing escape sequence fragments from leaking. + """ + if platform.system() == "Windows": + return + + try: + import select + + fd = sys.stdin + # Drain all pending bytes (10 ms timeout per byte — enough for + # escape sequences which arrive in a burst, but won't block on + # genuinely empty stdin). + while select.select([fd], [], [], 0.01)[0]: + fd.read(1) + except Exception: + pass # Best effort — silently ignore errors + + def reset_unix_terminal() -> None: """Reset Unix/Linux/macOS terminal to sane state. diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py index aa773acea..4755c3ec6 100644 --- a/code_puppy/tools/command_runner.py +++ b/code_puppy/tools/command_runner.py @@ -392,6 +392,13 @@ def _listen_for_ctrl_x_posix( data = stdin.read(1) if not data: break + # Drain multi-byte escape sequences (mouse events, arrow + # keys, etc.) so partial sequences don't leak into the + # stdin buffer for the next reader (e.g., prompt_toolkit). + if data == "\x1b": + while select.select([stdin], [], [], 0.01)[0]: + stdin.read(1) + continue if data == "\x18": # Ctrl+X try: on_escape() @@ -400,6 +407,13 @@ def _listen_for_ctrl_x_posix( "Ctrl+X handler raised unexpectedly; Ctrl+C still works." ) finally: + # Drain any remaining escape sequence bytes before restoring + # terminal attrs, so fragments don't leak to prompt_toolkit. + try: + while select.select([stdin], [], [], 0.01)[0]: + stdin.read(1) + except Exception: + pass termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs) diff --git a/tests/agents/test_base_agent_full_coverage.py b/tests/agents/test_base_agent_full_coverage.py index 4165df12e..e33cfa07e 100644 --- a/tests/agents/test_base_agent_full_coverage.py +++ b/tests/agents/test_base_agent_full_coverage.py @@ -2129,9 +2129,11 @@ def test_loads_from_project_dir(self, agent, tmp_path): patch("code_puppy.config.CONFIG_DIR", str(tmp_path / "nonexistent")), patch( "pathlib.Path.exists", - side_effect=lambda self: str(self) == str(rules_file) - or str(self).endswith("AGENTS.md") - and "nonexistent" not in str(self), + side_effect=lambda self: ( + str(self) == str(rules_file) + or str(self).endswith("AGENTS.md") + and "nonexistent" not in str(self) + ), ), ): # Complex to test due to pathlib patching, just test cached path diff --git a/tests/command_line/test_model_settings_menu_coverage.py b/tests/command_line/test_model_settings_menu_coverage.py index 97e257f24..c96a224c8 100644 --- a/tests/command_line/test_model_settings_menu_coverage.py +++ b/tests/command_line/test_model_settings_menu_coverage.py @@ -151,10 +151,13 @@ def test_get_supported_settings(self, mock_supports): def test_load_model_settings_with_openai( self, mock_supports, mock_get_all, mock_effort, mock_verb ): - mock_supports.side_effect = lambda m, s: s in ( - "temperature", - "reasoning_effort", - "verbosity", + mock_supports.side_effect = lambda m, s: ( + s + in ( + "temperature", + "reasoning_effort", + "verbosity", + ) ) menu = _make_menu() menu._load_model_settings("gpt-5") From 9cfa46818562be60fa908838567eda8290e1417c Mon Sep 17 00:00:00 2001 From: Lijun Zhu Date: Tue, 17 Mar 2026 06:21:39 -0500 Subject: [PATCH 2/4] fix: bound all drain loops to prevent infinite spin on mocked/flooded stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 5 escape-sequence drain loops (2 in base_agent.py, 2 in command_runner.py, 1 in terminal_utils.py) used unbounded `while select.select(...)` which hangs when stdin is mocked to always return data (CI test) or when a terminal floods mouse events (production). Cap at 256 iterations — more than enough for any real escape sequence burst. Fixes CI hang on test_listen_for_ctrl_x_posix_ctrl_x_detection (31-minute timeout → exit code 137 on macos-latest). Co-Authored-By: Claude Opus 4.6 --- code_puppy/agents/base_agent.py | 8 ++++++-- code_puppy/terminal_utils.py | 11 +++++++++-- code_puppy/tools/command_runner.py | 8 ++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/code_puppy/agents/base_agent.py b/code_puppy/agents/base_agent.py index a53f9d5e5..ccb9e270a 100644 --- a/code_puppy/agents/base_agent.py +++ b/code_puppy/agents/base_agent.py @@ -1776,7 +1776,9 @@ def _listen_for_ctrl_x_posix( # keys, etc.) so partial sequences don't leak into the # stdin buffer for the next reader (e.g., prompt_toolkit). if data == "\x1b": - while select.select([stdin], [], [], 0.01)[0]: + for _ in range(256): + if not select.select([stdin], [], [], 0.01)[0]: + break stdin.read(1) continue if data == "\x18": # Ctrl+X @@ -1797,7 +1799,9 @@ def _listen_for_ctrl_x_posix( # Drain any remaining escape sequence bytes before restoring # terminal attrs, so fragments don't leak to prompt_toolkit. try: - while select.select([stdin], [], [], 0.01)[0]: + for _ in range(256): + if not select.select([stdin], [], [], 0.01)[0]: + break stdin.read(1) except Exception: pass diff --git a/code_puppy/terminal_utils.py b/code_puppy/terminal_utils.py index f01aedb71..4a14e9970 100644 --- a/code_puppy/terminal_utils.py +++ b/code_puppy/terminal_utils.py @@ -151,7 +151,7 @@ def disable_mouse_tracking() -> None: pass # Best effort — silently ignore errors -def drain_stdin_escape_sequence() -> None: +def drain_stdin_escape_sequence(max_bytes: int = 256) -> None: """Drain any pending multi-byte escape sequence bytes from stdin. When reading stdin byte-by-byte in cbreak/raw mode, mouse events and @@ -162,6 +162,11 @@ def drain_stdin_escape_sequence() -> None: This function drains all currently pending bytes from stdin within a short timeout window, preventing escape sequence fragments from leaking. + + Args: + max_bytes: Safety cap to prevent infinite loops if stdin is flooded + (e.g., continuous mouse events). 256 bytes covers any realistic + burst of pending escape sequences. """ if platform.system() == "Windows": return @@ -173,8 +178,10 @@ def drain_stdin_escape_sequence() -> None: # Drain all pending bytes (10 ms timeout per byte — enough for # escape sequences which arrive in a burst, but won't block on # genuinely empty stdin). - while select.select([fd], [], [], 0.01)[0]: + drained = 0 + while drained < max_bytes and select.select([fd], [], [], 0.01)[0]: fd.read(1) + drained += 1 except Exception: pass # Best effort — silently ignore errors diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py index 4755c3ec6..89b2da717 100644 --- a/code_puppy/tools/command_runner.py +++ b/code_puppy/tools/command_runner.py @@ -396,7 +396,9 @@ def _listen_for_ctrl_x_posix( # keys, etc.) so partial sequences don't leak into the # stdin buffer for the next reader (e.g., prompt_toolkit). if data == "\x1b": - while select.select([stdin], [], [], 0.01)[0]: + for _ in range(256): + if not select.select([stdin], [], [], 0.01)[0]: + break stdin.read(1) continue if data == "\x18": # Ctrl+X @@ -410,7 +412,9 @@ def _listen_for_ctrl_x_posix( # Drain any remaining escape sequence bytes before restoring # terminal attrs, so fragments don't leak to prompt_toolkit. try: - while select.select([stdin], [], [], 0.01)[0]: + for _ in range(256): + if not select.select([stdin], [], [], 0.01)[0]: + break stdin.read(1) except Exception: pass From ec308de881d98d54a4ff8ed1bf783b0753f4ab50 Mon Sep 17 00:00:00 2001 From: Lijun Zhu Date: Tue, 17 Mar 2026 06:27:18 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20guard=20cleanup=20and=20deduplicate=20drain=20loops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. custom_server_form.py: wrap disable_mouse_tracking() in try/except with a finally block so alt screen exit + set_awaiting_user_input(False) always runs even if the import/call throws. 2. Deduplicate 4 inline drain loops in base_agent.py and command_runner.py by calling the centralized drain_stdin_escape_sequence() helper from terminal_utils.py. Add optional `stream` parameter to the helper so callers can pass a specific stdin reference instead of sys.stdin. Co-Authored-By: Claude Opus 4.6 --- code_puppy/agents/base_agent.py | 18 +++++++++-------- .../command_line/mcp/custom_server_form.py | 20 ++++++++++++------- code_puppy/terminal_utils.py | 7 +++++-- code_puppy/tools/command_runner.py | 16 +++++++-------- 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/code_puppy/agents/base_agent.py b/code_puppy/agents/base_agent.py index ccb9e270a..d6e167a44 100644 --- a/code_puppy/agents/base_agent.py +++ b/code_puppy/agents/base_agent.py @@ -1776,10 +1776,11 @@ def _listen_for_ctrl_x_posix( # keys, etc.) so partial sequences don't leak into the # stdin buffer for the next reader (e.g., prompt_toolkit). if data == "\x1b": - for _ in range(256): - if not select.select([stdin], [], [], 0.01)[0]: - break - stdin.read(1) + from code_puppy.terminal_utils import ( + drain_stdin_escape_sequence, + ) + + drain_stdin_escape_sequence(stream=stdin) continue if data == "\x18": # Ctrl+X try: @@ -1799,10 +1800,11 @@ def _listen_for_ctrl_x_posix( # Drain any remaining escape sequence bytes before restoring # terminal attrs, so fragments don't leak to prompt_toolkit. try: - for _ in range(256): - if not select.select([stdin], [], [], 0.01)[0]: - break - stdin.read(1) + from code_puppy.terminal_utils import ( + drain_stdin_escape_sequence, + ) + + drain_stdin_escape_sequence(stream=stdin) except Exception: pass termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs) diff --git a/code_puppy/command_line/mcp/custom_server_form.py b/code_puppy/command_line/mcp/custom_server_form.py index 0c9a6c0c0..4a3f1fae2 100644 --- a/code_puppy/command_line/mcp/custom_server_form.py +++ b/code_puppy/command_line/mcp/custom_server_form.py @@ -638,13 +638,19 @@ def _(event): # previous prompt_toolkit Application left tracking enabled # (e.g., due to an exception or threading race), this ensures # the terminal stops sending mouse escape sequences. - from code_puppy.terminal_utils import disable_mouse_tracking - - disable_mouse_tracking() - # Exit alternate screen buffer - sys.stdout.write("\033[?1049l") - sys.stdout.flush() - set_awaiting_user_input(False) + try: + from code_puppy.terminal_utils import disable_mouse_tracking + + disable_mouse_tracking() + except Exception: + pass + finally: + # Exit alternate screen buffer — must always run even if + # disable_mouse_tracking fails, or the terminal stays in + # alt screen with input flags stuck. + sys.stdout.write("\033[?1049l") + sys.stdout.flush() + set_awaiting_user_input(False) # Clear exit message if not installing if self.result != "installed": diff --git a/code_puppy/terminal_utils.py b/code_puppy/terminal_utils.py index 4a14e9970..b3d122335 100644 --- a/code_puppy/terminal_utils.py +++ b/code_puppy/terminal_utils.py @@ -151,7 +151,9 @@ def disable_mouse_tracking() -> None: pass # Best effort — silently ignore errors -def drain_stdin_escape_sequence(max_bytes: int = 256) -> None: +def drain_stdin_escape_sequence( + stream=None, max_bytes: int = 256 +) -> None: """Drain any pending multi-byte escape sequence bytes from stdin. When reading stdin byte-by-byte in cbreak/raw mode, mouse events and @@ -164,6 +166,7 @@ def drain_stdin_escape_sequence(max_bytes: int = 256) -> None: short timeout window, preventing escape sequence fragments from leaking. Args: + stream: File-like object to drain. Defaults to ``sys.stdin``. max_bytes: Safety cap to prevent infinite loops if stdin is flooded (e.g., continuous mouse events). 256 bytes covers any realistic burst of pending escape sequences. @@ -174,7 +177,7 @@ def drain_stdin_escape_sequence(max_bytes: int = 256) -> None: try: import select - fd = sys.stdin + fd = stream if stream is not None else sys.stdin # Drain all pending bytes (10 ms timeout per byte — enough for # escape sequences which arrive in a burst, but won't block on # genuinely empty stdin). diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py index 89b2da717..aec4fe5e6 100644 --- a/code_puppy/tools/command_runner.py +++ b/code_puppy/tools/command_runner.py @@ -396,10 +396,11 @@ def _listen_for_ctrl_x_posix( # keys, etc.) so partial sequences don't leak into the # stdin buffer for the next reader (e.g., prompt_toolkit). if data == "\x1b": - for _ in range(256): - if not select.select([stdin], [], [], 0.01)[0]: - break - stdin.read(1) + from code_puppy.terminal_utils import ( + drain_stdin_escape_sequence, + ) + + drain_stdin_escape_sequence(stream=stdin) continue if data == "\x18": # Ctrl+X try: @@ -412,10 +413,9 @@ def _listen_for_ctrl_x_posix( # Drain any remaining escape sequence bytes before restoring # terminal attrs, so fragments don't leak to prompt_toolkit. try: - for _ in range(256): - if not select.select([stdin], [], [], 0.01)[0]: - break - stdin.read(1) + from code_puppy.terminal_utils import drain_stdin_escape_sequence + + drain_stdin_escape_sequence(stream=stdin) except Exception: pass termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs) From 70613f51d17fa2ffb00654e878d6e2a17d9f2fa4 Mon Sep 17 00:00:00 2001 From: Lijun Zhu Date: Tue, 17 Mar 2026 06:28:41 -0500 Subject: [PATCH 4/4] style: ruff format terminal_utils.py Co-Authored-By: Claude Opus 4.6 --- code_puppy/terminal_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/code_puppy/terminal_utils.py b/code_puppy/terminal_utils.py index b3d122335..574a14b70 100644 --- a/code_puppy/terminal_utils.py +++ b/code_puppy/terminal_utils.py @@ -151,9 +151,7 @@ def disable_mouse_tracking() -> None: pass # Best effort — silently ignore errors -def drain_stdin_escape_sequence( - stream=None, max_bytes: int = 256 -) -> None: +def drain_stdin_escape_sequence(stream=None, max_bytes: int = 256) -> None: """Drain any pending multi-byte escape sequence bytes from stdin. When reading stdin byte-by-byte in cbreak/raw mode, mouse events and