ripperdoc 0.2.9__py3-none-any.whl → 0.3.0__py3-none-any.whl

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 (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +379 -51
  3. ripperdoc/cli/commands/__init__.py +6 -0
  4. ripperdoc/cli/commands/agents_cmd.py +128 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  7. ripperdoc/cli/commands/exit_cmd.py +1 -0
  8. ripperdoc/cli/commands/memory_cmd.py +2 -1
  9. ripperdoc/cli/commands/models_cmd.py +63 -7
  10. ripperdoc/cli/commands/resume_cmd.py +5 -0
  11. ripperdoc/cli/commands/skills_cmd.py +103 -0
  12. ripperdoc/cli/commands/stats_cmd.py +244 -0
  13. ripperdoc/cli/commands/status_cmd.py +10 -0
  14. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  15. ripperdoc/cli/commands/themes_cmd.py +139 -0
  16. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  17. ripperdoc/cli/ui/helpers.py +6 -3
  18. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  19. ripperdoc/cli/ui/panels.py +14 -8
  20. ripperdoc/cli/ui/rich_ui.py +737 -47
  21. ripperdoc/cli/ui/spinner.py +93 -18
  22. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  23. ripperdoc/cli/ui/tool_renderers.py +10 -9
  24. ripperdoc/cli/ui/wizard.py +24 -19
  25. ripperdoc/core/agents.py +14 -3
  26. ripperdoc/core/config.py +238 -6
  27. ripperdoc/core/default_tools.py +91 -10
  28. ripperdoc/core/hooks/events.py +4 -0
  29. ripperdoc/core/hooks/llm_callback.py +58 -0
  30. ripperdoc/core/hooks/manager.py +6 -0
  31. ripperdoc/core/permissions.py +160 -9
  32. ripperdoc/core/providers/openai.py +84 -28
  33. ripperdoc/core/query.py +489 -87
  34. ripperdoc/core/query_utils.py +17 -14
  35. ripperdoc/core/skills.py +1 -0
  36. ripperdoc/core/theme.py +298 -0
  37. ripperdoc/core/tool.py +15 -5
  38. ripperdoc/protocol/__init__.py +14 -0
  39. ripperdoc/protocol/models.py +300 -0
  40. ripperdoc/protocol/stdio.py +1453 -0
  41. ripperdoc/tools/background_shell.py +354 -139
  42. ripperdoc/tools/bash_tool.py +117 -22
  43. ripperdoc/tools/file_edit_tool.py +228 -50
  44. ripperdoc/tools/file_read_tool.py +154 -3
  45. ripperdoc/tools/file_write_tool.py +53 -11
  46. ripperdoc/tools/grep_tool.py +98 -8
  47. ripperdoc/tools/lsp_tool.py +609 -0
  48. ripperdoc/tools/multi_edit_tool.py +26 -3
  49. ripperdoc/tools/skill_tool.py +52 -1
  50. ripperdoc/tools/task_tool.py +539 -65
  51. ripperdoc/utils/conversation_compaction.py +1 -1
  52. ripperdoc/utils/file_watch.py +216 -7
  53. ripperdoc/utils/image_utils.py +125 -0
  54. ripperdoc/utils/log.py +30 -3
  55. ripperdoc/utils/lsp.py +812 -0
  56. ripperdoc/utils/mcp.py +80 -18
  57. ripperdoc/utils/message_formatting.py +7 -4
  58. ripperdoc/utils/messages.py +198 -33
  59. ripperdoc/utils/pending_messages.py +50 -0
  60. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  61. ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
  62. ripperdoc/utils/platform.py +198 -0
  63. ripperdoc/utils/session_heatmap.py +242 -0
  64. ripperdoc/utils/session_history.py +2 -2
  65. ripperdoc/utils/session_stats.py +294 -0
  66. ripperdoc/utils/shell_utils.py +8 -5
  67. ripperdoc/utils/todo.py +0 -6
  68. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
  69. ripperdoc-0.3.0.dist-info/RECORD +136 -0
  70. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  71. ripperdoc/sdk/__init__.py +0 -9
  72. ripperdoc/sdk/client.py +0 -333
  73. ripperdoc-0.2.9.dist-info/RECORD +0 -123
  74. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
@@ -5,8 +5,9 @@ Allows the AI to read file contents.
5
5
 
6
6
  import os
7
7
  from pathlib import Path
8
- from typing import AsyncGenerator, List, Optional
8
+ from typing import AsyncGenerator, List, Optional, Tuple
9
9
  from pydantic import BaseModel, Field
10
+ from charset_normalizer import from_bytes
10
11
 
11
12
  from ripperdoc.core.tool import (
12
13
  Tool,
@@ -23,6 +24,105 @@ from ripperdoc.utils.path_ignore import check_path_for_tool
23
24
  logger = get_logger()
24
25
 
25
26
 
27
+ def detect_file_encoding(file_path: str) -> Tuple[Optional[str], float]:
28
+ """Detect file encoding using charset-normalizer.
29
+
30
+ Returns:
31
+ Tuple of (encoding, confidence). encoding is None if detection failed.
32
+ """
33
+ try:
34
+ with open(file_path, "rb") as f:
35
+ raw_data = f.read()
36
+ results = from_bytes(raw_data)
37
+
38
+ if not results:
39
+ return None, 0.0
40
+
41
+ best = results.best()
42
+ if not best:
43
+ return None, 0.0
44
+
45
+ # For Chinese content, prefer GB encodings over Big5/others
46
+ # charset-normalizer sometimes picks Big5 for simplified Chinese
47
+ if best.language == "Chinese":
48
+ gb_encodings = {"gb18030", "gbk", "gb2312"}
49
+ for result in results:
50
+ if result.encoding.lower() in gb_encodings:
51
+ return result.encoding, 0.9
52
+
53
+ return best.encoding, 0.9
54
+ except (OSError, IOError) as e:
55
+ logger.warning("Failed to detect encoding for %s: %s", file_path, e)
56
+ return None, 0.0
57
+
58
+
59
+ def read_file_with_encoding(file_path: str) -> Tuple[Optional[List[str]], str, Optional[str]]:
60
+ """Read file with proper encoding detection.
61
+
62
+ Returns:
63
+ Tuple of (lines, encoding_used, error_message).
64
+ If successful: (lines, encoding, None)
65
+ If failed: (None, "", error_message)
66
+ """
67
+ # First, try UTF-8 (most common)
68
+ try:
69
+ with open(file_path, "r", encoding="utf-8", errors="strict") as f:
70
+ lines = f.readlines()
71
+ return lines, "utf-8", None
72
+ except UnicodeDecodeError:
73
+ pass
74
+
75
+ # UTF-8 failed, use charset-normalizer to detect encoding
76
+ detected_encoding, confidence = detect_file_encoding(file_path)
77
+
78
+ if detected_encoding:
79
+ try:
80
+ with open(file_path, "r", encoding=detected_encoding, errors="strict") as f:
81
+ lines = f.readlines()
82
+ logger.info(
83
+ "File %s decoded using detected encoding %s",
84
+ file_path,
85
+ detected_encoding,
86
+ )
87
+ return lines, detected_encoding, None
88
+ except (UnicodeDecodeError, LookupError) as e:
89
+ logger.warning(
90
+ "Failed to read %s with detected encoding %s: %s",
91
+ file_path,
92
+ detected_encoding,
93
+ e,
94
+ )
95
+
96
+ # Detection failed - try latin-1 as last resort (can decode any byte sequence)
97
+ try:
98
+ with open(file_path, "r", encoding="latin-1", errors="strict") as f:
99
+ lines = f.readlines()
100
+ logger.warning(
101
+ "File %s: encoding detection failed, using latin-1 fallback",
102
+ file_path,
103
+ )
104
+ return lines, "latin-1", None
105
+ except (UnicodeDecodeError, LookupError):
106
+ pass
107
+
108
+ # All attempts failed - return error
109
+ error_msg = (
110
+ f"Unable to determine file encoding. "
111
+ f"Detected: {detected_encoding or 'unknown'} (confidence: {confidence * 100:.0f}%). "
112
+ f"Tried fallback encodings: utf-8, latin-1. "
113
+ f"Please convert the file to UTF-8."
114
+ )
115
+ return None, "", error_msg
116
+
117
+
118
+ # Maximum file size to read (default 256KB)
119
+ # Can be overridden via env var in bytes
120
+ MAX_FILE_SIZE_BYTES = int(os.getenv("RIPPERDOC_MAX_READ_FILE_SIZE_BYTES", "262144")) # 256KB
121
+
122
+ # Maximum lines to read when no limit is specified (default 2000 lines)
123
+ MAX_READ_LINES = int(os.getenv("RIPPERDOC_MAX_READ_LINES", "2000"))
124
+
125
+
26
126
  class FileReadToolInput(BaseModel):
27
127
  """Input schema for FileReadTool."""
28
128
 
@@ -75,6 +175,7 @@ and limit to read only a portion of the file."""
75
175
  "Read a file from the local filesystem.\n\n"
76
176
  "Usage:\n"
77
177
  "- The file_path parameter must be an absolute path (not relative).\n"
178
+ "- Files larger than 256KB or with more than 2000 lines require using offset and limit parameters.\n"
78
179
  "- By default, the entire file is read. You can optionally specify a line offset and limit (handy for long files); offset is zero-based and output line numbers start at 1.\n"
79
180
  "- Lines longer than 2000 characters are truncated in the output.\n"
80
181
  "- Results are returned with cat -n style numbering: spaces + line number + tab, then the file content.\n"
@@ -140,11 +241,60 @@ and limit to read only a portion of the file."""
140
241
  """Read the file."""
141
242
 
142
243
  try:
143
- with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
144
- lines = f.readlines()
244
+ # Check file size before reading to prevent memory exhaustion
245
+ file_size = os.path.getsize(input_data.file_path)
246
+ if file_size > MAX_FILE_SIZE_BYTES:
247
+ size_kb = file_size / 1024
248
+ limit_kb = MAX_FILE_SIZE_BYTES / 1024
249
+ error_output = FileReadToolOutput(
250
+ content=f"File too large to read: {size_kb:.1f}KB exceeds limit of {limit_kb:.0f}KB. Use offset and limit parameters to read portions.",
251
+ file_path=input_data.file_path,
252
+ line_count=0,
253
+ offset=0,
254
+ limit=None,
255
+ )
256
+ yield ToolResult(
257
+ data=error_output,
258
+ result_for_assistant=f"Error: File {input_data.file_path} is too large ({size_kb:.1f}KB). Maximum size is {limit_kb:.0f}KB. Use offset and limit to read portions, e.g., Read(file_path='{input_data.file_path}', offset=0, limit=500).",
259
+ )
260
+ return
261
+
262
+ # Detect and read file with proper encoding
263
+ lines, used_encoding, encoding_error = read_file_with_encoding(input_data.file_path)
264
+
265
+ if lines is None:
266
+ # Encoding detection failed - return warning to LLM
267
+ error_output = FileReadToolOutput(
268
+ content=f"Encoding error: {encoding_error}",
269
+ file_path=input_data.file_path,
270
+ line_count=0,
271
+ offset=0,
272
+ limit=None,
273
+ )
274
+ yield ToolResult(
275
+ data=error_output,
276
+ result_for_assistant=f"Error: Cannot read file {input_data.file_path}. {encoding_error}",
277
+ )
278
+ return
145
279
 
146
280
  offset = input_data.offset or 0
147
281
  limit = input_data.limit
282
+ total_lines = len(lines)
283
+
284
+ # Check line count if no limit is specified (to prevent context overflow)
285
+ if limit is None and total_lines > MAX_READ_LINES:
286
+ error_output = FileReadToolOutput(
287
+ content=f"File too large: {total_lines} lines exceeds limit of {MAX_READ_LINES} lines. Use offset and limit parameters to read portions.",
288
+ file_path=input_data.file_path,
289
+ line_count=total_lines,
290
+ offset=0,
291
+ limit=None,
292
+ )
293
+ yield ToolResult(
294
+ data=error_output,
295
+ result_for_assistant=f"Error: File {input_data.file_path} has {total_lines} lines, exceeding the limit of {MAX_READ_LINES} lines when reading without limit parameter. Use offset and limit to read portions, e.g., Read(file_path='{input_data.file_path}', offset=0, limit=500).",
296
+ )
297
+ return
148
298
 
149
299
  # Apply offset and limit
150
300
  if limit is not None:
@@ -164,6 +314,7 @@ and limit to read only a portion of the file."""
164
314
  getattr(context, "file_state_cache", {}),
165
315
  offset=offset,
166
316
  limit=limit,
317
+ encoding=used_encoding,
167
318
  )
168
319
  except (OSError, IOError, RuntimeError) as exc:
169
320
  logger.warning(
@@ -19,10 +19,48 @@ from ripperdoc.core.tool import (
19
19
  from ripperdoc.utils.log import get_logger
20
20
  from ripperdoc.utils.file_watch import record_snapshot
21
21
  from ripperdoc.utils.path_ignore import check_path_for_tool
22
+ from ripperdoc.tools.file_read_tool import detect_file_encoding
22
23
 
23
24
  logger = get_logger()
24
25
 
25
26
 
27
+ def determine_write_encoding(file_path: str, content: str) -> str:
28
+ """Determine the best encoding to use for writing a file.
29
+
30
+ Strategy:
31
+ 1. If file doesn't exist -> use UTF-8
32
+ 2. If file exists -> detect its encoding using charset-normalizer
33
+ 3. Verify content can be encoded with target encoding
34
+ 4. If encoding fails (e.g., emoji in GBK) -> fall back to UTF-8
35
+
36
+ Returns:
37
+ The encoding to use for writing.
38
+ """
39
+ # Default to UTF-8 for new files
40
+ if not os.path.exists(file_path):
41
+ return "utf-8"
42
+
43
+ # Detect existing file's encoding
44
+ detected_encoding, _ = detect_file_encoding(file_path)
45
+
46
+ # If detection failed, use UTF-8
47
+ if not detected_encoding:
48
+ return "utf-8"
49
+
50
+ # Verify content can be encoded with detected encoding
51
+ try:
52
+ content.encode(detected_encoding)
53
+ return detected_encoding
54
+ except (UnicodeEncodeError, LookupError):
55
+ # Content can't be encoded (e.g., emoji in GBK), fall back to UTF-8
56
+ logger.info(
57
+ "Content cannot be encoded with %s, falling back to UTF-8 for %s",
58
+ detected_encoding,
59
+ file_path,
60
+ )
61
+ return "utf-8"
62
+
63
+
26
64
  class FileWriteToolInput(BaseModel):
27
65
  """Input schema for FileWriteTool."""
28
66
 
@@ -104,6 +142,13 @@ NEVER write new files unless explicitly required by the user."""
104
142
 
105
143
  file_path = os.path.abspath(input_data.file_path)
106
144
 
145
+ file_path_obj = Path(file_path)
146
+ should_proceed, warning_msg = check_path_for_tool(
147
+ file_path_obj, tool_name="Write", warn_only=True
148
+ )
149
+ if warning_msg:
150
+ logger.warning("[file_write_tool] %s", warning_msg)
151
+
107
152
  # If file doesn't exist, it's a new file - allow without reading first
108
153
  if not os.path.exists(file_path):
109
154
  return ValidationResult(result=True)
@@ -132,14 +177,6 @@ NEVER write new files unless explicitly required by the user."""
132
177
  except OSError:
133
178
  pass # File mtime check failed, proceed anyway
134
179
 
135
- # Check if path is ignored (warning for write operations)
136
- file_path_obj = Path(file_path)
137
- should_proceed, warning_msg = check_path_for_tool(
138
- file_path_obj, tool_name="Write", warn_only=True
139
- )
140
- if warning_msg:
141
- logger.warning("[file_write_tool] %s", warning_msg)
142
-
143
180
  return ValidationResult(result=True)
144
181
 
145
182
  def render_result_for_assistant(self, output: FileWriteToolOutput) -> str:
@@ -156,11 +193,15 @@ NEVER write new files unless explicitly required by the user."""
156
193
  """Write the file."""
157
194
 
158
195
  try:
159
- # Write the file
160
- with open(input_data.file_path, "w", encoding="utf-8") as f:
196
+ # Determine encoding based on target file and content
197
+ file_path = os.path.abspath(input_data.file_path)
198
+ encoding = determine_write_encoding(file_path, input_data.content)
199
+
200
+ # Write the file with the appropriate encoding
201
+ with open(input_data.file_path, "w", encoding=encoding) as f:
161
202
  f.write(input_data.content)
162
203
 
163
- bytes_written = len(input_data.content.encode("utf-8"))
204
+ bytes_written = len(input_data.content.encode(encoding))
164
205
 
165
206
  # Use absolute path to ensure consistency with validation lookup
166
207
  abs_file_path = os.path.abspath(input_data.file_path)
@@ -169,6 +210,7 @@ NEVER write new files unless explicitly required by the user."""
169
210
  abs_file_path,
170
211
  input_data.content,
171
212
  getattr(context, "file_state_cache", {}),
213
+ encoding=encoding,
172
214
  )
173
215
  except (OSError, IOError, RuntimeError) as exc:
174
216
  logger.warning(
@@ -78,6 +78,35 @@ def _normalize_glob_for_grep(glob_pattern: str) -> str:
78
78
  return glob_pattern.split("/")[-1] or glob_pattern
79
79
 
80
80
 
81
+ _GREP_SUPPORTS_PCRE: Optional[bool] = None
82
+
83
+
84
+ def _grep_supports_pcre() -> bool:
85
+ """Detect if the system grep supports -P (Perl regex), caching the result."""
86
+ global _GREP_SUPPORTS_PCRE
87
+ if _GREP_SUPPORTS_PCRE is not None:
88
+ return _GREP_SUPPORTS_PCRE
89
+
90
+ if shutil.which("grep") is None:
91
+ _GREP_SUPPORTS_PCRE = False
92
+ return _GREP_SUPPORTS_PCRE
93
+
94
+ try:
95
+ proc = subprocess.run(
96
+ ["grep", "-P", ""],
97
+ stdin=subprocess.DEVNULL, # Fix: prevent waiting for stdin
98
+ stdout=subprocess.DEVNULL,
99
+ stderr=subprocess.PIPE,
100
+ check=False,
101
+ timeout=15, # Safety timeout
102
+ )
103
+ _GREP_SUPPORTS_PCRE = proc.returncode in (0, 1)
104
+ except (OSError, ValueError, subprocess.SubprocessError, subprocess.TimeoutExpired):
105
+ _GREP_SUPPORTS_PCRE = False
106
+
107
+ return _GREP_SUPPORTS_PCRE
108
+
109
+
81
110
  class GrepToolInput(BaseModel):
82
111
  """Input schema for GrepTool."""
83
112
 
@@ -234,11 +263,36 @@ class GrepTool(Tool[GrepToolInput, GrepToolOutput]):
234
263
  self, input_data: GrepToolInput, _context: ToolUseContext
235
264
  ) -> AsyncGenerator[ToolOutput, None]:
236
265
  """Search for the pattern."""
266
+ logger.debug(
267
+ "[grep_tool] call ENTER: pattern='%s' path='%s'", input_data.pattern, input_data.path
268
+ )
237
269
 
238
270
  try:
239
271
  search_path = input_data.path or "."
240
272
 
273
+ async def _run_search(command: List[str]) -> Tuple[int, str, str]:
274
+ """Execute the search command and return decoded output."""
275
+ logger.debug(
276
+ "[grep_tool] _run_search: BEFORE create_subprocess_exec, cmd=%s", command[:5]
277
+ )
278
+ process = await asyncio.create_subprocess_exec(
279
+ *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
280
+ )
281
+ logger.debug(
282
+ "[grep_tool] _run_search: AFTER create_subprocess_exec, pid=%s", process.pid
283
+ )
284
+ logger.debug("[grep_tool] _run_search: BEFORE communicate()")
285
+ stdout, stderr = await process.communicate()
286
+ logger.debug(
287
+ "[grep_tool] _run_search: AFTER communicate(), returncode=%s",
288
+ process.returncode,
289
+ )
290
+ stdout_text = stdout.decode("utf-8", errors="ignore") if stdout else ""
291
+ stderr_text = stderr.decode("utf-8", errors="ignore") if stderr else ""
292
+ return process.returncode or 0, stdout_text, stderr_text
293
+
241
294
  use_ripgrep = shutil.which("rg") is not None
295
+ logger.debug("[grep_tool] use_ripgrep=%s", use_ripgrep)
242
296
  pattern = input_data.pattern
243
297
 
244
298
  if use_ripgrep:
@@ -263,7 +317,11 @@ class GrepTool(Tool[GrepToolInput, GrepToolOutput]):
263
317
  cmd.append(search_path)
264
318
  else:
265
319
  # Fallback to grep (note: grep --include matches basenames only)
266
- cmd = ["grep", "-r", "--color=never", "-P"]
320
+ logger.debug("[grep_tool] Using grep fallback, checking PCRE support...")
321
+ use_pcre = _grep_supports_pcre()
322
+ logger.debug("[grep_tool] PCRE support check done: use_pcre=%s", use_pcre)
323
+ cmd = ["grep", "-r", "--color=never", "-P" if use_pcre else "-E"]
324
+ logger.debug("[grep_tool] Building grep command...")
267
325
 
268
326
  if input_data.case_insensitive:
269
327
  cmd.append("-i")
@@ -285,20 +343,52 @@ class GrepTool(Tool[GrepToolInput, GrepToolOutput]):
285
343
 
286
344
  cmd.append(search_path)
287
345
 
288
- # Run grep asynchronously
289
- process = await asyncio.create_subprocess_exec(
290
- *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
346
+ logger.debug("[grep_tool] BEFORE _run_search, cmd=%s", cmd)
347
+ returncode, stdout_text, stderr_text = await _run_search(cmd)
348
+ logger.debug(
349
+ "[grep_tool] AFTER _run_search, returncode=%s, stdout_len=%d",
350
+ returncode,
351
+ len(stdout_text),
291
352
  )
292
-
293
- stdout, stderr = await process.communicate()
294
- returncode = process.returncode
353
+ fallback_attempted = False
354
+
355
+ if returncode not in (0, 1):
356
+ if not use_ripgrep and "-P" in cmd:
357
+ # BSD grep lacks -P; retry with extended regex before surfacing the error.
358
+ fallback_attempted = True
359
+ cmd = [flag if flag != "-P" else "-E" for flag in cmd]
360
+ returncode, stdout_text, stderr_text = await _run_search(cmd)
361
+
362
+ if returncode not in (0, 1):
363
+ error_msg = stderr_text.strip() or f"grep exited with status {returncode}"
364
+ logger.warning(
365
+ "[grep_tool] Grep command failed",
366
+ extra={
367
+ "pattern": input_data.pattern,
368
+ "path": input_data.path,
369
+ "returncode": returncode,
370
+ "stderr": error_msg,
371
+ "fallback_to_E": fallback_attempted,
372
+ },
373
+ )
374
+ error_output = GrepToolOutput(
375
+ matches=[],
376
+ pattern=input_data.pattern,
377
+ total_files=0,
378
+ total_matches=0,
379
+ output_mode=input_data.output_mode,
380
+ head_limit=input_data.head_limit,
381
+ )
382
+ yield ToolResult(
383
+ data=error_output, result_for_assistant=f"Grep error: {error_msg}"
384
+ )
385
+ return
295
386
 
296
387
  # Parse output
297
388
  matches: List[GrepMatch] = []
298
389
  total_matches = 0
299
390
  total_files = 0
300
391
  omitted_results = 0
301
- stdout_text = stdout.decode("utf-8", errors="ignore") if stdout else ""
302
392
  lines = [line for line in stdout_text.split("\n") if line]
303
393
 
304
394
  if returncode in (0, 1): # 0 = matches found, 1 = no matches (ripgrep/grep)