deepy-cli 0.1.12__tar.gz → 0.1.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/PKG-INFO +2 -3
  2. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/README.md +1 -1
  3. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/pyproject.toml +1 -2
  4. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/__init__.py +1 -1
  5. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/builtin.py +2 -45
  6. deepy_cli-0.1.14/src/deepy/tools/shell_output.py +45 -0
  7. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/local_command.py +94 -55
  8. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/__main__.py +0 -0
  9. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/cli.py +0 -0
  10. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/config/__init__.py +0 -0
  11. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/config/settings.py +0 -0
  12. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/__init__.py +0 -0
  13. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  14. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/WebFetch.md +0 -0
  15. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/WebSearch.md +0 -0
  16. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/__init__.py +0 -0
  17. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/edit.md +0 -0
  18. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/modify.md +0 -0
  19. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/read.md +0 -0
  20. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/shell.md +0 -0
  21. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/data/tools/write.md +0 -0
  22. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/errors.py +0 -0
  23. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/__init__.py +0 -0
  24. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/agent.py +0 -0
  25. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/compaction.py +0 -0
  26. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/context.py +0 -0
  27. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/events.py +0 -0
  28. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/model_capabilities.py +0 -0
  29. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/provider.py +0 -0
  30. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/replay.py +0 -0
  31. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/runner.py +0 -0
  32. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/llm/thinking.py +0 -0
  33. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/__init__.py +0 -0
  34. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/compact.py +0 -0
  35. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/rules.py +0 -0
  36. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/runtime_context.py +0 -0
  37. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/system.py +0 -0
  38. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/prompts/tool_docs.py +0 -0
  39. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/sessions/__init__.py +0 -0
  40. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/sessions/jsonl.py +0 -0
  41. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/sessions/manager.py +0 -0
  42. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/skills.py +0 -0
  43. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/status.py +0 -0
  44. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/__init__.py +0 -0
  45. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/agents.py +0 -0
  46. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/file_state.py +0 -0
  47. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/result.py +0 -0
  48. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/tools/shell_utils.py +0 -0
  49. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/__init__.py +0 -0
  50. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/app.py +0 -0
  51. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/ask_user_question.py +0 -0
  52. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/exit_summary.py +0 -0
  53. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/file_mentions.py +0 -0
  54. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/loading_text.py +0 -0
  55. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/markdown.py +0 -0
  56. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/message_view.py +0 -0
  57. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/model_picker.py +0 -0
  58. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/prompt_buffer.py +0 -0
  59. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/prompt_input.py +0 -0
  60. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/session_list.py +0 -0
  61. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/session_picker.py +0 -0
  62. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/slash_commands.py +0 -0
  63. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/styles.py +0 -0
  64. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/terminal.py +0 -0
  65. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/theme_picker.py +0 -0
  66. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/thinking_state.py +0 -0
  67. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/ui/welcome.py +0 -0
  68. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/update_check.py +0 -0
  69. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/usage.py +0 -0
  70. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/__init__.py +0 -0
  71. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/debug_logger.py +0 -0
  72. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/error_logger.py +0 -0
  73. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/json.py +0 -0
  74. {deepy_cli-0.1.12 → deepy_cli-0.1.14}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.1.12
3
+ Version: 0.1.14
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -17,7 +17,6 @@ Requires-Dist: openai>=2.26,<3
17
17
  Requires-Dist: orjson>=3.10,<4
18
18
  Requires-Dist: pydantic>=2.12,<3
19
19
  Requires-Dist: prompt-toolkit>=3.0,<4
20
- Requires-Dist: pywinpty>=2.0,<3 ; sys_platform == 'win32'
21
20
  Requires-Dist: rich>=13.9,<15
22
21
  Requires-Dist: tiktoken>=0.9,<1
23
22
  Requires-Dist: tomli-w>=1
@@ -79,7 +78,7 @@ of hiding tool calls behind chat text.
79
78
  - **Local command mode**: type `!cmd` to run a non-interactive local shell command
80
79
  without sending it to the model; the result is still saved as context.
81
80
  - **Cross-platform shell handling**: POSIX shell, PowerShell, cmd, Windows paths,
82
- UTF-8 output, CRLF editing, and pywinpty-backed local command mode.
81
+ UTF-8 output, CRLF editing, and non-interactive Windows local command mode.
83
82
 
84
83
  ## See It Work
85
84
 
@@ -50,7 +50,7 @@ of hiding tool calls behind chat text.
50
50
  - **Local command mode**: type `!cmd` to run a non-interactive local shell command
51
51
  without sending it to the model; the result is still saved as context.
52
52
  - **Cross-platform shell handling**: POSIX shell, PowerShell, cmd, Windows paths,
53
- UTF-8 output, CRLF editing, and pywinpty-backed local command mode.
53
+ UTF-8 output, CRLF editing, and non-interactive Windows local command mode.
54
54
 
55
55
  ## See It Work
56
56
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.1.12"
3
+ version = "0.1.14"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -23,7 +23,6 @@ dependencies = [
23
23
  "orjson>=3.10,<4",
24
24
  "pydantic>=2.12,<3",
25
25
  "prompt-toolkit>=3.0,<4",
26
- "pywinpty>=2.0,<3; sys_platform == 'win32'",
27
26
  "rich>=13.9,<15",
28
27
  "tiktoken>=0.9,<1",
29
28
  "tomli-w>=1",
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.1.12"
3
+ __version__ = "0.1.14"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -26,6 +26,7 @@ from deepy.utils import json as json_utils
26
26
 
27
27
  from .file_state import FileSnippet, FileState
28
28
  from .result import ToolResult
29
+ from .shell_output import decode_shell_output
29
30
  from .shell_utils import RuntimeEnvironment
30
31
  from .shell_utils import build_disable_extglob_command
31
32
  from .shell_utils import build_shell_init_command
@@ -2159,54 +2160,10 @@ def _read_captured_output(stream, *, marker: str | None = None) -> tuple[str, st
2159
2160
  truncated = len(data) > MAX_BASH_CAPTURE_CHARS
2160
2161
  if truncated:
2161
2162
  data = data[:MAX_BASH_CAPTURE_CHARS]
2162
- text, encoding = _decode_shell_output(data, marker=marker)
2163
+ text, encoding = decode_shell_output(data, marker=marker)
2163
2164
  return text, encoding, truncated
2164
2165
 
2165
2166
 
2166
- def _decode_shell_output(data: bytes, *, marker: str | None = None) -> tuple[str, str]:
2167
- if not data:
2168
- return "", "empty"
2169
- if marker:
2170
- marker_bytes = marker.encode("ascii")
2171
- marker_index = data.find(marker_bytes)
2172
- if marker_index >= 0:
2173
- sentinel_start = marker_index
2174
- if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\n":
2175
- sentinel_start -= 1
2176
- if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\r":
2177
- sentinel_start -= 1
2178
- visible, visible_encoding = _decode_shell_output_bytes(data[:sentinel_start])
2179
- sentinel = data[sentinel_start:].decode("utf-8", errors="replace")
2180
- return visible + sentinel, visible_encoding
2181
- return _decode_shell_output_bytes(data)
2182
-
2183
-
2184
- def _decode_shell_output_bytes(data: bytes) -> tuple[str, str]:
2185
- if not data:
2186
- return "", "empty"
2187
- if data.startswith((b"\xff\xfe", b"\xfe\xff")):
2188
- return data.decode("utf-16", errors="replace"), "utf-16"
2189
- if _looks_like_utf16le(data):
2190
- return data.decode("utf-16le", errors="replace"), "utf-16le"
2191
- try:
2192
- return data.decode("utf-8-sig"), "utf-8-sig" if data.startswith(b"\xef\xbb\xbf") else "utf-8"
2193
- except UnicodeDecodeError:
2194
- pass
2195
- try:
2196
- return data.decode("gb18030"), "gb18030"
2197
- except UnicodeDecodeError:
2198
- return data.decode("utf-8", errors="replace"), "utf-8-replace"
2199
-
2200
-
2201
- def _looks_like_utf16le(data: bytes) -> bool:
2202
- if len(data) < 4:
2203
- return False
2204
- sample = data[: min(len(data), 4096)]
2205
- odd_nuls = sample[1::2].count(0)
2206
- even_nuls = sample[0::2].count(0)
2207
- return odd_nuls >= max(2, len(sample) // 8) and odd_nuls > even_nuls * 2
2208
-
2209
-
2210
2167
  def _build_shell_command(
2211
2168
  command: str,
2212
2169
  marker: str,
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def decode_shell_output(data: bytes, *, marker: str | None = None) -> tuple[str, str]:
5
+ if not data:
6
+ return "", "empty"
7
+ if marker:
8
+ marker_bytes = marker.encode("ascii")
9
+ marker_index = data.find(marker_bytes)
10
+ if marker_index >= 0:
11
+ sentinel_start = marker_index
12
+ if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\n":
13
+ sentinel_start -= 1
14
+ if sentinel_start > 0 and data[sentinel_start - 1 : sentinel_start] == b"\r":
15
+ sentinel_start -= 1
16
+ visible, visible_encoding = decode_shell_output_bytes(data[:sentinel_start])
17
+ sentinel = data[sentinel_start:].decode("utf-8", errors="replace")
18
+ return visible + sentinel, visible_encoding
19
+ return decode_shell_output_bytes(data)
20
+
21
+
22
+ def decode_shell_output_bytes(data: bytes) -> tuple[str, str]:
23
+ if not data:
24
+ return "", "empty"
25
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
26
+ return data.decode("utf-16", errors="replace"), "utf-16"
27
+ if _looks_like_utf16le(data):
28
+ return data.decode("utf-16le", errors="replace"), "utf-16le"
29
+ try:
30
+ return data.decode("utf-8-sig"), "utf-8-sig" if data.startswith(b"\xef\xbb\xbf") else "utf-8"
31
+ except UnicodeDecodeError:
32
+ pass
33
+ try:
34
+ return data.decode("gb18030"), "gb18030"
35
+ except UnicodeDecodeError:
36
+ return data.decode("utf-8", errors="replace"), "utf-8-replace"
37
+
38
+
39
+ def _looks_like_utf16le(data: bytes) -> bool:
40
+ if len(data) < 4:
41
+ return False
42
+ sample = data[: min(len(data), 4096)]
43
+ odd_nuls = sample[1::2].count(0)
44
+ even_nuls = sample[0::2].count(0)
45
+ return odd_nuls >= max(2, len(sample) // 8) and odd_nuls > even_nuls * 2
@@ -4,6 +4,7 @@ import errno
4
4
  import contextlib
5
5
  import os
6
6
  import queue
7
+ import re
7
8
  import select
8
9
  import shutil
9
10
  import signal
@@ -17,6 +18,7 @@ from pathlib import Path
17
18
  from typing import Any
18
19
 
19
20
  from deepy.tools.result import ToolResult
21
+ from deepy.tools.shell_output import decode_shell_output_bytes
20
22
  from deepy.tools.shell_utils import RuntimeEnvironment, detect_runtime_environment
21
23
  from deepy.utils import json as json_utils
22
24
 
@@ -29,6 +31,21 @@ DEFAULT_LOCAL_COMMAND_TIMEOUT_MS = 120_000
29
31
  DEFAULT_DISPLAY_OUTPUT_LIMIT = 30_000
30
32
  DEFAULT_CONTEXT_OUTPUT_LIMIT = 8_000
31
33
  _TRUNCATED_MARKER = "\n... output truncated ...\n"
34
+ _ANSI_CONTROL_RE = re.compile(
35
+ r"""
36
+ \x1b
37
+ (?:
38
+ \[[0-?]*[ -/]*[@-~]
39
+ |\][^\x07\x1b]*(?:\x07|\x1b\\)
40
+ |P[^\x1b]*(?:\x1b\\)
41
+ |_[^\x1b]*(?:\x1b\\)
42
+ |\^[^\x1b]*(?:\x1b\\)
43
+ |[@-Z\\-_]
44
+ )
45
+ """,
46
+ re.VERBOSE,
47
+ )
48
+ _TERMINAL_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
32
49
 
33
50
 
34
51
  @dataclass(frozen=True)
@@ -97,7 +114,8 @@ def run_local_command(
97
114
  capture_limit = max(display_limit, context_limit) + len(_TRUNCATED_MARKER)
98
115
 
99
116
  if runtime.os_family == "windows":
100
- return _run_windows_pty(
117
+ _prepare_windows_process_env(process_env)
118
+ return _run_windows_pipes(
101
119
  command,
102
120
  cwd=cwd,
103
121
  env=process_env,
@@ -155,6 +173,8 @@ def shell_tool_result_json(
155
173
  output: str | None = None,
156
174
  ) -> str:
157
175
  rendered_output = result.context_output if output is None else output
176
+ if result.os_family == "windows":
177
+ rendered_output = _sanitize_terminal_output(rendered_output)
158
178
  metadata = _shell_metadata(result)
159
179
  if result.ok:
160
180
  return ToolResult.ok_result("shell", rendered_output, metadata=metadata).to_json()
@@ -270,7 +290,7 @@ def _run_posix_pty(
270
290
  )
271
291
 
272
292
 
273
- def _run_windows_pty(
293
+ def _run_windows_pipes(
274
294
  command: str,
275
295
  *,
276
296
  cwd: Path,
@@ -284,70 +304,56 @@ def _run_windows_pty(
284
304
  started_at: float,
285
305
  should_interrupt: Callable[[], bool] | None,
286
306
  ) -> LocalCommandResult:
287
- try:
288
- from winpty import PtyProcess # type: ignore[import-not-found]
289
- except Exception:
290
- error = "Windows local command mode requires the pywinpty package."
291
- return _error_result(
292
- command,
293
- cwd=cwd,
294
- runtime=runtime,
295
- shell_path=shell_path,
296
- timeout_ms=timeout_ms,
297
- started_at=started_at,
298
- tty_mode="unavailable",
299
- error=error,
300
- )
301
-
302
- process = None
307
+ process: subprocess.Popen[bytes] | None = None
303
308
  timed_out = False
304
309
  interrupted = False
305
- captured = ""
310
+ captured = bytearray()
306
311
  capture_truncated = False
307
- output_queue: queue.Queue[str] = queue.Queue()
308
- stop_reader = threading.Event()
312
+ output_queue: queue.Queue[bytes] = queue.Queue()
309
313
  try:
310
- process = PtyProcess.spawn(
314
+ process = subprocess.Popen(
311
315
  [shell_path, *_shell_args(runtime, command)],
312
316
  cwd=str(cwd),
313
317
  env=env,
318
+ stdin=subprocess.DEVNULL,
319
+ stdout=subprocess.PIPE,
320
+ stderr=subprocess.STDOUT,
321
+ close_fds=True,
314
322
  )
315
- reader = threading.Thread(
316
- target=_read_windows_pty_output,
317
- args=(process, output_queue, stop_reader),
318
- daemon=True,
319
- )
323
+ reader = threading.Thread(target=_read_pipe_output, args=(process, output_queue), daemon=True)
320
324
  reader.start()
321
325
  deadline = started_at + timeout_ms / 1000
322
326
  while True:
323
- captured, drained_truncated = _drain_text_queue(
327
+ drained_truncated = _drain_bytes_queue(
324
328
  output_queue,
325
329
  captured,
326
330
  capture_limit,
327
331
  )
328
332
  capture_truncated = capture_truncated or drained_truncated
329
- if not process.isalive():
333
+ if process.poll() is not None:
330
334
  break
331
335
  if callable(should_interrupt) and should_interrupt():
332
336
  interrupted = True
333
- process.terminate(force=True)
337
+ _terminate_windows_process(process)
334
338
  break
335
339
  remaining = deadline - time.monotonic()
336
340
  if remaining <= 0:
337
341
  timed_out = True
338
- process.terminate(force=True)
342
+ _terminate_windows_process(process)
339
343
  break
340
344
  time.sleep(min(0.05, remaining))
341
- stop_reader.set()
342
345
  reader.join(timeout=0.2)
343
- captured, drained_truncated = _drain_text_queue(output_queue, captured, capture_limit)
344
- capture_truncated = capture_truncated or drained_truncated
345
- exit_code = process.exitstatus
346
- output = captured
346
+ capture_truncated = (
347
+ capture_truncated or _drain_bytes_queue(output_queue, captured, capture_limit)
348
+ )
349
+ if len(captured) >= capture_limit:
350
+ capture_truncated = True
351
+ exit_code = process.poll()
352
+ output = _sanitize_terminal_output(_decode_output(bytes(captured), windows_compatible=True))
347
353
  error = _command_error(exit_code, timed_out=timed_out, interrupted=interrupted)
348
354
  except Exception as exc:
349
355
  exit_code = None
350
- output = captured
356
+ output = _sanitize_terminal_output(_decode_output(bytes(captured), windows_compatible=True))
351
357
  error = str(exc)
352
358
 
353
359
  display_output, display_truncated = _limit_output(output, display_limit)
@@ -364,7 +370,7 @@ def _run_windows_pty(
364
370
  command_dialect=runtime.command_dialect,
365
371
  path_style=runtime.path_style,
366
372
  os_family=runtime.os_family,
367
- tty_mode="winpty",
373
+ tty_mode="pipe",
368
374
  duration_ms=_elapsed_ms(started_at),
369
375
  timeout_ms=timeout_ms,
370
376
  timed_out=timed_out,
@@ -376,36 +382,37 @@ def _run_windows_pty(
376
382
  )
377
383
 
378
384
 
379
- def _read_windows_pty_output(
380
- process: Any,
381
- output_queue: queue.Queue[str],
382
- stop_reader: threading.Event,
385
+ def _read_pipe_output(
386
+ process: subprocess.Popen[bytes],
387
+ output_queue: queue.Queue[bytes],
383
388
  ) -> None:
384
- while not stop_reader.is_set():
389
+ stream = process.stdout
390
+ if stream is None:
391
+ return
392
+ while True:
385
393
  try:
386
- chunk = process.read(4096)
394
+ chunk = stream.read1(4096)
387
395
  except Exception:
388
396
  return
389
- if chunk:
390
- output_queue.put(chunk)
391
- elif not process.isalive():
397
+ if not chunk:
392
398
  return
399
+ output_queue.put(chunk)
393
400
 
394
401
 
395
- def _drain_text_queue(
396
- output_queue: queue.Queue[str],
397
- captured: str,
402
+ def _drain_bytes_queue(
403
+ output_queue: queue.Queue[bytes],
404
+ captured: bytearray,
398
405
  capture_limit: int,
399
- ) -> tuple[str, bool]:
406
+ ) -> bool:
400
407
  truncated = False
401
408
  while True:
402
409
  try:
403
410
  chunk = output_queue.get_nowait()
404
411
  except queue.Empty:
405
- return captured, truncated
412
+ return truncated
406
413
  if len(captured) < capture_limit:
407
414
  remaining = capture_limit - len(captured)
408
- captured += chunk[:remaining]
415
+ captured.extend(chunk[:remaining])
409
416
  if len(chunk) > remaining:
410
417
  truncated = True
411
418
  else:
@@ -472,6 +479,20 @@ def _terminate_process(process: subprocess.Popen[bytes]) -> None:
472
479
  process.wait(timeout=0.2)
473
480
 
474
481
 
482
+ def _terminate_windows_process(process: subprocess.Popen[bytes]) -> None:
483
+ if process.poll() is not None:
484
+ return
485
+ with contextlib.suppress(OSError):
486
+ process.terminate()
487
+ try:
488
+ process.wait(timeout=0.2)
489
+ except subprocess.TimeoutExpired:
490
+ with contextlib.suppress(OSError):
491
+ process.kill()
492
+ with contextlib.suppress(OSError, subprocess.TimeoutExpired):
493
+ process.wait(timeout=0.2)
494
+
495
+
475
496
  def _limit_output(output: str, limit: int) -> tuple[str, bool]:
476
497
  if limit <= 0:
477
498
  return (_TRUNCATED_MARKER if output else ""), bool(output)
@@ -484,8 +505,26 @@ def _limit_output(output: str, limit: int) -> tuple[str, bool]:
484
505
  return output[:keep] + marker, True
485
506
 
486
507
 
487
- def _decode_output(output: bytes) -> str:
488
- return output.replace(b"\r\n", b"\n").replace(b"\r", b"\n").decode("utf-8", errors="replace")
508
+ def _decode_output(output: bytes, *, windows_compatible: bool = False) -> str:
509
+ if windows_compatible:
510
+ text, _ = decode_shell_output_bytes(output)
511
+ else:
512
+ text = output.decode("utf-8", errors="replace")
513
+ return _normalize_line_endings(text)
514
+
515
+
516
+ def _prepare_windows_process_env(env: dict[str, str]) -> None:
517
+ env.setdefault("PYTHONUTF8", "1")
518
+ env.setdefault("PYTHONIOENCODING", "utf-8")
519
+
520
+
521
+ def _sanitize_terminal_output(output: str) -> str:
522
+ normalized = _normalize_line_endings(output)
523
+ return _TERMINAL_CONTROL_CHAR_RE.sub("", _ANSI_CONTROL_RE.sub("", normalized))
524
+
525
+
526
+ def _normalize_line_endings(output: str) -> str:
527
+ return output.replace("\r\n", "\n").replace("\r", "\n")
489
528
 
490
529
 
491
530
  def _command_error(exit_code: int | None, *, timed_out: bool, interrupted: bool) -> str | None:
File without changes