Skip to content

Commit f1eb37f

Browse files
committed
refactor(shell): harden command and idle event execution
1 parent 1f5619c commit f1eb37f

7 files changed

Lines changed: 530 additions & 112 deletions

File tree

src/pythinker_code/ui/shell/__init__.py

Lines changed: 65 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44
import asyncio
55
import contextlib
66
import json
7-
import os
87
import re
98
import shlex
109
import textwrap
1110
import time
1211
from collections import deque
1312
from collections.abc import Awaitable, Callable, Coroutine
1413
from dataclasses import dataclass
15-
from enum import Enum
14+
from enum import Enum, StrEnum
1615
from typing import TYPE_CHECKING, Any, Protocol, cast
1716

1817
if TYPE_CHECKING:
@@ -25,7 +24,6 @@
2524
APITimeoutError,
2625
ChatProviderError,
2726
)
28-
from pythinker_host.windows import windows_console_detach_flags
2927
from rich import box
3028
from rich.align import Align
3129
from rich.cells import cell_len
@@ -49,6 +47,7 @@
4947
run_soul,
5048
)
5149
from pythinker_code.soul.pythinkersoul import FLOW_COMMAND_PREFIX, PythinkerSoul
50+
from pythinker_code.ui.shell.command_runner import ShellCommandRunner
5251
from pythinker_code.ui.shell.components.render_utils import (
5352
cell_width,
5453
render_message_response,
@@ -106,7 +105,6 @@
106105
from pythinker_code.utils.logging import logger
107106
from pythinker_code.utils.signals import install_sigint_handler
108107
from pythinker_code.utils.slashcmd import SlashCommand, SlashCommandCall, parse_slash_command_call
109-
from pythinker_code.utils.subprocess_env import get_clean_env
110108
from pythinker_code.utils.term import ensure_new_line, ensure_tty_sane
111109
from pythinker_code.wire.types import (
112110
ApprovalRequest,
@@ -117,9 +115,20 @@
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)
121130
class _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

Comments
 (0)