code-puppy 0.0.214__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 (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  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 +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.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 shutil
114
- import subprocess
115
154
  import sys
116
155
 
117
156
  results = []
118
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,10 +197,10 @@ 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))
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)
185
204
 
186
205
  # Only use ripgrep for recursive listings
187
206
  if recursive:
@@ -198,6 +217,10 @@ def _list_files(
198
217
  ) as f:
199
218
  ignore_file = f.name
200
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
201
224
  f.write(f"{pattern}\n")
202
225
 
203
226
  cmd.extend(["--ignore-file", ignore_file])
@@ -290,8 +313,6 @@ def _list_files(
290
313
  # ripgrep's --files option only returns files; we add directories and files ourselves
291
314
  if not recursive:
292
315
  try:
293
- from code_puppy.tools.common import should_ignore_dir_path
294
-
295
316
  entries = os.listdir(directory)
296
317
  for entry in sorted(entries):
297
318
  full_entry_path = os.path.join(directory, entry)
@@ -299,8 +320,9 @@ def _list_files(
299
320
  continue
300
321
 
301
322
  if os.path.isdir(full_entry_path):
302
- # Skip ignored directories
303
- if should_ignore_dir_path(full_entry_path):
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("."):
304
326
  continue
305
327
  results.append(
306
328
  ListedFile(
@@ -330,17 +352,11 @@ def _list_files(
330
352
  # Skip entries we can't access
331
353
  pass
332
354
  except subprocess.TimeoutExpired:
333
- error_msg = (
334
- "[red bold]Error:[/red bold] List files command timed out after 30 seconds"
335
- )
336
- output_lines.append(error_msg)
337
- 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)
338
357
  except Exception as e:
339
- error_msg = (
340
- f"[red bold]Error:[/red bold] Error during list files operation: {e}"
341
- )
342
- output_lines.append(error_msg)
343
- 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)
344
360
  finally:
345
361
  # Clean up the temporary ignore file
346
362
  if ignore_file and os.path.exists(ignore_file):
@@ -390,59 +406,48 @@ def _list_files(
390
406
  file_count = sum(1 for item in results if item.type == "file")
391
407
  total_size = sum(item.size for item in results if item.type == "file")
392
408
 
393
- # Build the directory header section
394
- dir_name = os.path.basename(directory) or directory
395
- dir_header = f"\U0001f4c1 [bold blue]{dir_name}[/bold blue]"
396
- output_lines.append(dir_header)
397
-
398
- # Sort all items by path for consistent display
399
- all_items = sorted(results, key=lambda x: x.path)
400
-
401
- # Build file and directory tree representation
402
- parent_dirs_with_content = set()
403
- for item in all_items:
404
- # 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):
405
412
  if item.type == "directory" and not item.path:
406
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
+ )
407
422
 
408
- # Track parent directories that contain files/dirs
409
- if os.sep in item.path:
410
- parent_path = os.path.dirname(item.path)
411
- parent_dirs_with_content.add(parent_path)
412
-
413
- # Calculate indentation depth based on path separators
414
- depth = item.path.count(os.sep) + 1 if item.path else 0
415
- prefix = ""
416
- for d in range(depth):
417
- if d == depth - 1:
418
- prefix += "\u2514\u2500\u2500 "
419
- else:
420
- 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)
421
433
 
422
- # 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
423
438
  name = os.path.basename(item.path) or item.path
424
-
425
- # Add directory or file line with appropriate formatting
439
+ indent = " " * (item.depth or 0)
426
440
  if item.type == "directory":
427
- dir_line = f"{prefix}\U0001f4c1 [bold blue]{name}/[/bold blue]"
428
- output_lines.append(dir_line)
441
+ output_lines.append(f"{indent}{name}/")
429
442
  else:
430
- icon = get_file_icon(item.path)
431
443
  size_str = format_size(item.size)
432
- file_line = f"{prefix}{icon} [green]{name}[/green] [dim]({size_str})[/dim]"
433
- output_lines.append(file_line)
434
-
435
- # Add summary information
436
- summary_header = "\n[bold cyan]Summary:[/bold cyan]"
437
- output_lines.append(summary_header)
438
-
439
- summary_line = f"\U0001f4c1 [blue]{dir_count} directories[/blue], \U0001f4c4 [green]{file_count} files[/green] [dim]({format_size(total_size)} total)[/dim]"
440
- output_lines.append(summary_line)
444
+ output_lines.append(f"{indent}{name} ({size_str})")
441
445
 
442
- final_divider = "[dim]" + "─" * 100 + "\n" + "[/dim]"
443
- 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
+ )
444
450
 
445
- # Return the content string
446
451
  return ListFileOutput(content="\n".join(output_lines))
447
452
 
448
453
 
@@ -454,16 +459,6 @@ def _read_file(
454
459
  ) -> ReadFileOutput:
455
460
  file_path = os.path.abspath(os.path.expanduser(file_path))
456
461
 
457
- # Generate group_id for this tool execution
458
- group_id = generate_group_id("read_file", file_path)
459
-
460
- # Build console message with optional parameters
461
- console_msg = f"\n[bold white on blue] READ FILE [/bold white on blue] \U0001f4c2 [bold cyan]{file_path}[/bold cyan]"
462
- if start_line is not None and num_lines is not None:
463
- console_msg += f" [dim](lines {start_line}-{start_line + num_lines - 1})[/dim]"
464
- emit_info(console_msg, message_group=group_id)
465
-
466
- emit_divider(message_group=group_id)
467
462
  if not os.path.exists(file_path):
468
463
  error_msg = f"File {file_path} does not exist"
469
464
  return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
@@ -471,12 +466,15 @@ def _read_file(
471
466
  error_msg = f"{file_path} is not a file"
472
467
  return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
473
468
  try:
474
- 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:
475
473
  if start_line is not None and num_lines is not None:
476
474
  # Read only the specified lines
477
475
  lines = f.readlines()
478
- # Adjust for 1-based line numbering
479
- 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
480
478
  end_idx = start_idx + num_lines
481
479
  # Ensure indices are within bounds
482
480
  start_idx = max(0, start_idx)
@@ -486,6 +484,21 @@ def _read_file(
486
484
  # Read the entire file
487
485
  content = f.read()
488
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
+
489
502
  # Simple approximation: ~4 characters per token
490
503
  num_tokens = len(content) // 4
491
504
  if num_tokens > 10000:
@@ -494,6 +507,30 @@ def _read_file(
494
507
  error="The file is massive, greater than 10,000 tokens which is dangerous to read entirely. Please read this file in chunks.",
495
508
  num_tokens=0,
496
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
+
497
534
  return ReadFileOutput(content=content, num_tokens=num_tokens)
498
535
  except (FileNotFoundError, PermissionError):
499
536
  # For backward compatibility with tests, return "FILE NOT FOUND" for these specific errors
@@ -504,6 +541,33 @@ def _read_file(
504
541
  return ReadFileOutput(content=message, num_tokens=0, error=message)
505
542
 
506
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
+
507
571
  def _grep(context: RunContext, search_string: str, directory: str = ".") -> GrepOutput:
508
572
  import json
509
573
  import os
@@ -511,17 +575,12 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
511
575
  import subprocess
512
576
  import sys
513
577
 
578
+ # Sanitize search string to handle any surrogates from copy-paste
579
+ search_string = _sanitize_string(search_string)
580
+
514
581
  directory = os.path.abspath(os.path.expanduser(directory))
515
582
  matches: List[MatchInfo] = []
516
-
517
- # Generate group_id for this tool execution
518
- group_id = generate_group_id("grep", f"{directory}_{search_string}")
519
-
520
- emit_info(
521
- f"\n[bold white on blue] GREP [/bold white on blue] \U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim]for '{search_string}'[/dim]",
522
- message_group=group_id,
523
- )
524
- emit_divider(message_group=group_id)
583
+ error_message: str | None = None
525
584
 
526
585
  # Create a temporary ignore file with our ignore patterns
527
586
  ignore_file = None
@@ -553,11 +612,10 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
553
612
  break
554
613
 
555
614
  if not rg_path:
556
- emit_error(
557
- "ripgrep (rg) not found. Please install ripgrep to use this tool.",
558
- message_group=group_id,
615
+ error_message = (
616
+ "ripgrep (rg) not found. Please install ripgrep to use this tool."
559
617
  )
560
- return GrepOutput(matches=[])
618
+ return GrepOutput(matches=[], error=error_message)
561
619
 
562
620
  cmd = [
563
621
  rg_path,
@@ -579,7 +637,15 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
579
637
 
580
638
  cmd.extend(["--ignore-file", ignore_file])
581
639
  cmd.extend([search_string, directory])
582
- 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
+ )
583
649
 
584
650
  # Parse the JSON output from ripgrep
585
651
  for line in result.stdout.strip().split("\n"):
@@ -603,49 +669,57 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
603
669
  if len(line_content.strip()) > 512:
604
670
  line_content = line_content.strip()[0:512]
605
671
  if file_path and line_number:
672
+ # Sanitize content to handle any remaining encoding issues
606
673
  match_info = MatchInfo(
607
- file_path=file_path,
674
+ file_path=_sanitize_string(file_path),
608
675
  line_number=line_number,
609
- line_content=line_content.strip(),
676
+ line_content=_sanitize_string(line_content.strip()),
610
677
  )
611
678
  matches.append(match_info)
612
679
  # Limit to 50 matches total, same as original implementation
613
680
  if len(matches) >= 50:
614
681
  break
615
- emit_system_message(
616
- f"[green]Match:[/green] {file_path}:{line_number} - {line_content.strip()}",
617
- message_group=group_id,
618
- )
619
682
  except json.JSONDecodeError:
620
683
  # Skip lines that aren't valid JSON
621
684
  continue
622
685
 
623
- if not matches:
624
- emit_warning(
625
- f"No matches found for '{search_string}' in {directory}",
626
- message_group=group_id,
627
- )
628
- else:
629
- emit_success(
630
- f"Found {len(matches)} match(es) for '{search_string}' in {directory}",
631
- message_group=group_id,
632
- )
633
-
634
686
  except subprocess.TimeoutExpired:
635
- emit_error("Grep command timed out after 30 seconds", message_group=group_id)
687
+ error_message = "Grep command timed out after 30 seconds"
636
688
  except FileNotFoundError:
637
- emit_error(
638
- "ripgrep (rg) not found. Please install ripgrep to use this tool.",
639
- message_group=group_id,
689
+ error_message = (
690
+ "ripgrep (rg) not found. Please install ripgrep to use this tool."
640
691
  )
641
692
  except Exception as e:
642
- emit_error(f"Error during grep operation: {e}", message_group=group_id)
693
+ error_message = f"Error during grep operation: {e}"
643
694
  finally:
644
695
  # Clean up the temporary ignore file
645
696
  if ignore_file and os.path.exists(ignore_file):
646
697
  os.unlink(ignore_file)
647
698
 
648
- 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)
649
723
 
650
724
 
651
725
  def register_list_files(agent):
@@ -706,10 +780,8 @@ def register_list_files(agent):
706
780
  recursive = False
707
781
  result = _list_files(context, directory, recursive)
708
782
 
709
- # Emit the content directly to ensure it's displayed to the user
710
- emit_info(
711
- result.content, message_group=generate_group_id("list_files", directory)
712
- )
783
+ # The structured FileListingMessage is already emitted by _list_files
784
+ # No need to emit again here
713
785
  if warning:
714
786
  result.error = warning
715
787
  if (len(result.content)) > 200000: