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,16 +1,44 @@
1
+ import asyncio
1
2
  import fnmatch
3
+ import functools
2
4
  import hashlib
5
+ import logging
3
6
  import os
7
+ import sys
4
8
  import time
5
9
  from pathlib import Path
6
- from typing import Optional, Tuple
10
+ from typing import Any, Callable, Optional, Tuple
7
11
 
12
+ from prompt_toolkit import Application
13
+ from prompt_toolkit.formatted_text import HTML
14
+ from prompt_toolkit.key_binding import KeyBindings
15
+ from prompt_toolkit.layout import Layout, Window
16
+ from prompt_toolkit.layout.controls import FormattedTextControl
8
17
  from rapidfuzz.distance import JaroWinkler
9
18
  from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.prompt import Prompt
21
+ from rich.text import Text
22
+
23
+ # Syntax highlighting imports for "syntax" diff mode
24
+ try:
25
+ from pygments import lex
26
+ from pygments.lexers import TextLexer, get_lexer_by_name
27
+ from pygments.token import Token
28
+
29
+ PYGMENTS_AVAILABLE = True
30
+ except ImportError:
31
+ PYGMENTS_AVAILABLE = False
10
32
 
11
33
  # Import our queue-based console system
12
34
  try:
13
- from code_puppy.messaging import get_queue_console
35
+ from code_puppy.messaging import (
36
+ emit_error,
37
+ emit_info,
38
+ emit_success,
39
+ emit_warning,
40
+ get_queue_console,
41
+ )
14
42
 
15
43
  # Use queue console by default, but allow fallback
16
44
  NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
@@ -23,11 +51,59 @@ except ImportError:
23
51
  NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
24
52
  console = Console(no_color=NO_COLOR)
25
53
 
54
+ # Provide fallback emit functions
55
+ def emit_error(msg: str) -> None:
56
+ console.print(f"[bold red]{msg}[/bold red]")
57
+
58
+ def emit_info(msg: str) -> None:
59
+ console.print(msg)
60
+
61
+ def emit_success(msg: str) -> None:
62
+ console.print(f"[bold green]{msg}[/bold green]")
63
+
64
+ def emit_warning(msg: str) -> None:
65
+ console.print(f"[bold yellow]{msg}[/bold yellow]")
66
+
67
+
68
+ def should_suppress_browser() -> bool:
69
+ """Check if browsers should be suppressed (headless mode).
70
+
71
+ Returns:
72
+ True if browsers should be suppressed, False if they can open normally
73
+
74
+ This respects multiple headless mode controls:
75
+ - HEADLESS=true environment variable (suppresses ALL browsers)
76
+ - BROWSER_HEADLESS=true environment variable (for browser automation)
77
+ - CI=true environment variable (continuous integration)
78
+ - PYTEST_CURRENT_TEST environment variable (running under pytest)
79
+ """
80
+ # Explicit headless mode
81
+ if os.getenv("HEADLESS", "").lower() == "true":
82
+ return True
83
+
84
+ # Browser-specific headless mode
85
+ if os.getenv("BROWSER_HEADLESS", "").lower() == "true":
86
+ return True
87
+
88
+ # Continuous integration environments
89
+ if os.getenv("CI", "").lower() == "true":
90
+ return True
91
+
92
+ # Running under pytest
93
+ if "PYTEST_CURRENT_TEST" in os.environ:
94
+ return True
95
+
96
+ # Default to allowing browsers
97
+ return False
98
+
26
99
 
27
100
  # -------------------
28
101
  # Shared ignore patterns/helpers
102
+ # Split into directory vs file patterns so tools can choose appropriately
103
+ # - list_files should ignore only directories (still show binary files inside non-ignored dirs)
104
+ # - grep should ignore both directories and files (avoid grepping binaries)
29
105
  # -------------------
30
- IGNORE_PATTERNS = [
106
+ DIR_IGNORE_PATTERNS = [
31
107
  # Version control
32
108
  "**/.git/**",
33
109
  "**/.git",
@@ -304,6 +380,10 @@ IGNORE_PATTERNS = [
304
380
  "**/*.save",
305
381
  # Hidden files (but be careful with this one)
306
382
  "**/.*", # Commented out as it might be too aggressive
383
+ # Directory-only section ends here
384
+ ]
385
+
386
+ FILE_IGNORE_PATTERNS = [
307
387
  # Binary image formats
308
388
  "**/*.png",
309
389
  "**/*.jpg",
@@ -354,6 +434,9 @@ IGNORE_PATTERNS = [
354
434
  "**/*.sqlite3",
355
435
  ]
356
436
 
437
+ # Backwards compatibility for any imports still referring to IGNORE_PATTERNS
438
+ IGNORE_PATTERNS = DIR_IGNORE_PATTERNS + FILE_IGNORE_PATTERNS
439
+
357
440
 
358
441
  def should_ignore_path(path: str) -> bool:
359
442
  """Return True if *path* matches any pattern in IGNORE_PATTERNS."""
@@ -389,6 +472,890 @@ def should_ignore_path(path: str) -> bool:
389
472
  return False
390
473
 
391
474
 
475
+ def should_ignore_dir_path(path: str) -> bool:
476
+ """Return True if path matches any directory ignore pattern (directories only)."""
477
+ path_obj = Path(path)
478
+ for pattern in DIR_IGNORE_PATTERNS:
479
+ try:
480
+ if path_obj.match(pattern):
481
+ return True
482
+ except ValueError:
483
+ if fnmatch.fnmatch(path, pattern):
484
+ return True
485
+ if "**" in pattern:
486
+ simplified = pattern.replace("**/", "").replace("/**", "")
487
+ parts = path_obj.parts
488
+ for i in range(len(parts)):
489
+ subpath = Path(*parts[i:])
490
+ if fnmatch.fnmatch(str(subpath), simplified):
491
+ return True
492
+ if fnmatch.fnmatch(parts[i], simplified):
493
+ return True
494
+ return False
495
+
496
+
497
+ # ============================================================================
498
+ # SYNTAX HIGHLIGHTING FOR DIFFS ("syntax" mode)
499
+ # ============================================================================
500
+
501
+ # Monokai color scheme - because we have taste 🎨
502
+ TOKEN_COLORS = (
503
+ {
504
+ Token.Keyword: "#f92672" if PYGMENTS_AVAILABLE else "magenta",
505
+ Token.Name.Builtin: "#66d9ef" if PYGMENTS_AVAILABLE else "cyan",
506
+ Token.Name.Function: "#a6e22e" if PYGMENTS_AVAILABLE else "green",
507
+ Token.String: "#e6db74" if PYGMENTS_AVAILABLE else "yellow",
508
+ Token.Number: "#ae81ff" if PYGMENTS_AVAILABLE else "magenta",
509
+ Token.Comment: "#75715e" if PYGMENTS_AVAILABLE else "bright_black",
510
+ Token.Operator: "#f92672" if PYGMENTS_AVAILABLE else "magenta",
511
+ }
512
+ if PYGMENTS_AVAILABLE
513
+ else {}
514
+ )
515
+
516
+ EXTENSION_TO_LEXER_NAME = {
517
+ ".py": "python",
518
+ ".js": "javascript",
519
+ ".jsx": "jsx",
520
+ ".ts": "typescript",
521
+ ".tsx": "tsx",
522
+ ".java": "java",
523
+ ".c": "c",
524
+ ".h": "c",
525
+ ".cpp": "cpp",
526
+ ".hpp": "cpp",
527
+ ".cc": "cpp",
528
+ ".cxx": "cpp",
529
+ ".cs": "csharp",
530
+ ".rs": "rust",
531
+ ".go": "go",
532
+ ".rb": "ruby",
533
+ ".php": "php",
534
+ ".html": "html",
535
+ ".htm": "html",
536
+ ".css": "css",
537
+ ".scss": "scss",
538
+ ".json": "json",
539
+ ".yaml": "yaml",
540
+ ".yml": "yaml",
541
+ ".md": "markdown",
542
+ ".sh": "bash",
543
+ ".bash": "bash",
544
+ ".sql": "sql",
545
+ ".txt": "text",
546
+ }
547
+
548
+
549
+ def _get_lexer_for_extension(extension: str):
550
+ """Get the appropriate Pygments lexer for a file extension.
551
+
552
+ Args:
553
+ extension: File extension (with or without leading dot)
554
+
555
+ Returns:
556
+ A Pygments lexer instance or None if Pygments not available
557
+ """
558
+ if not PYGMENTS_AVAILABLE:
559
+ return None
560
+
561
+ # Normalize extension to have leading dot and be lowercase
562
+ if not extension.startswith("."):
563
+ extension = f".{extension}"
564
+ extension = extension.lower()
565
+
566
+ lexer_name = EXTENSION_TO_LEXER_NAME.get(extension, "text")
567
+
568
+ try:
569
+ return get_lexer_by_name(lexer_name)
570
+ except Exception:
571
+ # Fallback to plain text if lexer not found
572
+ return TextLexer()
573
+
574
+
575
+ def _get_token_color(token_type) -> str:
576
+ """Get color for a token type from our Monokai scheme.
577
+
578
+ Args:
579
+ token_type: Pygments token type
580
+
581
+ Returns:
582
+ Hex color string or color name
583
+ """
584
+ if not PYGMENTS_AVAILABLE:
585
+ return "#cccccc"
586
+
587
+ for ttype, color in TOKEN_COLORS.items():
588
+ if token_type in ttype:
589
+ return color
590
+ return "#cccccc" # Default light-grey for unmatched tokens
591
+
592
+
593
+ def _highlight_code_line(code: str, bg_color: str | None, lexer) -> Text:
594
+ """Highlight a line of code with syntax highlighting and optional background color.
595
+
596
+ Args:
597
+ code: The code string to highlight
598
+ bg_color: Background color in hex format, or None for no background
599
+ lexer: Pygments lexer instance to use
600
+
601
+ Returns:
602
+ Rich Text object with styling applied
603
+ """
604
+ if not PYGMENTS_AVAILABLE or lexer is None:
605
+ # Fallback: just return text with optional background
606
+ if bg_color:
607
+ return Text(code, style=f"on {bg_color}")
608
+ return Text(code)
609
+
610
+ text = Text()
611
+
612
+ for token_type, value in lex(code, lexer):
613
+ # Strip trailing newlines that Pygments adds
614
+ # Pygments lexer always adds a \n at the end of the last token
615
+ value = value.rstrip("\n")
616
+
617
+ # Skip if the value is now empty (was only whitespace/newlines)
618
+ if not value:
619
+ continue
620
+
621
+ fg_color = _get_token_color(token_type)
622
+ # Apply foreground color and optional background
623
+ if bg_color:
624
+ text.append(value, style=f"{fg_color} on {bg_color}")
625
+ else:
626
+ text.append(value, style=fg_color)
627
+
628
+ return text
629
+
630
+
631
+ def _extract_file_extension_from_diff(diff_text: str) -> str:
632
+ """Extract file extension from diff headers.
633
+
634
+ Args:
635
+ diff_text: Unified diff text
636
+
637
+ Returns:
638
+ File extension (e.g., '.py') or '.txt' as fallback
639
+ """
640
+ import re
641
+
642
+ # Look for +++ b/filename.ext or --- a/filename.ext headers
643
+ pattern = r"^(?:\+\+\+|---) [ab]/.*?(\.[a-zA-Z0-9]+)$"
644
+
645
+ for line in diff_text.split("\n")[:10]: # Check first 10 lines
646
+ match = re.search(pattern, line)
647
+ if match:
648
+ return match.group(1)
649
+
650
+ return ".txt" # Fallback to plain text
651
+
652
+
653
+ # ============================================================================
654
+ # COLOR PAIR OPTIMIZATION (for "highlighted" mode)
655
+ # ============================================================================
656
+
657
+
658
+ def brighten_hex(hex_color: str, factor: float) -> str:
659
+ """
660
+ Darken a hex color by multiplying each RGB channel by `factor`.
661
+ factor=1.0 -> no change
662
+ factor=0.0 -> black
663
+ factor=0.18 -> good for diff backgrounds (recommended)
664
+ """
665
+ hex_color = hex_color.lstrip("#")
666
+ if len(hex_color) != 6:
667
+ raise ValueError(f"Expected #RRGGBB, got {hex_color!r}")
668
+
669
+ r = int(hex_color[0:2], 16)
670
+ g = int(hex_color[2:4], 16)
671
+ b = int(hex_color[4:6], 16)
672
+
673
+ r = max(0, min(255, int(r * (1 + factor))))
674
+ g = max(0, min(255, int(g * (1 + factor))))
675
+ b = max(0, min(255, int(b * (1 + factor))))
676
+
677
+ return f"#{r:02x}{g:02x}{b:02x}"
678
+
679
+
680
+ def _format_diff_with_syntax_highlighting(
681
+ diff_text: str,
682
+ addition_color: str | None = None,
683
+ deletion_color: str | None = None,
684
+ ) -> Text:
685
+ """Format diff with full syntax highlighting using Pygments.
686
+
687
+ This renders diffs with:
688
+ - Syntax highlighting for code tokens
689
+ - Colored backgrounds for context/added/removed lines
690
+ - Monokai color scheme
691
+ - Optional custom colors for additions/deletions
692
+
693
+ Args:
694
+ diff_text: Raw unified diff text
695
+ addition_color: Optional custom color for added lines (default: green)
696
+ deletion_color: Optional custom color for deleted lines (default: red)
697
+
698
+ Returns:
699
+ Rich Text object with syntax highlighting (can be passed to emit_info)
700
+ """
701
+ if not PYGMENTS_AVAILABLE:
702
+ return Text(diff_text)
703
+
704
+ # Extract file extension from diff headers
705
+ extension = _extract_file_extension_from_diff(diff_text)
706
+ lexer = _get_lexer_for_extension(extension)
707
+
708
+ # Generate background colors from foreground colors
709
+ add_fg = brighten_hex(addition_color, 0.6)
710
+ del_fg = brighten_hex(deletion_color, 0.6)
711
+
712
+ # Background colors for different line types
713
+ # Context lines have no background (None) for clean, minimal diffs
714
+ bg_colors = {
715
+ "removed": deletion_color,
716
+ "added": addition_color,
717
+ "context": None, # No background for unchanged lines
718
+ }
719
+
720
+ lines = diff_text.split("\n")
721
+ # Remove trailing empty line if it exists (from trailing \n in diff)
722
+ if lines and lines[-1] == "":
723
+ lines = lines[:-1]
724
+ result = Text()
725
+
726
+ for i, line in enumerate(lines):
727
+ if not line:
728
+ # Empty line - just add a newline if not the last line
729
+ if i < len(lines) - 1:
730
+ result.append("\n")
731
+ continue
732
+
733
+ # Skip diff headers - they're redundant noise since we show the filename in the banner
734
+ if line.startswith(("---", "+++", "@@", "diff ", "index ")):
735
+ continue
736
+ else:
737
+ # Determine line type and extract code content
738
+ if line.startswith("-"):
739
+ line_type = "removed"
740
+ code = line[1:] # Remove the '-' prefix
741
+ marker_style = f"bold {del_fg} on {bg_colors[line_type]}"
742
+ prefix = "- "
743
+ elif line.startswith("+"):
744
+ line_type = "added"
745
+ code = line[1:] # Remove the '+' prefix
746
+ marker_style = f"bold {add_fg} on {bg_colors[line_type]}"
747
+ prefix = "+ "
748
+ else:
749
+ line_type = "context"
750
+ code = line[1:] if line.startswith(" ") else line
751
+ # Context lines have no background - clean and minimal
752
+ marker_style = "" # No special styling for context markers
753
+ prefix = " "
754
+
755
+ # Add the marker prefix
756
+ if marker_style: # Only apply style if we have one
757
+ result.append(prefix, style=marker_style)
758
+ else:
759
+ result.append(prefix)
760
+
761
+ # Add syntax-highlighted code
762
+ highlighted = _highlight_code_line(code, bg_colors[line_type], lexer)
763
+ result.append_text(highlighted)
764
+
765
+ # Add newline after each line except the last
766
+ if i < len(lines) - 1:
767
+ result.append("\n")
768
+
769
+ return result
770
+
771
+
772
+ def format_diff_with_colors(diff_text: str) -> Text:
773
+ """Format diff text with beautiful syntax highlighting.
774
+
775
+ This is the canonical diff formatting function used across the codebase.
776
+ It applies user-configurable color coding with full syntax highlighting using Pygments.
777
+
778
+ The function respects user preferences from config:
779
+ - get_diff_addition_color(): Color for added lines (markers and backgrounds)
780
+ - get_diff_deletion_color(): Color for deleted lines (markers and backgrounds)
781
+
782
+ Args:
783
+ diff_text: Raw diff text to format
784
+
785
+ Returns:
786
+ Rich Text object with syntax highlighting
787
+ """
788
+ from code_puppy.config import (
789
+ get_diff_addition_color,
790
+ get_diff_deletion_color,
791
+ )
792
+
793
+ if not diff_text or not diff_text.strip():
794
+ return Text("-- no diff available --", style="dim")
795
+
796
+ addition_base_color = get_diff_addition_color()
797
+ deletion_base_color = get_diff_deletion_color()
798
+
799
+ # Always use beautiful syntax highlighting!
800
+ if not PYGMENTS_AVAILABLE:
801
+ emit_warning("Pygments not available, diffs will look plain")
802
+ # Return plain text as fallback
803
+ return Text(diff_text)
804
+
805
+ # Return Text object with custom colors - emit_info handles this correctly
806
+ return _format_diff_with_syntax_highlighting(
807
+ diff_text,
808
+ addition_color=addition_base_color,
809
+ deletion_color=deletion_base_color,
810
+ )
811
+
812
+
813
+ async def arrow_select_async(
814
+ message: str,
815
+ choices: list[str],
816
+ preview_callback: Optional[Callable[[int], str]] = None,
817
+ ) -> str:
818
+ """Async version: Show an arrow-key navigable selector with optional preview.
819
+
820
+ Args:
821
+ message: The prompt message to display
822
+ choices: List of choice strings
823
+ preview_callback: Optional callback that takes the selected index and returns
824
+ preview text to display below the choices
825
+
826
+ Returns:
827
+ The selected choice string
828
+
829
+ Raises:
830
+ KeyboardInterrupt: If user cancels with Ctrl-C
831
+ """
832
+ import html
833
+
834
+ selected_index = [0] # Mutable container for selected index
835
+ result = [None] # Mutable container for result
836
+
837
+ def get_formatted_text():
838
+ """Generate the formatted text for display."""
839
+ # Escape XML special characters to prevent parsing errors
840
+ safe_message = html.escape(message)
841
+ lines = [f"<b>{safe_message}</b>", ""]
842
+ for i, choice in enumerate(choices):
843
+ safe_choice = html.escape(choice)
844
+ if i == selected_index[0]:
845
+ lines.append(f"<ansigreen>❯ {safe_choice}</ansigreen>")
846
+ else:
847
+ lines.append(f" {safe_choice}")
848
+ lines.append("")
849
+
850
+ # Add preview section if callback provided
851
+ if preview_callback is not None:
852
+ preview_text = preview_callback(selected_index[0])
853
+ if preview_text:
854
+ import textwrap
855
+
856
+ # Box width (excluding borders and padding)
857
+ box_width = 60
858
+ border_top = (
859
+ "<ansiyellow>┌─ Preview "
860
+ + "─" * (box_width - 10)
861
+ + "┐</ansiyellow>"
862
+ )
863
+ border_bottom = "<ansiyellow>└" + "─" * box_width + "┘</ansiyellow>"
864
+
865
+ lines.append(border_top)
866
+
867
+ # Wrap text to fit within box width (minus padding)
868
+ wrapped_lines = textwrap.wrap(preview_text, width=box_width - 2)
869
+
870
+ # If no wrapped lines (empty text), add empty line
871
+ if not wrapped_lines:
872
+ wrapped_lines = [""]
873
+
874
+ for wrapped_line in wrapped_lines:
875
+ safe_preview = html.escape(wrapped_line)
876
+ # Pad line to box width for consistent appearance
877
+ padded_line = safe_preview.ljust(box_width - 2)
878
+ lines.append(f"<dim>│ {padded_line} │</dim>")
879
+
880
+ lines.append(border_bottom)
881
+ lines.append("")
882
+
883
+ lines.append("<ansicyan>(Use ↑↓ arrows to select, Enter to confirm)</ansicyan>")
884
+ return HTML("\n".join(lines))
885
+
886
+ # Key bindings
887
+ kb = KeyBindings()
888
+
889
+ @kb.add("up")
890
+ def move_up(event):
891
+ selected_index[0] = (selected_index[0] - 1) % len(choices)
892
+ event.app.invalidate() # Force redraw to update preview
893
+
894
+ @kb.add("down")
895
+ def move_down(event):
896
+ selected_index[0] = (selected_index[0] + 1) % len(choices)
897
+ event.app.invalidate() # Force redraw to update preview
898
+
899
+ @kb.add("enter")
900
+ def accept(event):
901
+ result[0] = choices[selected_index[0]]
902
+ event.app.exit()
903
+
904
+ @kb.add("c-c") # Ctrl-C
905
+ def cancel(event):
906
+ result[0] = None
907
+ event.app.exit()
908
+
909
+ # Layout
910
+ control = FormattedTextControl(get_formatted_text)
911
+ layout = Layout(Window(content=control))
912
+
913
+ # Application
914
+ app = Application(
915
+ layout=layout,
916
+ key_bindings=kb,
917
+ full_screen=False,
918
+ )
919
+
920
+ # Flush output before prompt_toolkit takes control
921
+ sys.stdout.flush()
922
+ sys.stderr.flush()
923
+
924
+ # Run the app asynchronously
925
+ await app.run_async()
926
+
927
+ if result[0] is None:
928
+ raise KeyboardInterrupt()
929
+
930
+ return result[0]
931
+
932
+
933
+ def arrow_select(message: str, choices: list[str]) -> str:
934
+ """Show an arrow-key navigable selector (synchronous version).
935
+
936
+ Args:
937
+ message: The prompt message to display
938
+ choices: List of choice strings
939
+
940
+ Returns:
941
+ The selected choice string
942
+
943
+ Raises:
944
+ KeyboardInterrupt: If user cancels with Ctrl-C
945
+ """
946
+ import asyncio
947
+
948
+ selected_index = [0] # Mutable container for selected index
949
+ result = [None] # Mutable container for result
950
+
951
+ def get_formatted_text():
952
+ """Generate the formatted text for display."""
953
+ lines = [f"<b>{message}</b>", ""]
954
+ for i, choice in enumerate(choices):
955
+ if i == selected_index[0]:
956
+ lines.append(f"<ansigreen>❯ {choice}</ansigreen>")
957
+ else:
958
+ lines.append(f" {choice}")
959
+ lines.append("")
960
+ lines.append("<ansicyan>(Use ↑↓ arrows to select, Enter to confirm)</ansicyan>")
961
+ return HTML("\n".join(lines))
962
+
963
+ # Key bindings
964
+ kb = KeyBindings()
965
+
966
+ @kb.add("up")
967
+ def move_up(event):
968
+ selected_index[0] = (selected_index[0] - 1) % len(choices)
969
+ event.app.invalidate() # Force redraw to update preview
970
+
971
+ @kb.add("down")
972
+ def move_down(event):
973
+ selected_index[0] = (selected_index[0] + 1) % len(choices)
974
+ event.app.invalidate() # Force redraw to update preview
975
+
976
+ @kb.add("enter")
977
+ def accept(event):
978
+ result[0] = choices[selected_index[0]]
979
+ event.app.exit()
980
+
981
+ @kb.add("c-c") # Ctrl-C
982
+ def cancel(event):
983
+ result[0] = None
984
+ event.app.exit()
985
+
986
+ # Layout
987
+ control = FormattedTextControl(get_formatted_text)
988
+ layout = Layout(Window(content=control))
989
+
990
+ # Application
991
+ app = Application(
992
+ layout=layout,
993
+ key_bindings=kb,
994
+ full_screen=False,
995
+ )
996
+
997
+ # Flush output before prompt_toolkit takes control
998
+ sys.stdout.flush()
999
+ sys.stderr.flush()
1000
+
1001
+ # Check if we're already in an async context
1002
+ try:
1003
+ asyncio.get_running_loop()
1004
+ # We're in an async context - can't use app.run()
1005
+ # Caller should use arrow_select_async instead
1006
+ raise RuntimeError(
1007
+ "arrow_select() called from async context. Use arrow_select_async() instead."
1008
+ )
1009
+ except RuntimeError as e:
1010
+ if "no running event loop" in str(e).lower():
1011
+ # No event loop, safe to use app.run()
1012
+ app.run()
1013
+ else:
1014
+ # Re-raise if it's our error message
1015
+ raise
1016
+
1017
+ if result[0] is None:
1018
+ raise KeyboardInterrupt()
1019
+
1020
+ return result[0]
1021
+
1022
+
1023
+ def get_user_approval(
1024
+ title: str,
1025
+ content: Text | str,
1026
+ preview: str | None = None,
1027
+ border_style: str = "dim white",
1028
+ puppy_name: str | None = None,
1029
+ ) -> tuple[bool, str | None]:
1030
+ """Show a beautiful approval panel with arrow-key selector.
1031
+
1032
+ Args:
1033
+ title: Title for the panel (e.g., "File Operation", "Shell Command")
1034
+ content: Main content to display (Rich Text object or string)
1035
+ preview: Optional preview content (like a diff)
1036
+ border_style: Border color/style for the panel
1037
+ puppy_name: Name of the assistant (defaults to config value)
1038
+
1039
+ Returns:
1040
+ Tuple of (confirmed: bool, user_feedback: str | None)
1041
+ - confirmed: True if approved, False if rejected
1042
+ - user_feedback: Optional feedback text if user provided it
1043
+ """
1044
+ import time
1045
+
1046
+ from code_puppy.tools.command_runner import set_awaiting_user_input
1047
+
1048
+ if puppy_name is None:
1049
+ from code_puppy.config import get_puppy_name
1050
+
1051
+ puppy_name = get_puppy_name().title()
1052
+
1053
+ # Build panel content
1054
+ if isinstance(content, str):
1055
+ panel_content = Text(content)
1056
+ else:
1057
+ panel_content = content
1058
+
1059
+ # Add preview if provided
1060
+ if preview:
1061
+ panel_content.append("\n\n", style="")
1062
+ panel_content.append("Preview of changes:", style="bold underline")
1063
+ panel_content.append("\n", style="")
1064
+ formatted_preview = format_diff_with_colors(preview)
1065
+
1066
+ # Handle both string (text mode) and Text object (highlight mode)
1067
+ if isinstance(formatted_preview, Text):
1068
+ preview_text = formatted_preview
1069
+ else:
1070
+ preview_text = Text.from_markup(formatted_preview)
1071
+
1072
+ panel_content.append(preview_text)
1073
+
1074
+ # Mark that we showed a diff preview
1075
+ try:
1076
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
1077
+ set_diff_already_shown,
1078
+ )
1079
+
1080
+ set_diff_already_shown(True)
1081
+ except ImportError:
1082
+ pass
1083
+
1084
+ # Create panel
1085
+ panel = Panel(
1086
+ panel_content,
1087
+ title=f"[bold white]{title}[/bold white]",
1088
+ border_style=border_style,
1089
+ padding=(1, 2),
1090
+ )
1091
+
1092
+ # Pause spinners BEFORE showing panel
1093
+ set_awaiting_user_input(True)
1094
+ # Also explicitly pause spinners to ensure they're fully stopped
1095
+ try:
1096
+ from code_puppy.messaging.spinner import pause_all_spinners
1097
+
1098
+ pause_all_spinners()
1099
+ except (ImportError, Exception):
1100
+ pass
1101
+
1102
+ time.sleep(0.3) # Let spinners fully stop
1103
+
1104
+ # Display panel
1105
+ local_console = Console()
1106
+ emit_info("")
1107
+ local_console.print(panel)
1108
+ emit_info("")
1109
+
1110
+ # Flush and buffer before selector
1111
+ sys.stdout.flush()
1112
+ sys.stderr.flush()
1113
+ time.sleep(0.1)
1114
+
1115
+ user_feedback = None
1116
+ confirmed = False
1117
+
1118
+ try:
1119
+ # Final flush
1120
+ sys.stdout.flush()
1121
+
1122
+ # Show arrow-key selector
1123
+ choice = arrow_select(
1124
+ "💭 What would you like to do?",
1125
+ [
1126
+ "✓ Approve",
1127
+ "✗ Reject",
1128
+ f"💬 Reject with feedback (tell {puppy_name} what to change)",
1129
+ ],
1130
+ )
1131
+
1132
+ if choice == "✓ Approve":
1133
+ confirmed = True
1134
+ elif choice == "✗ Reject":
1135
+ confirmed = False
1136
+ else:
1137
+ # User wants to provide feedback
1138
+ confirmed = False
1139
+ emit_info("")
1140
+ emit_info(f"Tell {puppy_name} what to change:")
1141
+ user_feedback = Prompt.ask(
1142
+ "[bold green]➤[/bold green]",
1143
+ default="",
1144
+ ).strip()
1145
+
1146
+ if not user_feedback:
1147
+ user_feedback = None
1148
+
1149
+ except (KeyboardInterrupt, EOFError):
1150
+ emit_error("Cancelled by user")
1151
+ confirmed = False
1152
+
1153
+ finally:
1154
+ set_awaiting_user_input(False)
1155
+
1156
+ # Force Rich console to reset display state to prevent artifacts
1157
+ try:
1158
+ # Clear Rich's internal display state to prevent artifacts
1159
+ local_console.file.write("\r") # Return to start of line
1160
+ local_console.file.write("\x1b[K") # Clear current line
1161
+ local_console.file.flush()
1162
+ except Exception:
1163
+ pass
1164
+
1165
+ # Ensure streams are flushed
1166
+ sys.stdout.flush()
1167
+ sys.stderr.flush()
1168
+
1169
+ # Show result BEFORE resuming spinners (no puppy litter!)
1170
+ emit_info("")
1171
+ if not confirmed:
1172
+ if user_feedback:
1173
+ emit_error("Rejected with feedback!")
1174
+ emit_warning(f'Telling {puppy_name}: "{user_feedback}"')
1175
+ else:
1176
+ emit_error("Rejected.")
1177
+ else:
1178
+ emit_success("Approved!")
1179
+
1180
+ # NOW resume spinners after showing the result
1181
+ try:
1182
+ from code_puppy.messaging.spinner import resume_all_spinners
1183
+
1184
+ resume_all_spinners()
1185
+ except (ImportError, Exception):
1186
+ pass
1187
+
1188
+ return confirmed, user_feedback
1189
+
1190
+
1191
+ async def get_user_approval_async(
1192
+ title: str,
1193
+ content: Text | str,
1194
+ preview: str | None = None,
1195
+ border_style: str = "dim white",
1196
+ puppy_name: str | None = None,
1197
+ ) -> tuple[bool, str | None]:
1198
+ """Async version of get_user_approval - show a beautiful approval panel with arrow-key selector.
1199
+
1200
+ Args:
1201
+ title: Title for the panel (e.g., "File Operation", "Shell Command")
1202
+ content: Main content to display (Rich Text object or string)
1203
+ preview: Optional preview content (like a diff)
1204
+ border_style: Border color/style for the panel
1205
+ puppy_name: Name of the assistant (defaults to config value)
1206
+
1207
+ Returns:
1208
+ Tuple of (confirmed: bool, user_feedback: str | None)
1209
+ - confirmed: True if approved, False if rejected
1210
+ - user_feedback: Optional feedback text if user provided it
1211
+ """
1212
+ import asyncio
1213
+
1214
+ from code_puppy.tools.command_runner import set_awaiting_user_input
1215
+
1216
+ if puppy_name is None:
1217
+ from code_puppy.config import get_puppy_name
1218
+
1219
+ puppy_name = get_puppy_name().title()
1220
+
1221
+ # Build panel content
1222
+ if isinstance(content, str):
1223
+ panel_content = Text(content)
1224
+ else:
1225
+ panel_content = content
1226
+
1227
+ # Add preview if provided
1228
+ if preview:
1229
+ panel_content.append("\n\n", style="")
1230
+ panel_content.append("Preview of changes:", style="bold underline")
1231
+ panel_content.append("\n", style="")
1232
+ formatted_preview = format_diff_with_colors(preview)
1233
+
1234
+ # Handle both string (text mode) and Text object (highlight mode)
1235
+ if isinstance(formatted_preview, Text):
1236
+ preview_text = formatted_preview
1237
+ else:
1238
+ preview_text = Text.from_markup(formatted_preview)
1239
+
1240
+ panel_content.append(preview_text)
1241
+
1242
+ # Mark that we showed a diff preview
1243
+ try:
1244
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
1245
+ set_diff_already_shown,
1246
+ )
1247
+
1248
+ set_diff_already_shown(True)
1249
+ except ImportError:
1250
+ pass
1251
+
1252
+ # Create panel
1253
+ panel = Panel(
1254
+ panel_content,
1255
+ title=f"[bold white]{title}[/bold white]",
1256
+ border_style=border_style,
1257
+ padding=(1, 2),
1258
+ )
1259
+
1260
+ # Pause spinners BEFORE showing panel
1261
+ set_awaiting_user_input(True)
1262
+ # Also explicitly pause spinners to ensure they're fully stopped
1263
+ try:
1264
+ from code_puppy.messaging.spinner import pause_all_spinners
1265
+
1266
+ pause_all_spinners()
1267
+ except (ImportError, Exception):
1268
+ pass
1269
+
1270
+ await asyncio.sleep(0.3) # Let spinners fully stop
1271
+
1272
+ # Display panel
1273
+ local_console = Console()
1274
+ emit_info("")
1275
+ local_console.print(panel)
1276
+ emit_info("")
1277
+
1278
+ # Flush and buffer before selector
1279
+ sys.stdout.flush()
1280
+ sys.stderr.flush()
1281
+ await asyncio.sleep(0.1)
1282
+
1283
+ user_feedback = None
1284
+ confirmed = False
1285
+
1286
+ try:
1287
+ # Final flush
1288
+ sys.stdout.flush()
1289
+
1290
+ # Show arrow-key selector (ASYNC VERSION)
1291
+ choice = await arrow_select_async(
1292
+ "💭 What would you like to do?",
1293
+ [
1294
+ "✓ Approve",
1295
+ "✗ Reject",
1296
+ f"💬 Reject with feedback (tell {puppy_name} what to change)",
1297
+ ],
1298
+ )
1299
+
1300
+ if choice == "✓ Approve":
1301
+ confirmed = True
1302
+ elif choice == "✗ Reject":
1303
+ confirmed = False
1304
+ else:
1305
+ # User wants to provide feedback
1306
+ confirmed = False
1307
+ emit_info("")
1308
+ emit_info(f"Tell {puppy_name} what to change:")
1309
+ user_feedback = Prompt.ask(
1310
+ "[bold green]➤[/bold green]",
1311
+ default="",
1312
+ ).strip()
1313
+
1314
+ if not user_feedback:
1315
+ user_feedback = None
1316
+
1317
+ except (KeyboardInterrupt, EOFError):
1318
+ emit_error("Cancelled by user")
1319
+ confirmed = False
1320
+
1321
+ finally:
1322
+ set_awaiting_user_input(False)
1323
+
1324
+ # Force Rich console to reset display state to prevent artifacts
1325
+ try:
1326
+ # Clear Rich's internal display state to prevent artifacts
1327
+ local_console.file.write("\r") # Return to start of line
1328
+ local_console.file.write("\x1b[K") # Clear current line
1329
+ local_console.file.flush()
1330
+ except Exception:
1331
+ pass
1332
+
1333
+ # Ensure streams are flushed
1334
+ sys.stdout.flush()
1335
+ sys.stderr.flush()
1336
+
1337
+ # Show result BEFORE resuming spinners (no puppy litter!)
1338
+ emit_info("")
1339
+ if not confirmed:
1340
+ if user_feedback:
1341
+ emit_error("Rejected with feedback!")
1342
+ emit_warning(f'Telling {puppy_name}: "{user_feedback}"')
1343
+ else:
1344
+ emit_error("Rejected.")
1345
+ else:
1346
+ emit_success("Approved!")
1347
+
1348
+ # NOW resume spinners after showing the result
1349
+ try:
1350
+ from code_puppy.messaging.spinner import resume_all_spinners
1351
+
1352
+ resume_all_spinners()
1353
+ except (ImportError, Exception):
1354
+ pass
1355
+
1356
+ return confirmed, user_feedback
1357
+
1358
+
392
1359
  def _find_best_window(
393
1360
  haystack_lines: list[str],
394
1361
  needle: str,
@@ -413,9 +1380,10 @@ def _find_best_window(
413
1380
  best_span = (i, i + win_size)
414
1381
  best_window = window
415
1382
 
416
- console.log(f"Best span: {best_span}")
417
- console.log(f"Best window: {best_window}")
418
- console.log(f"Best score: {best_score}")
1383
+ # Debug logging
1384
+ console.log(best_span)
1385
+ console.log(best_window)
1386
+ console.log(best_score)
419
1387
  return best_span, best_score
420
1388
 
421
1389
 
@@ -441,3 +1409,175 @@ def generate_group_id(tool_name: str, extra_context: str = "") -> str:
441
1409
  short_hash = hash_obj.hexdigest()[:8]
442
1410
 
443
1411
  return f"{tool_name}_{short_hash}"
1412
+
1413
+
1414
+ # =============================================================================
1415
+ # TOOL CALLBACK WRAPPER
1416
+ # =============================================================================
1417
+
1418
+ logger = logging.getLogger(__name__)
1419
+
1420
+
1421
+ def with_tool_callbacks(tool_name: str) -> Callable:
1422
+ """Decorator that wraps tool functions with pre/post callback hooks.
1423
+
1424
+ This decorator enables plugins to hook into tool execution for:
1425
+ - Logging and analytics
1426
+ - Pre-execution validation or modification
1427
+ - Post-execution result processing
1428
+ - Performance monitoring
1429
+
1430
+ Args:
1431
+ tool_name: The name of the tool being wrapped (e.g., 'edit_file', 'list_files')
1432
+
1433
+ Returns:
1434
+ A decorator function that wraps the tool with callbacks.
1435
+
1436
+ Example:
1437
+ @with_tool_callbacks('my_tool')
1438
+ async def my_tool_impl(ctx, **kwargs):
1439
+ return result
1440
+ """
1441
+
1442
+ def decorator(func: Callable) -> Callable:
1443
+ @functools.wraps(func)
1444
+ async def async_wrapper(*args, **kwargs) -> Any:
1445
+ # Extract context from args if available (usually first arg is RunContext)
1446
+ context = None
1447
+ tool_args = kwargs.copy()
1448
+
1449
+ # Try to get session context
1450
+ try:
1451
+ from code_puppy.messaging import get_session_context
1452
+
1453
+ context = get_session_context()
1454
+ except ImportError:
1455
+ pass
1456
+
1457
+ # Fire pre-tool callback (non-blocking)
1458
+ try:
1459
+ from code_puppy import callbacks
1460
+
1461
+ asyncio.create_task(
1462
+ callbacks.on_pre_tool_call(tool_name, tool_args, context)
1463
+ )
1464
+ except ImportError:
1465
+ logger.debug("callbacks module not available for pre_tool_call")
1466
+ except Exception as e:
1467
+ logger.debug(f"Error in pre_tool_call callback: {e}")
1468
+
1469
+ # Execute the tool and measure duration
1470
+ start_time = time.perf_counter()
1471
+ result = None
1472
+ error = None
1473
+
1474
+ try:
1475
+ result = await func(*args, **kwargs)
1476
+ return result
1477
+ except Exception as e:
1478
+ error = e
1479
+ raise
1480
+ finally:
1481
+ end_time = time.perf_counter()
1482
+ duration_ms = (end_time - start_time) * 1000
1483
+
1484
+ # Fire post-tool callback (non-blocking)
1485
+ final_result = result if error is None else {"error": str(error)}
1486
+ try:
1487
+ from code_puppy import callbacks
1488
+
1489
+ asyncio.create_task(
1490
+ callbacks.on_post_tool_call(
1491
+ tool_name, tool_args, final_result, duration_ms, context
1492
+ )
1493
+ )
1494
+ except ImportError:
1495
+ logger.debug("callbacks module not available for post_tool_call")
1496
+ except Exception as e:
1497
+ logger.debug(f"Error in post_tool_call callback: {e}")
1498
+
1499
+ @functools.wraps(func)
1500
+ def sync_wrapper(*args, **kwargs) -> Any:
1501
+ """Sync wrapper for non-async tool functions."""
1502
+ # Extract context
1503
+ context = None
1504
+ tool_args = kwargs.copy()
1505
+
1506
+ try:
1507
+ from code_puppy.messaging import get_session_context
1508
+
1509
+ context = get_session_context()
1510
+ except ImportError:
1511
+ pass
1512
+
1513
+ # For sync functions, we can't use asyncio.create_task directly
1514
+ # Instead, we'll try to schedule it if there's a running loop
1515
+ def fire_pre_callback():
1516
+ try:
1517
+ from code_puppy import callbacks
1518
+
1519
+ loop = asyncio.get_running_loop()
1520
+ asyncio.run_coroutine_threadsafe(
1521
+ callbacks.on_pre_tool_call(tool_name, tool_args, context),
1522
+ loop,
1523
+ )
1524
+ except RuntimeError:
1525
+ # No running loop - skip async callback
1526
+ pass
1527
+ except ImportError:
1528
+ pass
1529
+ except Exception as e:
1530
+ logger.debug(f"Error in sync pre_tool_call: {e}")
1531
+
1532
+ fire_pre_callback()
1533
+
1534
+ # Execute the tool
1535
+ start_time = time.perf_counter()
1536
+ result = None
1537
+ error = None
1538
+
1539
+ try:
1540
+ result = func(*args, **kwargs)
1541
+ return result
1542
+ except Exception as e:
1543
+ error = e
1544
+ raise
1545
+ finally:
1546
+ end_time = time.perf_counter()
1547
+ duration_ms = (end_time - start_time) * 1000
1548
+
1549
+ # Fire post-tool callback
1550
+ final_result = result if error is None else {"error": str(error)}
1551
+
1552
+ def fire_post_callback():
1553
+ try:
1554
+ from code_puppy import callbacks
1555
+
1556
+ loop = asyncio.get_running_loop()
1557
+ asyncio.run_coroutine_threadsafe(
1558
+ callbacks.on_post_tool_call(
1559
+ tool_name,
1560
+ tool_args,
1561
+ final_result,
1562
+ duration_ms,
1563
+ context,
1564
+ ),
1565
+ loop,
1566
+ )
1567
+ except RuntimeError:
1568
+ # No running loop - skip async callback
1569
+ pass
1570
+ except ImportError:
1571
+ pass
1572
+ except Exception as e:
1573
+ logger.debug(f"Error in sync post_tool_call: {e}")
1574
+
1575
+ fire_post_callback()
1576
+
1577
+ # Return appropriate wrapper based on function type
1578
+ if asyncio.iscoroutinefunction(func):
1579
+ return async_wrapper
1580
+ else:
1581
+ return sync_wrapper
1582
+
1583
+ return decorator