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
@@ -0,0 +1,84 @@
1
+ """Common display utilities for rendering agent outputs.
2
+
3
+ This module provides non-streaming display functions for rendering
4
+ agent results and other structured content using termflow for markdown.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ from rich.console import Console
10
+
11
+ from code_puppy.config import get_banner_color, get_subagent_verbose
12
+ from code_puppy.tools.subagent_context import is_subagent
13
+
14
+
15
+ def display_non_streamed_result(
16
+ content: str,
17
+ console: Optional[Console] = None,
18
+ banner_text: str = "AGENT RESPONSE",
19
+ banner_name: str = "agent_response",
20
+ ) -> None:
21
+ """Display a non-streamed result with markdown rendering via termflow.
22
+
23
+ This function renders markdown content using termflow for beautiful
24
+ terminal output. Use this instead of streaming for sub-agent responses
25
+ or any other content that arrives all at once.
26
+
27
+ Args:
28
+ content: The content to display (can include markdown).
29
+ console: Rich Console to use for output. If None, creates a new one.
30
+ banner_text: Text to display in the banner (default: "AGENT RESPONSE").
31
+ banner_name: Banner config key for color lookup (default: "agent_response").
32
+
33
+ Example:
34
+ >>> display_non_streamed_result("# Hello\n\nThis is **bold** text.")
35
+ # Renders with AGENT RESPONSE banner and formatted markdown
36
+ """
37
+ # Skip display for sub-agents unless verbose mode
38
+ if is_subagent() and not get_subagent_verbose():
39
+ return
40
+
41
+ import time
42
+
43
+ from rich.text import Text
44
+ from termflow import Parser as TermflowParser
45
+ from termflow import Renderer as TermflowRenderer
46
+
47
+ from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners
48
+
49
+ if console is None:
50
+ console = Console()
51
+
52
+ # Pause spinners and give time to clear
53
+ pause_all_spinners()
54
+ time.sleep(0.1)
55
+
56
+ # Clear line and print banner
57
+ console.print(" " * 50, end="\r")
58
+ console.print() # Newline before banner
59
+
60
+ banner_color = get_banner_color(banner_name)
61
+ console.print(
62
+ Text.from_markup(
63
+ f"[bold white on {banner_color}] {banner_text} [/bold white on {banner_color}]"
64
+ )
65
+ )
66
+
67
+ # Use termflow for markdown rendering
68
+ parser = TermflowParser()
69
+ renderer = TermflowRenderer(output=console.file, width=console.width)
70
+
71
+ # Process content line by line
72
+ for line in content.split("\n"):
73
+ events = parser.parse_line(line)
74
+ renderer.render_all(events)
75
+
76
+ # Finalize to close any open markdown blocks
77
+ final_events = parser.finalize()
78
+ renderer.render_all(final_events)
79
+
80
+ # Resume spinners
81
+ resume_all_spinners()
82
+
83
+
84
+ __all__ = ["display_non_streamed_result"]
@@ -20,10 +20,58 @@ import json_repair
20
20
  from pydantic import BaseModel
21
21
  from pydantic_ai import RunContext
22
22
 
23
- from code_puppy.messaging import emit_error, emit_info, emit_warning
23
+ from code_puppy.callbacks import on_delete_file, on_edit_file
24
+ from code_puppy.messaging import ( # Structured messaging types
25
+ DiffLine,
26
+ DiffMessage,
27
+ emit_error,
28
+ emit_warning,
29
+ get_message_bus,
30
+ )
24
31
  from code_puppy.tools.common import _find_best_window, generate_group_id
25
32
 
26
33
 
34
+ def _create_rejection_response(file_path: str) -> Dict[str, Any]:
35
+ """Create a standardized rejection response with user feedback if available.
36
+
37
+ Args:
38
+ file_path: Path to the file that was rejected
39
+
40
+ Returns:
41
+ Dict containing rejection details and any user feedback
42
+ """
43
+ # Check for user feedback from permission handler
44
+ try:
45
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
46
+ clear_user_feedback,
47
+ get_last_user_feedback,
48
+ )
49
+
50
+ user_feedback = get_last_user_feedback()
51
+ # Clear feedback after reading it
52
+ clear_user_feedback()
53
+ except ImportError:
54
+ user_feedback = None
55
+
56
+ rejection_message = (
57
+ "USER REJECTED: The user explicitly rejected these file changes."
58
+ )
59
+ if user_feedback:
60
+ rejection_message += f" User feedback: {user_feedback}"
61
+ else:
62
+ rejection_message += " Please do not retry the same changes or any other changes - immediately ask for clarification."
63
+
64
+ return {
65
+ "success": False,
66
+ "path": file_path,
67
+ "message": rejection_message,
68
+ "changed": False,
69
+ "user_rejection": True,
70
+ "rejection_type": "explicit_user_denial",
71
+ "user_feedback": user_feedback,
72
+ }
73
+
74
+
27
75
  class DeleteSnippetPayload(BaseModel):
28
76
  file_path: str
29
77
  delete_snippet: str
@@ -48,57 +96,110 @@ class ContentPayload(BaseModel):
48
96
  EditFilePayload = Union[DeleteSnippetPayload, ReplacementsPayload, ContentPayload]
49
97
 
50
98
 
51
- def _print_diff(diff_text: str, message_group: str = None) -> None:
52
- """Pretty-print *diff_text* with colour-coding (always runs)."""
99
+ def _parse_diff_lines(diff_text: str) -> List[DiffLine]:
100
+ """Parse unified diff text into structured DiffLine objects.
53
101
 
54
- emit_info(
55
- "[bold cyan]\n── DIFF ────────────────────────────────────────────────[/bold cyan]",
56
- message_group=message_group,
57
- )
58
- if diff_text and diff_text.strip():
59
- for line in diff_text.splitlines():
60
- # Git-style diff coloring using markup strings for TUI compatibility
61
- if line.startswith("+") and not line.startswith("+++"):
62
- # Addition line - use markup string instead of Rich Text
63
- emit_info(
64
- f"[bold green]{line}[/bold green]",
65
- highlight=False,
66
- message_group=message_group,
67
- )
68
- elif line.startswith("-") and not line.startswith("---"):
69
- # Removal line - use markup string instead of Rich Text
70
- emit_info(
71
- f"[bold red]{line}[/bold red]",
72
- highlight=False,
73
- message_group=message_group,
74
- )
75
- elif line.startswith("@@"):
76
- # Hunk info - use markup string instead of Rich Text
77
- emit_info(
78
- f"[bold cyan]{line}[/bold cyan]",
79
- highlight=False,
80
- message_group=message_group,
81
- )
82
- elif line.startswith("+++") or line.startswith("---"):
83
- # Filename lines in diff - use markup string instead of Rich Text
84
- emit_info(
85
- f"[dim white]{line}[/dim white]",
86
- highlight=False,
87
- message_group=message_group,
88
- )
89
- else:
90
- # Context lines - no special formatting
91
- emit_info(line, highlight=False, message_group=message_group)
92
- else:
93
- emit_info("[dim]-- no diff available --[/dim]", message_group=message_group)
94
- emit_info(
95
- "[bold cyan]───────────────────────────────────────────────────────[/bold cyan]",
96
- message_group=message_group,
102
+ Args:
103
+ diff_text: Raw unified diff text
104
+
105
+ Returns:
106
+ List of DiffLine objects with line numbers and types
107
+ """
108
+ if not diff_text or not diff_text.strip():
109
+ return []
110
+
111
+ diff_lines = []
112
+ line_number = 0
113
+
114
+ for line in diff_text.splitlines():
115
+ # Determine line type based on diff markers
116
+ if line.startswith("+") and not line.startswith("+++"):
117
+ line_type = "add"
118
+ line_number += 1
119
+ content = line[1:] # Remove the + prefix
120
+ elif line.startswith("-") and not line.startswith("---"):
121
+ line_type = "remove"
122
+ line_number += 1
123
+ content = line[1:] # Remove the - prefix
124
+ elif line.startswith("@@"):
125
+ # Parse hunk header to get line number
126
+ # Format: @@ -start,count +start,count @@
127
+ import re
128
+
129
+ match = re.search(r"@@ -\d+(?:,\d+)? \+(\d+)", line)
130
+ if match:
131
+ line_number = (
132
+ int(match.group(1)) - 1
133
+ ) # Will be incremented on next line
134
+ line_type = "context"
135
+ content = line
136
+ elif line.startswith("---") or line.startswith("+++"):
137
+ # File headers - treat as context
138
+ line_type = "context"
139
+ content = line
140
+ else:
141
+ line_type = "context"
142
+ line_number += 1
143
+ content = line
144
+
145
+ diff_lines.append(
146
+ DiffLine(
147
+ line_number=max(1, line_number),
148
+ type=line_type,
149
+ content=content,
150
+ )
151
+ )
152
+
153
+ return diff_lines
154
+
155
+
156
+ def _emit_diff_message(
157
+ file_path: str,
158
+ operation: str,
159
+ diff_text: str,
160
+ old_content: str | None = None,
161
+ new_content: str | None = None,
162
+ ) -> None:
163
+ """Emit a structured DiffMessage for UI display.
164
+
165
+ Args:
166
+ file_path: Path to the file being modified
167
+ operation: One of 'create', 'modify', 'delete'
168
+ diff_text: Raw unified diff text
169
+ old_content: Original file content (optional)
170
+ new_content: New file content (optional)
171
+ """
172
+ # Check if diff was already shown during permission prompt
173
+ try:
174
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
175
+ clear_diff_shown_flag,
176
+ was_diff_already_shown,
177
+ )
178
+
179
+ if was_diff_already_shown():
180
+ # Diff already displayed in permission panel, skip redundant display
181
+ clear_diff_shown_flag()
182
+ return
183
+ except ImportError:
184
+ pass # Permission handler not available, emit anyway
185
+
186
+ if not diff_text or not diff_text.strip():
187
+ return
188
+
189
+ diff_lines = _parse_diff_lines(diff_text)
190
+
191
+ diff_msg = DiffMessage(
192
+ path=file_path,
193
+ operation=operation,
194
+ old_content=old_content,
195
+ new_content=new_content,
196
+ diff_lines=diff_lines,
97
197
  )
198
+ get_message_bus().emit(diff_msg)
98
199
 
99
200
 
100
201
  def _log_error(
101
- msg: str, exc: Exception | None = None, message_group: str = None
202
+ msg: str, exc: Exception | None = None, message_group: str | None = None
102
203
  ) -> None:
103
204
  emit_error(f"{msg}", message_group=message_group)
104
205
  if exc is not None:
@@ -106,28 +207,40 @@ def _log_error(
106
207
 
107
208
 
108
209
  def _delete_snippet_from_file(
109
- context: RunContext | None, file_path: str, snippet: str, message_group: str = None
210
+ context: RunContext | None,
211
+ file_path: str,
212
+ snippet: str,
213
+ message_group: str | None = None,
110
214
  ) -> Dict[str, Any]:
111
215
  file_path = os.path.abspath(file_path)
112
216
  diff_text = ""
113
217
  try:
114
218
  if not os.path.exists(file_path) or not os.path.isfile(file_path):
115
219
  return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
116
- with open(file_path, "r", encoding="utf-8") as f:
220
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
117
221
  original = f.read()
222
+ # Sanitize any surrogate characters from reading
223
+ try:
224
+ original = original.encode("utf-8", errors="surrogatepass").decode(
225
+ "utf-8", errors="replace"
226
+ )
227
+ except (UnicodeEncodeError, UnicodeDecodeError):
228
+ pass
118
229
  if snippet not in original:
119
230
  return {
120
231
  "error": f"Snippet not found in file '{file_path}'.",
121
232
  "diff": diff_text,
122
233
  }
123
234
  modified = original.replace(snippet, "")
235
+ from code_puppy.config import get_diff_context_lines
236
+
124
237
  diff_text = "".join(
125
238
  difflib.unified_diff(
126
239
  original.splitlines(keepends=True),
127
240
  modified.splitlines(keepends=True),
128
241
  fromfile=f"a/{os.path.basename(file_path)}",
129
242
  tofile=f"b/{os.path.basename(file_path)}",
130
- n=3,
243
+ n=get_diff_context_lines(),
131
244
  )
132
245
  )
133
246
  with open(file_path, "w", encoding="utf-8") as f:
@@ -147,14 +260,22 @@ def _replace_in_file(
147
260
  context: RunContext | None,
148
261
  path: str,
149
262
  replacements: List[Dict[str, str]],
150
- message_group: str = None,
263
+ message_group: str | None = None,
151
264
  ) -> Dict[str, Any]:
152
265
  """Robust replacement engine with explicit edge‑case reporting."""
153
266
  file_path = os.path.abspath(path)
154
267
 
155
- with open(file_path, "r", encoding="utf-8") as f:
268
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
156
269
  original = f.read()
157
270
 
271
+ # Sanitize any surrogate characters from reading
272
+ try:
273
+ original = original.encode("utf-8", errors="surrogatepass").decode(
274
+ "utf-8", errors="replace"
275
+ )
276
+ except (UnicodeEncodeError, UnicodeDecodeError):
277
+ pass
278
+
158
279
  modified = original
159
280
  for rep in replacements:
160
281
  old_snippet = rep.get("old_str", "")
@@ -197,13 +318,15 @@ def _replace_in_file(
197
318
  "diff": "",
198
319
  }
199
320
 
321
+ from code_puppy.config import get_diff_context_lines
322
+
200
323
  diff_text = "".join(
201
324
  difflib.unified_diff(
202
325
  original.splitlines(keepends=True),
203
326
  modified.splitlines(keepends=True),
204
327
  fromfile=f"a/{os.path.basename(file_path)}",
205
328
  tofile=f"b/{os.path.basename(file_path)}",
206
- n=3,
329
+ n=get_diff_context_lines(),
207
330
  )
208
331
  )
209
332
  with open(file_path, "w", encoding="utf-8") as f:
@@ -222,7 +345,7 @@ def _write_to_file(
222
345
  path: str,
223
346
  content: str,
224
347
  overwrite: bool = False,
225
- message_group: str = None,
348
+ message_group: str | None = None,
226
349
  ) -> Dict[str, Any]:
227
350
  file_path = os.path.abspath(path)
228
351
 
@@ -237,12 +360,14 @@ def _write_to_file(
237
360
  "diff": "",
238
361
  }
239
362
 
363
+ from code_puppy.config import get_diff_context_lines
364
+
240
365
  diff_lines = difflib.unified_diff(
241
366
  [] if not exists else [""],
242
367
  content.splitlines(keepends=True),
243
368
  fromfile="/dev/null" if not exists else f"a/{os.path.basename(file_path)}",
244
369
  tofile=f"b/{os.path.basename(file_path)}",
245
- n=3,
370
+ n=get_diff_context_lines(),
246
371
  )
247
372
  diff_text = "".join(diff_lines)
248
373
 
@@ -265,18 +390,28 @@ def _write_to_file(
265
390
 
266
391
 
267
392
  def delete_snippet_from_file(
268
- context: RunContext, file_path: str, snippet: str, message_group: str = None
393
+ context: RunContext, file_path: str, snippet: str, message_group: str | None = None
269
394
  ) -> Dict[str, Any]:
270
- emit_info(
271
- f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]",
272
- message_group=message_group,
395
+ # Use the plugin system for permission handling with operation data
396
+ from code_puppy.callbacks import on_file_permission
397
+
398
+ operation_data = {"snippet": snippet}
399
+ permission_results = on_file_permission(
400
+ context, file_path, "delete snippet from", None, message_group, operation_data
273
401
  )
402
+
403
+ # If any permission handler denies the operation, return cancelled result
404
+ if permission_results and any(
405
+ not result for result in permission_results if result is not None
406
+ ):
407
+ return _create_rejection_response(file_path)
408
+
274
409
  res = _delete_snippet_from_file(
275
410
  context, file_path, snippet, message_group=message_group
276
411
  )
277
412
  diff = res.get("diff", "")
278
413
  if diff:
279
- _print_diff(diff, message_group=message_group)
414
+ _emit_diff_message(file_path, "modify", diff)
280
415
  return res
281
416
 
282
417
 
@@ -285,17 +420,30 @@ def write_to_file(
285
420
  path: str,
286
421
  content: str,
287
422
  overwrite: bool,
288
- message_group: str = None,
423
+ message_group: str | None = None,
289
424
  ) -> Dict[str, Any]:
290
- emit_info(
291
- f"✏️ Writing file [bold blue]{path}[/bold blue]", message_group=message_group
425
+ # Use the plugin system for permission handling with operation data
426
+ from code_puppy.callbacks import on_file_permission
427
+
428
+ operation_data = {"content": content, "overwrite": overwrite}
429
+ permission_results = on_file_permission(
430
+ context, path, "write", None, message_group, operation_data
292
431
  )
432
+
433
+ # If any permission handler denies the operation, return cancelled result
434
+ if permission_results and any(
435
+ not result for result in permission_results if result is not None
436
+ ):
437
+ return _create_rejection_response(path)
438
+
293
439
  res = _write_to_file(
294
440
  context, path, content, overwrite=overwrite, message_group=message_group
295
441
  )
296
442
  diff = res.get("diff", "")
297
443
  if diff:
298
- _print_diff(diff, message_group=message_group)
444
+ # Determine operation type based on whether file existed
445
+ operation = "modify" if overwrite else "create"
446
+ _emit_diff_message(path, operation, diff, new_content=content)
299
447
  return res
300
448
 
301
449
 
@@ -303,21 +451,31 @@ def replace_in_file(
303
451
  context: RunContext,
304
452
  path: str,
305
453
  replacements: List[Dict[str, str]],
306
- message_group: str = None,
454
+ message_group: str | None = None,
307
455
  ) -> Dict[str, Any]:
308
- emit_info(
309
- f"♻️ Replacing text in [bold yellow]{path}[/bold yellow]",
310
- message_group=message_group,
456
+ # Use the plugin system for permission handling with operation data
457
+ from code_puppy.callbacks import on_file_permission
458
+
459
+ operation_data = {"replacements": replacements}
460
+ permission_results = on_file_permission(
461
+ context, path, "replace text in", None, message_group, operation_data
311
462
  )
463
+
464
+ # If any permission handler denies the operation, return cancelled result
465
+ if permission_results and any(
466
+ not result for result in permission_results if result is not None
467
+ ):
468
+ return _create_rejection_response(path)
469
+
312
470
  res = _replace_in_file(context, path, replacements, message_group=message_group)
313
471
  diff = res.get("diff", "")
314
472
  if diff:
315
- _print_diff(diff, message_group=message_group)
473
+ _emit_diff_message(path, "modify", diff)
316
474
  return res
317
475
 
318
476
 
319
477
  def _edit_file(
320
- context: RunContext, payload: EditFilePayload, group_id: str = None
478
+ context: RunContext, payload: EditFilePayload, group_id: str | None = None
321
479
  ) -> Dict[str, Any]:
322
480
  """
323
481
  High-level implementation of the *edit_file* behaviour.
@@ -356,9 +514,6 @@ def _edit_file(
356
514
  if group_id is None:
357
515
  group_id = generate_group_id("edit_file", file_path)
358
516
 
359
- emit_info(
360
- "\n[bold white on blue] EDIT FILE [/bold white on blue]", message_group=group_id
361
- )
362
517
  try:
363
518
  if isinstance(payload, DeleteSnippetPayload):
364
519
  return delete_snippet_from_file(
@@ -411,25 +566,46 @@ def _edit_file(
411
566
 
412
567
 
413
568
  def _delete_file(
414
- context: RunContext, file_path: str, message_group: str = None
569
+ context: RunContext, file_path: str, message_group: str | None = None
415
570
  ) -> Dict[str, Any]:
416
- emit_info(
417
- f"🗑️ Deleting file [bold red]{file_path}[/bold red]", message_group=message_group
418
- )
419
571
  file_path = os.path.abspath(file_path)
572
+
573
+ # Use the plugin system for permission handling with operation data
574
+ from code_puppy.callbacks import on_file_permission
575
+
576
+ operation_data = {} # No additional data needed for delete operations
577
+ permission_results = on_file_permission(
578
+ context, file_path, "delete", None, message_group, operation_data
579
+ )
580
+
581
+ # If any permission handler denies the operation, return cancelled result
582
+ if permission_results and any(
583
+ not result for result in permission_results if result is not None
584
+ ):
585
+ return _create_rejection_response(file_path)
586
+
420
587
  try:
421
588
  if not os.path.exists(file_path) or not os.path.isfile(file_path):
422
589
  res = {"error": f"File '{file_path}' does not exist.", "diff": ""}
423
590
  else:
424
- with open(file_path, "r", encoding="utf-8") as f:
591
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
425
592
  original = f.read()
593
+ # Sanitize any surrogate characters from reading
594
+ try:
595
+ original = original.encode("utf-8", errors="surrogatepass").decode(
596
+ "utf-8", errors="replace"
597
+ )
598
+ except (UnicodeEncodeError, UnicodeDecodeError):
599
+ pass
600
+ from code_puppy.config import get_diff_context_lines
601
+
426
602
  diff_text = "".join(
427
603
  difflib.unified_diff(
428
604
  original.splitlines(keepends=True),
429
605
  [],
430
606
  fromfile=f"a/{os.path.basename(file_path)}",
431
607
  tofile=f"b/{os.path.basename(file_path)}",
432
- n=3,
608
+ n=get_diff_context_lines(),
433
609
  )
434
610
  )
435
611
  os.remove(file_path)
@@ -443,7 +619,10 @@ def _delete_file(
443
619
  except Exception as exc:
444
620
  _log_error("Unhandled exception in delete_file", exc)
445
621
  res = {"error": str(exc), "diff": ""}
446
- _print_diff(res.get("diff", ""), message_group=message_group)
622
+
623
+ diff = res.get("diff", "")
624
+ if diff:
625
+ _emit_diff_message(file_path, "delete", diff)
447
626
  return res
448
627
 
449
628
 
@@ -546,17 +725,17 @@ def register_edit_file(agent):
546
725
  if isinstance(payload, str):
547
726
  try:
548
727
  # Fallback for weird models that just can't help but send json strings...
549
- payload = json.loads(json_repair.repair_json(payload))
550
- if "replacements" in payload:
551
- payload = ReplacementsPayload(**payload)
552
- elif "delete_snippet" in payload:
553
- payload = DeleteSnippetPayload(**payload)
554
- elif "content" in payload:
555
- payload = ContentPayload(**payload)
728
+ payload_dict = json.loads(json_repair.repair_json(payload))
729
+ if "replacements" in payload_dict:
730
+ payload = ReplacementsPayload(**payload_dict)
731
+ elif "delete_snippet" in payload_dict:
732
+ payload = DeleteSnippetPayload(**payload_dict)
733
+ elif "content" in payload_dict:
734
+ payload = ContentPayload(**payload_dict)
556
735
  else:
557
736
  file_path = "Unknown"
558
- if "file_path" in payload:
559
- file_path = payload["file_path"]
737
+ if "file_path" in payload_dict:
738
+ file_path = payload_dict["file_path"]
560
739
  return {
561
740
  "success": False,
562
741
  "path": file_path,
@@ -575,6 +754,16 @@ def register_edit_file(agent):
575
754
  result = _edit_file(context, payload)
576
755
  if "diff" in result:
577
756
  del result["diff"]
757
+
758
+ # Trigger edit_file callbacks to enhance the result with rejection details
759
+ enhanced_results = on_edit_file(context, result, payload)
760
+ if enhanced_results:
761
+ # Use the first non-None enhanced result
762
+ for enhanced_result in enhanced_results:
763
+ if enhanced_result is not None:
764
+ result = enhanced_result
765
+ break
766
+
578
767
  return result
579
768
 
580
769
 
@@ -624,4 +813,14 @@ def register_delete_file(agent):
624
813
  result = _delete_file(context, file_path, message_group=group_id)
625
814
  if "diff" in result:
626
815
  del result["diff"]
816
+
817
+ # Trigger delete_file callbacks to enhance the result with rejection details
818
+ enhanced_results = on_delete_file(context, result, file_path)
819
+ if enhanced_results:
820
+ # Use the first non-None enhanced result
821
+ for enhanced_result in enhanced_results:
822
+ if enhanced_result is not None:
823
+ result = enhanced_result
824
+ break
825
+
627
826
  return result