Skip to content

Commit db29871

Browse files
feat(windows): fix terminal bricking when running via uvx on Windows 🐶
1 parent 804d1a8 commit db29871

6 files changed

Lines changed: 844 additions & 3 deletions

File tree

code_puppy/agents/base_agent.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1938,7 +1938,12 @@ def keyboard_interrupt_handler(_sig, _frame):
19381938
def graceful_sigint_handler(_sig, _frame):
19391939
# When using keyboard-based cancel, SIGINT should be a no-op
19401940
# (just show a hint to user about the configured cancel key)
1941+
# Also reset terminal to prevent bricking on Windows+uvx
19411942
from code_puppy.keymap import get_cancel_agent_display_name
1943+
from code_puppy.terminal_utils import reset_windows_terminal_full
1944+
1945+
# Reset terminal state first to prevent bricking
1946+
reset_windows_terminal_full()
19421947

19431948
cancel_key = get_cancel_agent_display_name()
19441949
emit_info(f"Use {cancel_key} to cancel the agent task.")

code_puppy/cli_runner.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,45 @@ async def main():
171171
emit_error(str(e))
172172
sys.exit(1)
173173

174+
# Show uvx detection notice if we're on Windows + uvx
175+
# Also disable Ctrl+C at the console level to prevent terminal bricking
176+
try:
177+
from code_puppy.uvx_detection import should_use_alternate_cancel_key
178+
179+
if should_use_alternate_cancel_key():
180+
from code_puppy.terminal_utils import (
181+
disable_windows_ctrl_c,
182+
set_keep_ctrl_c_disabled,
183+
)
184+
185+
# Disable Ctrl+C at the console input level
186+
# This prevents Ctrl+C from being processed as a signal at all
187+
disable_windows_ctrl_c()
188+
189+
# Set flag to keep it disabled (prompt_toolkit may re-enable it)
190+
set_keep_ctrl_c_disabled(True)
191+
192+
# Use print directly - emit_system_message can get cleared by ANSI codes
193+
print(
194+
"🔧 Detected uvx launch on Windows - using Ctrl+K for cancellation "
195+
"(Ctrl+C is disabled to prevent terminal issues)"
196+
)
197+
198+
# Also install a SIGINT handler as backup
199+
import signal
200+
201+
from code_puppy.terminal_utils import reset_windows_terminal_full
202+
203+
def _uvx_protective_sigint_handler(_sig, _frame):
204+
"""Protective SIGINT handler for Windows+uvx."""
205+
reset_windows_terminal_full()
206+
# Re-disable Ctrl+C in case something re-enabled it
207+
disable_windows_ctrl_c()
208+
209+
signal.signal(signal.SIGINT, _uvx_protective_sigint_handler)
210+
except ImportError:
211+
pass # uvx_detection module not available, ignore
212+
174213
# Load API keys from puppy.cfg into environment variables
175214
from code_puppy.config import load_api_keys_to_environment
176215

@@ -440,6 +479,15 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
440479
task = await get_input_with_combined_completion(
441480
get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE
442481
)
482+
483+
# Windows+uvx: Re-disable Ctrl+C after prompt_toolkit
484+
# (prompt_toolkit restores console mode which re-enables Ctrl+C)
485+
try:
486+
from code_puppy.terminal_utils import ensure_ctrl_c_disabled
487+
488+
ensure_ctrl_c_disabled()
489+
except ImportError:
490+
pass
443491
except ImportError:
444492
# Fall back to basic input if prompt_toolkit is not available
445493
task = input(">>> ")
@@ -605,6 +653,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
605653
if result is None:
606654
# Windows-specific: Reset terminal state after cancellation
607655
reset_windows_terminal_ansi()
656+
# Re-disable Ctrl+C if needed (uvx mode)
657+
try:
658+
from code_puppy.terminal_utils import ensure_ctrl_c_disabled
659+
660+
ensure_ctrl_c_disabled()
661+
except ImportError:
662+
pass
608663
continue
609664
# Get the structured response
610665
agent_response = result.output
@@ -645,6 +700,15 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
645700

646701
auto_save_session_if_enabled()
647702

703+
# Re-disable Ctrl+C if needed (uvx mode) - must be done after
704+
# each iteration as various operations may restore console mode
705+
try:
706+
from code_puppy.terminal_utils import ensure_ctrl_c_disabled
707+
708+
ensure_ctrl_c_disabled()
709+
except ImportError:
710+
pass
711+
648712

649713
def prettier_code_blocks():
650714
"""Configure Rich to use prettier code block rendering."""

code_puppy/keymap.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,19 @@ class KeymapError(Exception):
5555
def get_cancel_agent_key() -> str:
5656
"""Get the configured cancel agent key from config.
5757
58+
On Windows when launched via uvx, this automatically returns "ctrl+k"
59+
to work around uvx capturing Ctrl+C before it reaches Python.
60+
5861
Returns:
5962
The key name (e.g., "ctrl+c", "ctrl+k") from config,
6063
or the default if not configured.
6164
"""
6265
from code_puppy.config import get_value
66+
from code_puppy.uvx_detection import should_use_alternate_cancel_key
67+
68+
# On Windows + uvx, force ctrl+k to bypass uvx's SIGINT capture
69+
if should_use_alternate_cancel_key():
70+
return "ctrl+k"
6371

6472
key = get_value("cancel_agent_key")
6573
if key is None or key.strip() == "":

code_puppy/terminal_utils.py

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
import platform
77
import subprocess
88
import sys
9+
from typing import Callable, Optional
10+
11+
# Store the original console ctrl handler so we can restore it if needed
12+
_original_ctrl_handler: Optional[Callable] = None
913

1014

1115
def reset_windows_terminal_ansi() -> None:
@@ -86,17 +90,36 @@ def reset_windows_console_mode() -> None:
8690
pass # Silently ignore errors - best effort reset
8791

8892

93+
def flush_windows_keyboard_buffer() -> None:
94+
"""Flush the Windows keyboard buffer.
95+
96+
Clears any pending keyboard input that could interfere with
97+
subsequent input operations after an interrupt.
98+
"""
99+
if platform.system() != "Windows":
100+
return
101+
102+
try:
103+
import msvcrt
104+
105+
while msvcrt.kbhit():
106+
msvcrt.getch()
107+
except Exception:
108+
pass # Silently ignore errors - best effort flush
109+
110+
89111
def reset_windows_terminal_full() -> None:
90-
"""Perform a full Windows terminal reset (ANSI + console mode).
112+
"""Perform a full Windows terminal reset (ANSI + console mode + keyboard buffer).
91113
92-
Combines both ANSI reset and console mode reset for complete
93-
terminal state restoration after interrupts.
114+
Combines ANSI reset, console mode reset, and keyboard buffer flush
115+
for complete terminal state restoration after interrupts.
94116
"""
95117
if platform.system() != "Windows":
96118
return
97119

98120
reset_windows_terminal_ansi()
99121
reset_windows_console_mode()
122+
flush_windows_keyboard_buffer()
100123

101124

102125
def reset_unix_terminal() -> None:
@@ -124,3 +147,145 @@ def reset_terminal() -> None:
124147
reset_windows_terminal_full()
125148
else:
126149
reset_unix_terminal()
150+
151+
152+
def disable_windows_ctrl_c() -> bool:
153+
"""Disable Ctrl+C processing at the Windows console input level.
154+
155+
This removes ENABLE_PROCESSED_INPUT from stdin, which prevents
156+
Ctrl+C from being interpreted as a signal at all. Instead, it
157+
becomes just a regular character (^C) that gets ignored.
158+
159+
This is more reliable than SetConsoleCtrlHandler because it
160+
prevents Ctrl+C from being processed before it reaches any handler.
161+
162+
Returns:
163+
True if successfully disabled, False otherwise.
164+
"""
165+
global _original_ctrl_handler
166+
167+
if platform.system() != "Windows":
168+
return False
169+
170+
try:
171+
import ctypes
172+
173+
kernel32 = ctypes.windll.kernel32
174+
175+
# Get stdin handle
176+
STD_INPUT_HANDLE = -10
177+
stdin_handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
178+
179+
# Get current console mode
180+
mode = ctypes.c_ulong()
181+
if not kernel32.GetConsoleMode(stdin_handle, ctypes.byref(mode)):
182+
return False
183+
184+
# Save original mode for potential restoration
185+
_original_ctrl_handler = mode.value
186+
187+
# Console mode flags
188+
ENABLE_PROCESSED_INPUT = 0x0001 # This makes Ctrl+C generate signals
189+
190+
# Remove ENABLE_PROCESSED_INPUT to disable Ctrl+C signal generation
191+
new_mode = mode.value & ~ENABLE_PROCESSED_INPUT
192+
193+
if kernel32.SetConsoleMode(stdin_handle, new_mode):
194+
return True
195+
return False
196+
197+
except Exception:
198+
return False
199+
200+
201+
def enable_windows_ctrl_c() -> bool:
202+
"""Re-enable Ctrl+C at the Windows console level.
203+
204+
Restores the original console mode saved by disable_windows_ctrl_c().
205+
206+
Returns:
207+
True if successfully re-enabled, False otherwise.
208+
"""
209+
global _original_ctrl_handler
210+
211+
if platform.system() != "Windows":
212+
return False
213+
214+
if _original_ctrl_handler is None:
215+
return True # Nothing to restore
216+
217+
try:
218+
import ctypes
219+
220+
kernel32 = ctypes.windll.kernel32
221+
222+
# Get stdin handle
223+
STD_INPUT_HANDLE = -10
224+
stdin_handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
225+
226+
# Restore original mode
227+
if kernel32.SetConsoleMode(stdin_handle, _original_ctrl_handler):
228+
_original_ctrl_handler = None
229+
return True
230+
return False
231+
232+
except Exception:
233+
return False
234+
235+
236+
# Flag to track if we should keep Ctrl+C disabled
237+
_keep_ctrl_c_disabled: bool = False
238+
239+
240+
def set_keep_ctrl_c_disabled(value: bool) -> None:
241+
"""Set whether Ctrl+C should be kept disabled.
242+
243+
When True, ensure_ctrl_c_disabled() will re-disable Ctrl+C
244+
even if something else (like prompt_toolkit) re-enables it.
245+
"""
246+
global _keep_ctrl_c_disabled
247+
_keep_ctrl_c_disabled = value
248+
249+
250+
def ensure_ctrl_c_disabled() -> bool:
251+
"""Ensure Ctrl+C is disabled if it should be.
252+
253+
Call this after operations that might restore console mode
254+
(like prompt_toolkit input).
255+
256+
Returns:
257+
True if Ctrl+C is now disabled (or wasn't needed), False on error.
258+
"""
259+
if not _keep_ctrl_c_disabled:
260+
return True
261+
262+
if platform.system() != "Windows":
263+
return True
264+
265+
try:
266+
import ctypes
267+
268+
kernel32 = ctypes.windll.kernel32
269+
270+
# Get stdin handle
271+
STD_INPUT_HANDLE = -10
272+
stdin_handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
273+
274+
# Get current console mode
275+
mode = ctypes.c_ulong()
276+
if not kernel32.GetConsoleMode(stdin_handle, ctypes.byref(mode)):
277+
return False
278+
279+
# Console mode flags
280+
ENABLE_PROCESSED_INPUT = 0x0001
281+
282+
# Check if Ctrl+C processing is enabled
283+
if mode.value & ENABLE_PROCESSED_INPUT:
284+
# Disable it
285+
new_mode = mode.value & ~ENABLE_PROCESSED_INPUT
286+
return bool(kernel32.SetConsoleMode(stdin_handle, new_mode))
287+
288+
return True # Already disabled
289+
290+
except Exception:
291+
return False

0 commit comments

Comments
 (0)