44import asyncio
55import contextlib
66import json
7- import os
87import re
98import shlex
109import textwrap
1110import time
1211from collections import deque
1312from collections .abc import Awaitable , Callable , Coroutine
1413from dataclasses import dataclass
15- from enum import Enum
14+ from enum import Enum , StrEnum
1615from typing import TYPE_CHECKING , Any , Protocol , cast
1716
1817if TYPE_CHECKING :
2524 APITimeoutError ,
2625 ChatProviderError ,
2726)
28- from pythinker_host .windows import windows_console_detach_flags
2927from rich import box
3028from rich .align import Align
3129from rich .cells import cell_len
4947 run_soul ,
5048)
5149from pythinker_code .soul .pythinkersoul import FLOW_COMMAND_PREFIX , PythinkerSoul
50+ from pythinker_code .ui .shell .command_runner import ShellCommandRunner
5251from pythinker_code .ui .shell .components .render_utils import (
5352 cell_width ,
5453 render_message_response ,
106105from pythinker_code .utils .logging import logger
107106from pythinker_code .utils .signals import install_sigint_handler
108107from pythinker_code .utils .slashcmd import SlashCommand , SlashCommandCall , parse_slash_command_call
109- from pythinker_code .utils .subprocess_env import get_clean_env
110108from pythinker_code .utils .term import ensure_new_line , ensure_tty_sane
111109from pythinker_code .wire .types import (
112110 ApprovalRequest ,
117115)
118116
119117
118+ class PromptEventKind (StrEnum ):
119+ INPUT = "input"
120+ INPUT_ACTIVITY = "input_activity"
121+ BACKGROUND_GRACE_EXPIRED = "background_grace_expired"
122+ BACKGROUND_NOOP = "bg_noop"
123+ INTERRUPT = "interrupt"
124+ EOF = "eof"
125+ CWD_LOST = "cwd_lost"
126+ ERROR = "error"
127+
128+
120129@dataclass (slots = True )
121130class _PromptEvent :
122- kind : str
131+ kind : PromptEventKind
123132 user_input : UserInput | None = None
124133
125134
@@ -235,15 +244,19 @@ async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _Prom
235244 assert self ._event is not None
236245 bg_wait_task = asyncio .create_task (self ._event .wait ())
237246
238- done , _ = await asyncio .wait (
239- [idle_task , bg_wait_task ],
240- return_when = asyncio .FIRST_COMPLETED ,
241- )
242- for t in (idle_task , bg_wait_task ):
243- if t not in done :
244- t .cancel ()
247+ done : set [asyncio .Task [Any ]] = set ()
248+ try :
249+ done , _ = await asyncio .wait (
250+ [idle_task , bg_wait_task ],
251+ return_when = asyncio .FIRST_COMPLETED ,
252+ )
253+ finally :
254+ for task in (idle_task , bg_wait_task ):
255+ if task .done ():
256+ continue
257+ task .cancel ()
245258 with contextlib .suppress (asyncio .CancelledError ):
246- await t
259+ await task
247260
248261 if idle_task in done :
249262 if bg_wait_task in done :
@@ -255,8 +268,8 @@ async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _Prom
255268 if self ._has_pending_llm_notifications ():
256269 if self ._can_auto_trigger_pending ():
257270 return None
258- return _PromptEvent (kind = "bg_noop" )
259- return _PromptEvent (kind = "bg_noop" )
271+ return _PromptEvent (kind = PromptEventKind . BACKGROUND_NOOP )
272+ return _PromptEvent (kind = PromptEventKind . BACKGROUND_NOOP )
260273
261274 def _has_pending_llm_notifications (self ) -> bool :
262275 if self ._notifications is None :
@@ -735,7 +748,7 @@ async def _route_prompt_events(
735748 self ._running_interrupt_handler ()
736749 continue
737750 resume_prompt .clear ()
738- await idle_events .put (_PromptEvent (kind = "interrupt" ))
751+ await idle_events .put (_PromptEvent (kind = PromptEventKind . INTERRUPT ))
739752 continue
740753 except EOFError :
741754 logger .debug ("Prompt router got EOF" )
@@ -748,17 +761,17 @@ async def _route_prompt_events(
748761 self ._running_interrupt_handler ()
749762 return
750763 resume_prompt .clear ()
751- await idle_events .put (_PromptEvent (kind = "eof" ))
764+ await idle_events .put (_PromptEvent (kind = PromptEventKind . EOF ))
752765 return
753766 except CwdLostError :
754767 logger .error ("Working directory no longer exists" )
755768 resume_prompt .clear ()
756- await idle_events .put (_PromptEvent (kind = "cwd_lost" ))
769+ await idle_events .put (_PromptEvent (kind = PromptEventKind . CWD_LOST ))
757770 return
758771 except Exception :
759772 logger .exception ("Prompt router crashed" )
760773 resume_prompt .clear ()
761- await idle_events .put (_PromptEvent (kind = "error" ))
774+ await idle_events .put (_PromptEvent (kind = PromptEventKind . ERROR ))
762775 return
763776
764777 if prompt_session .last_submission_was_running : # noqa: SIM102
@@ -769,7 +782,7 @@ async def _route_prompt_events(
769782 # Handler already unbound — fall through to idle path.
770783
771784 resume_prompt .clear ()
772- await idle_events .put (_PromptEvent (kind = "input" , user_input = user_input ))
785+ await idle_events .put (_PromptEvent (kind = PromptEventKind . INPUT , user_input = user_input ))
773786
774787 def _register_task_label_resolver (self ) -> None :
775788 """Let TaskOutput/TaskStop headers show a task's friendly description
@@ -1042,6 +1055,13 @@ def _can_auto_trigger_pending() -> bool:
10421055 else :
10431056 result = await bg_watcher .wait_for_next (idle_events )
10441057
1058+ if (
1059+ result is not None
1060+ and result .kind is PromptEventKind .BACKGROUND_GRACE_EXPIRED
1061+ ):
1062+ logger .debug ("Background auto-trigger input grace elapsed" )
1063+ result = None
1064+
10451065 if result is None :
10461066 if self ._should_defer_background_auto_trigger (prompt_session ):
10471067 deferred_bg_trigger = True
@@ -1082,28 +1102,29 @@ def _can_auto_trigger_pending() -> bool:
10821102
10831103 event = result
10841104
1085- if event .kind == "input_activity" :
1105+ if event .kind is PromptEventKind .INPUT_ACTIVITY :
1106+ logger .debug ("Deferring background auto-trigger for local input activity" )
10861107 continue
10871108
1088- if event .kind == "bg_noop" :
1109+ if event .kind is PromptEventKind . BACKGROUND_NOOP :
10891110 continue
10901111
1091- if event .kind == "interrupt" :
1112+ if event .kind is PromptEventKind . INTERRUPT :
10921113 _t = _get_tui_tokens ()
10931114 console .print (f"[{ _t .muted } ]Tip: press Ctrl-D or send 'exit' to quit[/]" )
10941115 resume_prompt .set ()
10951116 continue
10961117
1097- if event .kind == "eof" :
1118+ if event .kind is PromptEventKind . EOF :
10981119 console .print ("Bye!" )
10991120 break
11001121
1101- if event .kind == "cwd_lost" :
1122+ if event .kind is PromptEventKind . CWD_LOST :
11021123 self ._print_cwd_lost_crash ()
11031124 shell_ok = False
11041125 break
11051126
1106- if event .kind == "error" :
1127+ if event .kind is PromptEventKind . ERROR :
11071128 shell_ok = False
11081129 break
11091130
@@ -1251,67 +1272,31 @@ async def _run_shell_command(self, command: str) -> None:
12511272
12521273 track ("input_bash" )
12531274
1254- proc : asyncio .subprocess . Process | None = None
1255- max_output_bytes = 1_000_000
1275+ runner_task = asyncio .create_task ( ShellCommandRunner (). run ( command ))
1276+ interrupted = False
12561277
1257- async def _read_stream_limited (stream : asyncio .StreamReader | None , limit : int ) -> bytes :
1258- if stream is None :
1259- return b""
1260- chunks : list [bytes ] = []
1261- total = 0
1262- truncated = False
1263- while True :
1264- chunk = await stream .read (65536 )
1265- if not chunk :
1266- break
1267- remaining = limit - total
1268- if remaining > 0 :
1269- chunks .append (chunk [:remaining ])
1270- total += min (len (chunk ), remaining )
1271- if len (chunk ) > remaining :
1272- truncated = True
1273- if truncated :
1274- chunks .append (b"\n ... output truncated ...\n " )
1275- return b"" .join (chunks )
1276-
1277- def _handler ():
1278+ def _handler () -> None :
1279+ nonlocal interrupted
12781280 logger .debug ("SIGINT received." )
1279- if proc :
1280- proc . terminate ()
1281+ interrupted = True
1282+ runner_task . cancel ()
12811283
12821284 loop = asyncio .get_running_loop ()
12831285 remove_sigint = install_sigint_handler (loop , _handler )
12841286 try :
1285- # TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
1286- # Later we should consider making this behave like a real shell.
1287- spawn_kwargs : dict [str , Any ] = {}
1288- if os .name == "nt" :
1289- # CREATE_NO_WINDOW: don't share the interactive console — a child
1290- # touching it via the Win32 console API bypasses the pipes and
1291- # can blank the TUI until terminal restart.
1292- spawn_kwargs ["creationflags" ] = windows_console_detach_flags (
1293- new_process_group = False
1294- )
1295- proc = await asyncio .create_subprocess_shell (
1296- command ,
1297- env = get_clean_env (),
1298- stdout = asyncio .subprocess .PIPE ,
1299- stderr = asyncio .subprocess .PIPE ,
1300- ** spawn_kwargs ,
1301- )
1302- stdout_task = asyncio .create_task (_read_stream_limited (proc .stdout , max_output_bytes ))
1303- stderr_task = asyncio .create_task (_read_stream_limited (proc .stderr , max_output_bytes ))
1304- await proc .wait ()
1305- stdout_bytes , stderr_bytes = await asyncio .gather (stdout_task , stderr_task )
1306- stdout = stdout_bytes .decode ("utf-8" , errors = "replace" ) if stdout_bytes else ""
1307- stderr = stderr_bytes .decode ("utf-8" , errors = "replace" ) if stderr_bytes else ""
1287+ # Commands intentionally run in the detected configured shell. Each
1288+ # execution remains isolated, so state such as `cd` is not persistent.
1289+ result = await runner_task
13081290 output = _format_local_shell_output (
1309- stdout = stdout ,
1310- stderr = stderr ,
1311- returncode = proc .returncode ,
1291+ stdout = result . stdout ,
1292+ stderr = result . stderr ,
1293+ returncode = result .returncode ,
13121294 )
13131295 if output is not None :
13141296 console .print (render_message_response (output ))
1297+ except asyncio .CancelledError :
1298+ if not interrupted :
1299+ raise
13151300 except Exception as e :
13161301 logger .exception ("Failed to run shell command:" )
13171302 console .print (
@@ -1794,7 +1779,9 @@ async def _wait_for_input_or_activity(
17941779
17951780 if idle_task in done :
17961781 return idle_task .result ()
1797- return _PromptEvent (kind = "input_activity" )
1782+ if activity_task in done :
1783+ return _PromptEvent (kind = PromptEventKind .INPUT_ACTIVITY )
1784+ return _PromptEvent (kind = PromptEventKind .BACKGROUND_GRACE_EXPIRED )
17981785
17991786 async def _watch_root_wire_hub (self ) -> None :
18001787 if not isinstance (self .soul , PythinkerSoul ):
0 commit comments