Skip to content

fix: drain escape sequences in cbreak listeners & disable mouse tracking leak#245

Open
lijunzh wants to merge 4 commits intompfaffenberger:mainfrom
lijunzh:fix/mouse-escape-sequence-leak
Open

fix: drain escape sequences in cbreak listeners & disable mouse tracking leak#245
lijunzh wants to merge 4 commits intompfaffenberger:mainfrom
lijunzh:fix/mouse-escape-sequence-leak

Conversation

@lijunzh
Copy link
Copy Markdown
Contributor

@lijunzh lijunzh commented Mar 17, 2026

Summary

Fixes #244 — After prolonged use of Code Puppy, mouse actions in the terminal send garbled escape sequence characters into the prompt, rendering them meaningless.

Root Cause

Three interacting bugs compound over long sessions:

  1. _listen_for_ctrl_x_posix (in base_agent.py and 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, the remaining bytes leak into the stdin buffer and appear as garbled characters when prompt_toolkit takes over.

  2. custom_server_form.py is the only TUI component with mouse_support=True. If cleanup fails (threading race, exception during run(in_thread=True)), mouse tracking stays permanently enabled, flooding stdin with escape sequences on every mouse action.

  3. Concurrent stdin access between the cbreak listener thread and prompt_toolkit Applications fragments escape sequences across readers.

Changes

code_puppy/agents/base_agent.py

  • When ESC (\x1b) is read, drain all subsequent bytes within 10ms timeout instead of discarding just one byte (matching the pattern already used in ask_user_question/tui_loop.py)
  • Added a final drain in the finally block before restoring terminal attrs

code_puppy/tools/command_runner.py

  • Same fix as base_agent.py — drain escape sequences on ESC byte and in finally block

code_puppy/command_line/mcp/custom_server_form.py

  • Changed mouse_support=Truemouse_support=False (consistent with all 17 other TUI components)
  • Added explicit disable_mouse_tracking() call in the finally block as a safety net

code_puppy/terminal_utils.py

  • Added disable_mouse_tracking(): sends VT100 escape sequences to disable all mouse tracking modes
  • Added drain_stdin_escape_sequence(): drains pending bytes from stdin to prevent escape sequence fragment leaks

Testing

  • All existing command runner tests pass (82 passed, 3 skipped)
  • All ask_user_question tests pass (163 passed)
  • New helper functions importable and callable

Summary by CodeRabbit

  • Bug Fixes

    • Prevented incomplete escape sequences from leaking into subsequent input, avoiding spurious key/mouse events.
    • Disabled mouse support in an interactive form and ensured mouse tracking is reliably turned off on exit.
    • Added cleanup to drain remaining escape bytes on exit so leftover sequences no longer affect future prompts.
  • Tests

    • Minor test formatting tweaks (no behavioral changes).

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 17, 2026

Warning

Rate limit exceeded

@lijunzh has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 20 minutes and 11 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 083b41b1-ac6f-45ce-93c5-a142b59adf9d

📥 Commits

Reviewing files that changed from the base of the PR and between 9cfa468 and 70613f5.

📒 Files selected for processing (4)
  • code_puppy/agents/base_agent.py
  • code_puppy/command_line/mcp/custom_server_form.py
  • code_puppy/terminal_utils.py
  • code_puppy/tools/command_runner.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lijunzh lijunzh force-pushed the fix/mouse-escape-sequence-leak branch from d10c7fb to b5b0726 Compare March 17, 2026 02:35
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
code_puppy/command_line/mcp/custom_server_form.py (1)

637-647: ⚠️ Potential issue | 🟠 Major

Guard mouse-tracking cleanup so terminal restoration always runs.

If the import/call at Line [641]-Line [643] throws, Line [645]-Line [647] won’t run, which can leave the terminal state broken (alt screen/input flag not restored).

🔧 Proposed fix
         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()
-            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
+                sys.stdout.write("\033[?1049l")
+                sys.stdout.flush()
+                set_awaiting_user_input(False)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code_puppy/command_line/mcp/custom_server_form.py` around lines 637 - 647,
The cleanup sequence can be interrupted if importing or calling
disable_mouse_tracking raises, so wrap the import and call to
disable_mouse_tracking in a try/except (or try/finally) and ensure the terminal
restoration and flag reset always run in a finally block; specifically, protect
the disable_mouse_tracking import/call and always execute
sys.stdout.write("\033[?1049l"), sys.stdout.flush(), and
set_awaiting_user_input(False) in the finally section so alternate screen buffer
and awaiting-user flag are restored even on errors.
🧹 Nitpick comments (1)
code_puppy/terminal_utils.py (1)

154-179: Use this helper from listener call sites to remove duplicated drain loops.

drain_stdin_escape_sequence() now centralizes exactly the behavior duplicated in code_puppy/tools/command_runner.py (Line [399]-Line [401], Line [413]-Line [414]) and code_puppy/agents/base_agent.py (Line [1779]-Line [1780], Line [1800]-Line [1801]). Wiring those call sites to this helper will reduce drift risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code_puppy/terminal_utils.py` around lines 154 - 179, Replace the duplicated
stdin-draining loops in code_puppy/tools/command_runner.py and
code_puppy/agents/base_agent.py with a single call to the new helper
drain_stdin_escape_sequence() from code_puppy/terminal_utils; import the
function where needed, remove the local select/read loop at the duplicate sites,
and ensure the call is used in the same locations that previously invoked the
manual draining logic so behavior (including the Windows early-return and
exception-silencing) is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@code_puppy/command_line/mcp/custom_server_form.py`:
- Around line 637-647: The cleanup sequence can be interrupted if importing or
calling disable_mouse_tracking raises, so wrap the import and call to
disable_mouse_tracking in a try/except (or try/finally) and ensure the terminal
restoration and flag reset always run in a finally block; specifically, protect
the disable_mouse_tracking import/call and always execute
sys.stdout.write("\033[?1049l"), sys.stdout.flush(), and
set_awaiting_user_input(False) in the finally section so alternate screen buffer
and awaiting-user flag are restored even on errors.

---

Nitpick comments:
In `@code_puppy/terminal_utils.py`:
- Around line 154-179: Replace the duplicated stdin-draining loops in
code_puppy/tools/command_runner.py and code_puppy/agents/base_agent.py with a
single call to the new helper drain_stdin_escape_sequence() from
code_puppy/terminal_utils; import the function where needed, remove the local
select/read loop at the duplicate sites, and ensure the call is used in the same
locations that previously invoked the manual draining logic so behavior
(including the Windows early-return and exception-silencing) is preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9f3eaaca-5d55-43a5-aca9-d5265a436fbf

📥 Commits

Reviewing files that changed from the base of the PR and between 4d4936b and d10c7fb.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • code_puppy/agents/base_agent.py
  • code_puppy/command_line/mcp/custom_server_form.py
  • code_puppy/terminal_utils.py
  • code_puppy/tools/command_runner.py

…ing leak

Fixes mpfaffenberger#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.
@lijunzh lijunzh force-pushed the fix/mouse-escape-sequence-leak branch from b5b0726 to 80e6003 Compare March 17, 2026 02:41
… stdin

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 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
code_puppy/terminal_utils.py (1)

139-149: Only emit mouse-disable sequences when stdout is a TTY.

At Line 139, this runs on all Unix stdout targets. If stdout is redirected, the escape bytes can leak into logs/pipes. Add a TTY guard before writing sequences.

Proposed patch
 def disable_mouse_tracking() -> None:
@@
-    if platform.system() == "Windows":
+    if platform.system() == "Windows":
+        return
+    if not sys.stdout.isatty():
         return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code_puppy/terminal_utils.py` around lines 139 - 149, The code
unconditionally writes terminal mouse-disable escape sequences to stdout on
non-Windows systems; add a TTY guard so these bytes are only emitted when stdout
is a terminal. Update the block that checks platform.system() and the
sys.stdout.write call to first check sys.stdout.isatty() (or
os.isatty(sys.stdout.fileno())) and skip writing the escape sequences when
stdout is not a TTY; preserve the existing Windows early return and keep
flushing behavior when the TTY guard passes (referencing the
sys.stdout.write/sys.stdout.flush usage in terminal_utils.py).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@code_puppy/terminal_utils.py`:
- Around line 171-183: The draining loop is running on non-Windows even when
stdin is a redirected pipe; guard it with a TTY check so you only drain terminal
input. Update the early conditional in the function containing
platform.system(), select.select, sys.stdin, max_bytes and drained so it returns
unless sys.stdin.isatty() is true (e.g., if platform.system() == "Windows" or
not sys.stdin.isatty(): return), then proceed with the existing select/read
loop; this prevents consuming real piped data.

---

Nitpick comments:
In `@code_puppy/terminal_utils.py`:
- Around line 139-149: The code unconditionally writes terminal mouse-disable
escape sequences to stdout on non-Windows systems; add a TTY guard so these
bytes are only emitted when stdout is a terminal. Update the block that checks
platform.system() and the sys.stdout.write call to first check
sys.stdout.isatty() (or os.isatty(sys.stdout.fileno())) and skip writing the
escape sequences when stdout is not a TTY; preserve the existing Windows early
return and keep flushing behavior when the TTY guard passes (referencing the
sys.stdout.write/sys.stdout.flush usage in terminal_utils.py).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62e343fd-d722-4e4d-9946-169546d44600

📥 Commits

Reviewing files that changed from the base of the PR and between b5b0726 and 9cfa468.

📒 Files selected for processing (6)
  • code_puppy/agents/base_agent.py
  • code_puppy/command_line/mcp/custom_server_form.py
  • code_puppy/terminal_utils.py
  • code_puppy/tools/command_runner.py
  • tests/agents/test_base_agent_full_coverage.py
  • tests/command_line/test_model_settings_menu_coverage.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • tests/agents/test_base_agent_full_coverage.py
  • code_puppy/agents/base_agent.py
  • tests/command_line/test_model_settings_menu_coverage.py
  • code_puppy/tools/command_runner.py
  • code_puppy/command_line/mcp/custom_server_form.py

Comment thread code_puppy/terminal_utils.py
lijunzh and others added 2 commits March 17, 2026 06:27
…loops

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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Mouse escape sequences leak into stdin after prolonged use, causing garbled input

1 participant