code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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 (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,8 @@
1
1
  # file_operations.py
2
2
 
3
3
  import os
4
+ import shutil
5
+ import subprocess
4
6
  import tempfile
5
7
  from typing import List
6
8
 
@@ -10,15 +12,14 @@ from pydantic_ai import RunContext
10
12
  # ---------------------------------------------------------------------------
11
13
  # Module-level helper functions (exposed for unit tests _and_ used as tools)
12
14
  # ---------------------------------------------------------------------------
13
- from code_puppy.messaging import (
14
- emit_divider,
15
- emit_error,
16
- emit_info,
17
- emit_success,
18
- emit_system_message,
19
- emit_warning,
15
+ from code_puppy.messaging import ( # New structured messaging types
16
+ FileContentMessage,
17
+ FileEntry,
18
+ FileListingMessage,
19
+ GrepMatch,
20
+ GrepResultMessage,
21
+ get_message_bus,
20
22
  )
21
- from code_puppy.tools.common import generate_group_id
22
23
 
23
24
 
24
25
  # Pydantic models for tool return types
@@ -49,6 +50,7 @@ class MatchInfo(BaseModel):
49
50
 
50
51
  class GrepOutput(BaseModel):
51
52
  matches: List[MatchInfo]
53
+ error: str | None = None
52
54
 
53
55
 
54
56
  def is_likely_home_directory(directory):
@@ -107,54 +109,71 @@ def is_project_directory(directory):
107
109
  return False
108
110
 
109
111
 
112
+ def would_match_directory(pattern: str, directory: str) -> bool:
113
+ """Check if a glob pattern would match the given directory path.
114
+
115
+ This is used to avoid adding ignore patterns that would inadvertently
116
+ exclude the directory we're actually trying to search in.
117
+
118
+ Args:
119
+ pattern: A glob pattern like '**/tmp/**' or 'node_modules'
120
+ directory: The directory path to check against
121
+
122
+ Returns:
123
+ True if the pattern would match the directory, False otherwise
124
+ """
125
+ import fnmatch
126
+
127
+ # Normalize the directory path
128
+ abs_dir = os.path.abspath(directory)
129
+ dir_name = os.path.basename(abs_dir)
130
+
131
+ # Strip leading/trailing wildcards and slashes for simpler matching
132
+ clean_pattern = pattern.strip("*").strip("/")
133
+
134
+ # Check if the directory name matches the pattern
135
+ if fnmatch.fnmatch(dir_name, clean_pattern):
136
+ return True
137
+
138
+ # Check if the full path contains the pattern
139
+ if fnmatch.fnmatch(abs_dir, pattern):
140
+ return True
141
+
142
+ # Check if any part of the path matches
143
+ path_parts = abs_dir.split(os.sep)
144
+ for part in path_parts:
145
+ if fnmatch.fnmatch(part, clean_pattern):
146
+ return True
147
+
148
+ return False
149
+
150
+
110
151
  def _list_files(
111
152
  context: RunContext, directory: str = ".", recursive: bool = True
112
153
  ) -> ListFileOutput:
113
- import subprocess
114
- import shutil
115
154
  import sys
116
155
 
117
156
  results = []
118
- directory = os.path.abspath(directory)
157
+ directory = os.path.abspath(os.path.expanduser(directory))
119
158
 
120
- # Build string representation
159
+ # Plain text output for LLM consumption
121
160
  output_lines = []
122
-
123
- directory_listing_header = (
124
- "\n[bold white on blue] DIRECTORY LISTING [/bold white on blue]"
125
- )
126
- output_lines.append(directory_listing_header)
127
-
128
- directory_info = f"\U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim](recursive={recursive})[/dim]\n"
129
- output_lines.append(directory_info)
130
-
131
- divider = "[dim]" + "─" * 100 + "\n" + "[/dim]"
132
- output_lines.append(divider)
161
+ output_lines.append(f"DIRECTORY LISTING: {directory} (recursive={recursive})")
133
162
 
134
163
  if not os.path.exists(directory):
135
- error_msg = (
136
- f"[red bold]Error:[/red bold] Directory '{directory}' does not exist"
137
- )
138
- output_lines.append(error_msg)
139
-
140
- output_lines.append(divider)
141
- return ListFileOutput(content="\n".join(output_lines))
164
+ error_msg = f"Error: Directory '{directory}' does not exist"
165
+ return ListFileOutput(content=error_msg, error=error_msg)
142
166
  if not os.path.isdir(directory):
143
- error_msg = f"[red bold]Error:[/red bold] '{directory}' is not a directory"
144
- output_lines.append(error_msg)
145
-
146
- output_lines.append(divider)
147
- return ListFileOutput(content="\n".join(output_lines))
167
+ error_msg = f"Error: '{directory}' is not a directory"
168
+ return ListFileOutput(content=error_msg, error=error_msg)
148
169
 
149
170
  # Smart home directory detection - auto-limit recursion for performance
150
171
  # But allow recursion in tests (when context=None) or when explicitly requested
151
172
  if context is not None and is_likely_home_directory(directory) and recursive:
152
173
  if not is_project_directory(directory):
153
- warning_msg = "[yellow bold]Warning:[/yellow bold] 🏠 Detected home directory - limiting to non-recursive listing for performance"
154
- output_lines.append(warning_msg)
155
-
156
- info_msg = f"[dim]💡 To force recursive listing in home directory, use list_files('{directory}', recursive=True) explicitly[/dim]"
157
- output_lines.append(info_msg)
174
+ output_lines.append(
175
+ "Warning: Detected home directory - limiting to non-recursive listing for performance"
176
+ )
158
177
  recursive = False
159
178
 
160
179
  # Create a temporary ignore file with our ignore patterns
@@ -178,129 +197,133 @@ def _list_files(
178
197
  rg_path = venv_rg_exe_path
179
198
  break
180
199
 
181
- if not rg_path:
182
- error_msg = "[red bold]Error:[/red bold] ripgrep (rg) not found. Please install ripgrep to use this tool."
183
- output_lines.append(error_msg)
184
- return ListFileOutput(content="\n".join(output_lines))
185
-
186
- # Build command for ripgrep --files
187
- cmd = [rg_path, "--files"]
200
+ if not rg_path and recursive:
201
+ # Only need ripgrep for recursive listings
202
+ error_msg = "Error: ripgrep (rg) not found. Please install ripgrep to use this tool."
203
+ return ListFileOutput(content=error_msg, error=error_msg)
188
204
 
189
- # For non-recursive mode, we'll limit depth after getting results
190
- if not recursive:
191
- cmd.extend(["--max-depth", "1"])
205
+ # Only use ripgrep for recursive listings
206
+ if recursive:
207
+ # Build command for ripgrep --files
208
+ cmd = [rg_path, "--files"]
192
209
 
193
- # Add ignore patterns to the command via a temporary file
194
- from code_puppy.tools.common import IGNORE_PATTERNS
195
-
196
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".ignore") as f:
197
- ignore_file = f.name
198
- for pattern in IGNORE_PATTERNS:
199
- f.write(f"{pattern}\n")
200
-
201
- cmd.extend(["--ignore-file", ignore_file])
202
- cmd.append(directory)
203
-
204
- # Run ripgrep to get file listing
205
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
206
-
207
- # Process the output lines
208
- files = result.stdout.strip().split("\n") if result.stdout.strip() else []
209
-
210
- # Create ListedFile objects with metadata
211
- for full_path in files:
212
- if not full_path: # Skip empty lines
213
- continue
214
-
215
- # Skip if file doesn't exist (though it should)
216
- if not os.path.exists(full_path):
217
- continue
218
-
219
- # Extract relative path from the full path
220
- if full_path.startswith(directory):
221
- file_path = full_path[len(directory):].lstrip(os.sep)
222
- else:
223
- file_path = full_path
224
-
225
- # For non-recursive mode, skip files in subdirectories
226
- # Only check the relative path, not the full path
227
- if not recursive and os.sep in file_path:
228
- continue
229
-
230
- # Check if path is a file or directory
231
- if os.path.isfile(full_path):
232
- entry_type = "file"
233
- size = os.path.getsize(full_path)
234
- elif os.path.isdir(full_path):
235
- entry_type = "directory"
236
- size = 0
237
- else:
238
- # Skip if it's neither a file nor directory
239
- continue
210
+ # Add ignore patterns to the command via a temporary file
211
+ from code_puppy.tools.common import (
212
+ DIR_IGNORE_PATTERNS,
213
+ )
240
214
 
241
- try:
242
- # Get stats for the entry
243
- stat_info = os.stat(full_path)
244
- actual_size = stat_info.st_size
245
-
246
- # For files, we use the actual size; for directories, we keep size=0
247
- if entry_type == "file":
248
- size = actual_size
249
-
250
- # Calculate depth based on the relative path
251
- depth = file_path.count(os.sep)
252
-
253
- # Add directory entries if needed for files
254
- if entry_type == "file":
255
- dir_path = os.path.dirname(file_path)
256
- if dir_path:
257
- # Add directory path components if they don't exist
258
- path_parts = dir_path.split(os.sep)
259
- for i in range(len(path_parts)):
260
- partial_path = os.sep.join(path_parts[: i + 1])
261
- # Check if we already added this directory
262
- if not any(
263
- f.path == partial_path and f.type == "directory"
264
- for f in results
265
- ):
266
- results.append(
267
- ListedFile(
268
- path=partial_path,
269
- type="directory",
270
- size=0,
271
- full_path=os.path.join(directory, partial_path),
272
- depth=partial_path.count(os.sep),
215
+ with tempfile.NamedTemporaryFile(
216
+ mode="w", delete=False, suffix=".ignore"
217
+ ) as f:
218
+ ignore_file = f.name
219
+ for pattern in DIR_IGNORE_PATTERNS:
220
+ # Skip patterns that would match the search directory itself
221
+ # For example, if searching in /tmp/test-dir, skip **/tmp/**
222
+ if would_match_directory(pattern, directory):
223
+ continue
224
+ f.write(f"{pattern}\n")
225
+
226
+ cmd.extend(["--ignore-file", ignore_file])
227
+ cmd.append(directory)
228
+
229
+ # Run ripgrep to get file listing
230
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
231
+
232
+ # Process the output lines
233
+ files = result.stdout.strip().split("\n") if result.stdout.strip() else []
234
+
235
+ # Create ListedFile objects with metadata
236
+ for full_path in files:
237
+ if not full_path: # Skip empty lines
238
+ continue
239
+
240
+ # Skip if file doesn't exist (though it should)
241
+ if not os.path.exists(full_path):
242
+ continue
243
+
244
+ # Extract relative path from the full path
245
+ if full_path.startswith(directory):
246
+ file_path = full_path[len(directory) :].lstrip(os.sep)
247
+ else:
248
+ file_path = full_path
249
+
250
+ # Check if path is a file or directory
251
+ if os.path.isfile(full_path):
252
+ entry_type = "file"
253
+ size = os.path.getsize(full_path)
254
+ elif os.path.isdir(full_path):
255
+ entry_type = "directory"
256
+ size = 0
257
+ else:
258
+ # Skip if it's neither a file nor directory
259
+ continue
260
+
261
+ try:
262
+ # Get stats for the entry
263
+ stat_info = os.stat(full_path)
264
+ actual_size = stat_info.st_size
265
+
266
+ # For files, we use the actual size; for directories, we keep size=0
267
+ if entry_type == "file":
268
+ size = actual_size
269
+
270
+ # Calculate depth based on the relative path
271
+ depth = file_path.count(os.sep)
272
+
273
+ # Add directory entries if needed for files
274
+ if entry_type == "file":
275
+ dir_path = os.path.dirname(file_path)
276
+ if dir_path:
277
+ # Add directory path components if they don't exist
278
+ path_parts = dir_path.split(os.sep)
279
+ for i in range(len(path_parts)):
280
+ partial_path = os.sep.join(path_parts[: i + 1])
281
+ # Check if we already added this directory
282
+ if not any(
283
+ f.path == partial_path and f.type == "directory"
284
+ for f in results
285
+ ):
286
+ results.append(
287
+ ListedFile(
288
+ path=partial_path,
289
+ type="directory",
290
+ size=0,
291
+ full_path=os.path.join(
292
+ directory, partial_path
293
+ ),
294
+ depth=partial_path.count(os.sep),
295
+ )
273
296
  )
274
- )
275
-
276
- # Add the entry (file or directory)
277
- results.append(
278
- ListedFile(
279
- path=file_path,
280
- type=entry_type,
281
- size=size,
282
- full_path=full_path,
283
- depth=depth,
297
+
298
+ # Add the entry (file or directory)
299
+ results.append(
300
+ ListedFile(
301
+ path=file_path,
302
+ type=entry_type,
303
+ size=size,
304
+ full_path=full_path,
305
+ depth=depth,
306
+ )
284
307
  )
285
- )
286
- except (FileNotFoundError, PermissionError, OSError):
287
- # Skip files we can't access
288
- continue
308
+ except (FileNotFoundError, PermissionError, OSError):
309
+ # Skip files we can't access
310
+ continue
289
311
 
290
- # In non-recursive mode, we also need to explicitly list directories in the target directory
291
- # ripgrep's --files option only returns files, not directories
312
+ # In non-recursive mode, we also need to explicitly list immediate entries
313
+ # ripgrep's --files option only returns files; we add directories and files ourselves
292
314
  if not recursive:
293
315
  try:
294
316
  entries = os.listdir(directory)
295
- for entry in entries:
317
+ for entry in sorted(entries):
296
318
  full_entry_path = os.path.join(directory, entry)
297
- # Skip if it doesn't exist or if it's a file (since files are already listed by ripgrep)
298
- if not os.path.exists(full_entry_path) or os.path.isfile(full_entry_path):
319
+ if not os.path.exists(full_entry_path):
299
320
  continue
300
-
301
- # For non-recursive mode, only include directories that are directly in the target directory
321
+
302
322
  if os.path.isdir(full_entry_path):
303
- # Create a ListedFile for the directory
323
+ # In non-recursive mode, only skip obviously system/hidden directories
324
+ # Don't use the full should_ignore_dir_path which is too aggressive
325
+ if entry.startswith("."):
326
+ continue
304
327
  results.append(
305
328
  ListedFile(
306
329
  path=entry,
@@ -310,21 +333,30 @@ def _list_files(
310
333
  depth=0,
311
334
  )
312
335
  )
336
+ elif os.path.isfile(full_entry_path):
337
+ # Include top-level files (including binaries)
338
+ try:
339
+ size = os.path.getsize(full_entry_path)
340
+ except OSError:
341
+ size = 0
342
+ results.append(
343
+ ListedFile(
344
+ path=entry,
345
+ type="file",
346
+ size=size,
347
+ full_path=full_entry_path,
348
+ depth=0,
349
+ )
350
+ )
313
351
  except (FileNotFoundError, PermissionError, OSError):
314
- # Skip directories we can't access
352
+ # Skip entries we can't access
315
353
  pass
316
354
  except subprocess.TimeoutExpired:
317
- error_msg = (
318
- "[red bold]Error:[/red bold] List files command timed out after 30 seconds"
319
- )
320
- output_lines.append(error_msg)
321
- return ListFileOutput(content="\n".join(output_lines))
355
+ error_msg = "Error: List files command timed out after 30 seconds"
356
+ return ListFileOutput(content=error_msg, error=error_msg)
322
357
  except Exception as e:
323
- error_msg = (
324
- f"[red bold]Error:[/red bold] Error during list files operation: {e}"
325
- )
326
- output_lines.append(error_msg)
327
- return ListFileOutput(content="\n".join(output_lines))
358
+ error_msg = f"Error: Error during list files operation: {e}"
359
+ return ListFileOutput(content=error_msg, error=error_msg)
328
360
  finally:
329
361
  # Clean up the temporary ignore file
330
362
  if ignore_file and os.path.exists(ignore_file):
@@ -373,62 +405,49 @@ def _list_files(
373
405
  dir_count = sum(1 for item in results if item.type == "directory")
374
406
  file_count = sum(1 for item in results if item.type == "file")
375
407
  total_size = sum(item.size for item in results if item.type == "file")
376
-
377
-
378
-
379
- # Build the directory header section
380
- dir_name = os.path.basename(directory) or directory
381
- dir_header = f"\U0001f4c1 [bold blue]{dir_name}[/bold blue]"
382
- output_lines.append(dir_header)
383
-
384
- # Sort all items by path for consistent display
385
- all_items = sorted(results, key=lambda x: x.path)
386
408
 
387
- # Build file and directory tree representation
388
- parent_dirs_with_content = set()
389
- for item in all_items:
390
- # Skip root directory entries with no path
409
+ # Build structured FileEntry objects for the UI
410
+ file_entries = []
411
+ for item in sorted(results, key=lambda x: x.path):
391
412
  if item.type == "directory" and not item.path:
392
413
  continue
414
+ file_entries.append(
415
+ FileEntry(
416
+ path=item.path,
417
+ type="dir" if item.type == "directory" else "file",
418
+ size=item.size,
419
+ depth=item.depth or 0,
420
+ )
421
+ )
393
422
 
394
- # Track parent directories that contain files/dirs
395
- if os.sep in item.path:
396
- parent_path = os.path.dirname(item.path)
397
- parent_dirs_with_content.add(parent_path)
398
-
399
- # Calculate indentation depth based on path separators
400
- depth = item.path.count(os.sep) + 1 if item.path else 0
401
- prefix = ""
402
- for d in range(depth):
403
- if d == depth - 1:
404
- prefix += "\u2514\u2500\u2500 "
405
- else:
406
- prefix += " "
423
+ # Emit structured message for the UI
424
+ file_listing_msg = FileListingMessage(
425
+ directory=directory,
426
+ files=file_entries,
427
+ recursive=recursive,
428
+ total_size=total_size,
429
+ dir_count=dir_count,
430
+ file_count=file_count,
431
+ )
432
+ get_message_bus().emit(file_listing_msg)
407
433
 
408
- # Get the display name (basename) of the item
434
+ # Build plain text output for LLM consumption
435
+ for item in sorted(results, key=lambda x: x.path):
436
+ if item.type == "directory" and not item.path:
437
+ continue
409
438
  name = os.path.basename(item.path) or item.path
410
-
411
- # Add directory or file line with appropriate formatting
439
+ indent = " " * (item.depth or 0)
412
440
  if item.type == "directory":
413
- dir_line = f"{prefix}\U0001f4c1 [bold blue]{name}/[/bold blue]"
414
- output_lines.append(dir_line)
441
+ output_lines.append(f"{indent}{name}/")
415
442
  else:
416
- icon = get_file_icon(item.path)
417
443
  size_str = format_size(item.size)
418
- file_line = f"{prefix}{icon} [green]{name}[/green] [dim]({size_str})[/dim]"
419
- output_lines.append(file_line)
444
+ output_lines.append(f"{indent}{name} ({size_str})")
420
445
 
421
- # Add summary information
422
- summary_header = "\n[bold cyan]Summary:[/bold cyan]"
423
- output_lines.append(summary_header)
424
-
425
- summary_line = f"\U0001f4c1 [blue]{dir_count} directories[/blue], \U0001f4c4 [green]{file_count} files[/green] [dim]({format_size(total_size)} total)[/dim]"
426
- output_lines.append(summary_line)
427
-
428
- final_divider = "[dim]" + "─" * 100 + "\n" + "[/dim]"
429
- output_lines.append(final_divider)
446
+ # Add summary
447
+ output_lines.append(
448
+ f"\nSummary: {dir_count} directories, {file_count} files ({format_size(total_size)} total)"
449
+ )
430
450
 
431
- # Return the content string
432
451
  return ListFileOutput(content="\n".join(output_lines))
433
452
 
434
453
 
@@ -438,18 +457,8 @@ def _read_file(
438
457
  start_line: int | None = None,
439
458
  num_lines: int | None = None,
440
459
  ) -> ReadFileOutput:
441
- file_path = os.path.abspath(file_path)
442
-
443
- # Generate group_id for this tool execution
444
- group_id = generate_group_id("read_file", file_path)
460
+ file_path = os.path.abspath(os.path.expanduser(file_path))
445
461
 
446
- # Build console message with optional parameters
447
- console_msg = f"\n[bold white on blue] READ FILE [/bold white on blue] \U0001f4c2 [bold cyan]{file_path}[/bold cyan]"
448
- if start_line is not None and num_lines is not None:
449
- console_msg += f" [dim](lines {start_line}-{start_line + num_lines - 1})[/dim]"
450
- emit_info(console_msg, message_group=group_id)
451
-
452
- emit_divider(message_group=group_id)
453
462
  if not os.path.exists(file_path):
454
463
  error_msg = f"File {file_path} does not exist"
455
464
  return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
@@ -457,12 +466,15 @@ def _read_file(
457
466
  error_msg = f"{file_path} is not a file"
458
467
  return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
459
468
  try:
460
- with open(file_path, "r", encoding="utf-8") as f:
469
+ # Use errors="surrogateescape" to handle files with invalid UTF-8 sequences
470
+ # This is common on Windows when files contain emojis or were created by
471
+ # applications that don't properly encode Unicode
472
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
461
473
  if start_line is not None and num_lines is not None:
462
474
  # Read only the specified lines
463
475
  lines = f.readlines()
464
- # Adjust for 1-based line numbering
465
- start_idx = start_line - 1
476
+ # Adjust for 1-based line numbering and handle negative values
477
+ start_idx = start_line - 1 if start_line > 0 else 0
466
478
  end_idx = start_idx + num_lines
467
479
  # Ensure indices are within bounds
468
480
  start_idx = max(0, start_idx)
@@ -472,6 +484,21 @@ def _read_file(
472
484
  # Read the entire file
473
485
  content = f.read()
474
486
 
487
+ # Sanitize the content to remove any surrogate characters that could
488
+ # cause issues when the content is later serialized or displayed
489
+ # This re-encodes with surrogatepass then decodes with replace to
490
+ # convert lone surrogates to replacement characters
491
+ try:
492
+ content = content.encode("utf-8", errors="surrogatepass").decode(
493
+ "utf-8", errors="replace"
494
+ )
495
+ except (UnicodeEncodeError, UnicodeDecodeError):
496
+ # If that fails, do a more aggressive cleanup
497
+ content = "".join(
498
+ char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
499
+ for char in content
500
+ )
501
+
475
502
  # Simple approximation: ~4 characters per token
476
503
  num_tokens = len(content) // 4
477
504
  if num_tokens > 10000:
@@ -480,6 +507,30 @@ def _read_file(
480
507
  error="The file is massive, greater than 10,000 tokens which is dangerous to read entirely. Please read this file in chunks.",
481
508
  num_tokens=0,
482
509
  )
510
+
511
+ # Count total lines for the message
512
+ total_lines = content.count("\n") + (
513
+ 1 if content and not content.endswith("\n") else 0
514
+ )
515
+
516
+ # Emit structured message for the UI
517
+ # Only include start_line/num_lines if they are valid positive integers
518
+ emit_start_line = (
519
+ start_line if start_line is not None and start_line >= 1 else None
520
+ )
521
+ emit_num_lines = (
522
+ num_lines if num_lines is not None and num_lines >= 1 else None
523
+ )
524
+ file_content_msg = FileContentMessage(
525
+ path=file_path,
526
+ content=content,
527
+ start_line=emit_start_line,
528
+ num_lines=emit_num_lines,
529
+ total_lines=total_lines,
530
+ num_tokens=num_tokens,
531
+ )
532
+ get_message_bus().emit(file_content_msg)
533
+
483
534
  return ReadFileOutput(content=content, num_tokens=num_tokens)
484
535
  except (FileNotFoundError, PermissionError):
485
536
  # For backward compatibility with tests, return "FILE NOT FOUND" for these specific errors
@@ -490,24 +541,46 @@ def _read_file(
490
541
  return ReadFileOutput(content=message, num_tokens=0, error=message)
491
542
 
492
543
 
544
+ def _sanitize_string(text: str) -> str:
545
+ """Sanitize a string to remove invalid Unicode surrogates.
546
+
547
+ This handles encoding issues common on Windows with copy-paste operations.
548
+ """
549
+ if not text:
550
+ return text
551
+ try:
552
+ # Try encoding - if it works, string is clean
553
+ text.encode("utf-8")
554
+ return text
555
+ except UnicodeEncodeError:
556
+ pass
557
+
558
+ try:
559
+ # Encode allowing surrogates, then decode replacing them
560
+ return text.encode("utf-8", errors="surrogatepass").decode(
561
+ "utf-8", errors="replace"
562
+ )
563
+ except (UnicodeEncodeError, UnicodeDecodeError):
564
+ # Last resort: filter out surrogate characters
565
+ return "".join(
566
+ char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
567
+ for char in text
568
+ )
569
+
570
+
493
571
  def _grep(context: RunContext, search_string: str, directory: str = ".") -> GrepOutput:
494
- import subprocess
495
572
  import json
496
573
  import os
497
574
  import shutil
575
+ import subprocess
498
576
  import sys
499
577
 
500
- directory = os.path.abspath(directory)
501
- matches: List[MatchInfo] = []
502
-
503
- # Generate group_id for this tool execution
504
- group_id = generate_group_id("grep", f"{directory}_{search_string}")
578
+ # Sanitize search string to handle any surrogates from copy-paste
579
+ search_string = _sanitize_string(search_string)
505
580
 
506
- emit_info(
507
- f"\n[bold white on blue] GREP [/bold white on blue] \U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim]for '{search_string}'[/dim]",
508
- message_group=group_id,
509
- )
510
- emit_divider(message_group=group_id)
581
+ directory = os.path.abspath(os.path.expanduser(directory))
582
+ matches: List[MatchInfo] = []
583
+ error_message: str | None = None
511
584
 
512
585
  # Create a temporary ignore file with our ignore patterns
513
586
  ignore_file = None
@@ -539,11 +612,10 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
539
612
  break
540
613
 
541
614
  if not rg_path:
542
- emit_error(
543
- "ripgrep (rg) not found. Please install ripgrep to use this tool.",
544
- message_group=group_id,
615
+ error_message = (
616
+ "ripgrep (rg) not found. Please install ripgrep to use this tool."
545
617
  )
546
- return GrepOutput(matches=[])
618
+ return GrepOutput(matches=[], error=error_message)
547
619
 
548
620
  cmd = [
549
621
  rg_path,
@@ -556,16 +628,24 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
556
628
  ]
557
629
 
558
630
  # Add ignore patterns to the command via a temporary file
559
- from code_puppy.tools.common import IGNORE_PATTERNS
631
+ from code_puppy.tools.common import DIR_IGNORE_PATTERNS
560
632
 
561
633
  with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".ignore") as f:
562
634
  ignore_file = f.name
563
- for pattern in IGNORE_PATTERNS:
635
+ for pattern in DIR_IGNORE_PATTERNS:
564
636
  f.write(f"{pattern}\n")
565
637
 
566
638
  cmd.extend(["--ignore-file", ignore_file])
567
639
  cmd.extend([search_string, directory])
568
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
640
+ # Use encoding with error handling to handle files with invalid UTF-8
641
+ result = subprocess.run(
642
+ cmd,
643
+ capture_output=True,
644
+ text=True,
645
+ timeout=30,
646
+ encoding="utf-8",
647
+ errors="replace", # Replace invalid chars instead of crashing
648
+ )
569
649
 
570
650
  # Parse the JSON output from ripgrep
571
651
  for line in result.stdout.strip().split("\n"):
@@ -589,49 +669,57 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
589
669
  if len(line_content.strip()) > 512:
590
670
  line_content = line_content.strip()[0:512]
591
671
  if file_path and line_number:
672
+ # Sanitize content to handle any remaining encoding issues
592
673
  match_info = MatchInfo(
593
- file_path=file_path,
674
+ file_path=_sanitize_string(file_path),
594
675
  line_number=line_number,
595
- line_content=line_content.strip(),
676
+ line_content=_sanitize_string(line_content.strip()),
596
677
  )
597
678
  matches.append(match_info)
598
679
  # Limit to 50 matches total, same as original implementation
599
680
  if len(matches) >= 50:
600
681
  break
601
- emit_system_message(
602
- f"[green]Match:[/green] {file_path}:{line_number} - {line_content.strip()}",
603
- message_group=group_id,
604
- )
605
682
  except json.JSONDecodeError:
606
683
  # Skip lines that aren't valid JSON
607
684
  continue
608
685
 
609
- if not matches:
610
- emit_warning(
611
- f"No matches found for '{search_string}' in {directory}",
612
- message_group=group_id,
613
- )
614
- else:
615
- emit_success(
616
- f"Found {len(matches)} match(es) for '{search_string}' in {directory}",
617
- message_group=group_id,
618
- )
619
-
620
686
  except subprocess.TimeoutExpired:
621
- emit_error("Grep command timed out after 30 seconds", message_group=group_id)
687
+ error_message = "Grep command timed out after 30 seconds"
622
688
  except FileNotFoundError:
623
- emit_error(
624
- "ripgrep (rg) not found. Please install ripgrep to use this tool.",
625
- message_group=group_id,
689
+ error_message = (
690
+ "ripgrep (rg) not found. Please install ripgrep to use this tool."
626
691
  )
627
692
  except Exception as e:
628
- emit_error(f"Error during grep operation: {e}", message_group=group_id)
693
+ error_message = f"Error during grep operation: {e}"
629
694
  finally:
630
695
  # Clean up the temporary ignore file
631
696
  if ignore_file and os.path.exists(ignore_file):
632
697
  os.unlink(ignore_file)
633
698
 
634
- return GrepOutput(matches=matches)
699
+ # Build structured GrepMatch objects for the UI
700
+ grep_matches = [
701
+ GrepMatch(
702
+ file_path=m.file_path or "",
703
+ line_number=m.line_number or 1,
704
+ line_content=m.line_content or "",
705
+ )
706
+ for m in matches
707
+ ]
708
+
709
+ # Count unique files searched (approximation based on matches)
710
+ unique_files = len(set(m.file_path for m in matches)) if matches else 0
711
+
712
+ # Emit structured message for the UI (only once, at the end)
713
+ grep_result_msg = GrepResultMessage(
714
+ search_term=search_string,
715
+ directory=directory,
716
+ matches=grep_matches,
717
+ total_matches=len(matches),
718
+ files_searched=unique_files,
719
+ )
720
+ get_message_bus().emit(grep_result_msg)
721
+
722
+ return GrepOutput(matches=matches, error=error_message)
635
723
 
636
724
 
637
725
  def register_list_files(agent):
@@ -692,10 +780,8 @@ def register_list_files(agent):
692
780
  recursive = False
693
781
  result = _list_files(context, directory, recursive)
694
782
 
695
- # Emit the content directly to ensure it's displayed to the user
696
- emit_info(
697
- result.content, message_group=generate_group_id("list_files", directory)
698
- )
783
+ # The structured FileListingMessage is already emitted by _list_files
784
+ # No need to emit again here
699
785
  if warning:
700
786
  result.error = warning
701
787
  if (len(result.content)) > 200000: