codepp 0.0.437__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 (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1409 @@
1
+ import asyncio
2
+ import fnmatch
3
+ import hashlib
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Callable, Optional, Tuple
9
+
10
+ from prompt_toolkit import Application
11
+ from prompt_toolkit.formatted_text import HTML
12
+ from prompt_toolkit.key_binding import KeyBindings
13
+ from prompt_toolkit.layout import Layout, Window
14
+ from prompt_toolkit.layout.controls import FormattedTextControl
15
+ from rapidfuzz.distance import JaroWinkler
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.prompt import Prompt
19
+ from rich.text import Text
20
+
21
+ # Syntax highlighting imports for "syntax" diff mode
22
+ try:
23
+ from pygments import lex
24
+ from pygments.lexers import TextLexer, get_lexer_by_name
25
+ from pygments.token import Token
26
+
27
+ PYGMENTS_AVAILABLE = True
28
+ except ImportError:
29
+ PYGMENTS_AVAILABLE = False
30
+
31
+ # Import our queue-based console system
32
+ try:
33
+ from code_puppy.messaging import (
34
+ emit_error,
35
+ emit_info,
36
+ emit_success,
37
+ emit_warning,
38
+ get_queue_console,
39
+ )
40
+
41
+ # Use queue console by default, but allow fallback
42
+ NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
43
+ _rich_console = Console(no_color=NO_COLOR)
44
+ console = get_queue_console()
45
+ # Set the fallback console for compatibility
46
+ console.fallback_console = _rich_console
47
+ except ImportError:
48
+ # Fallback to regular Rich console if messaging system not available
49
+ NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
50
+ console = Console(no_color=NO_COLOR)
51
+
52
+ # Provide fallback emit functions
53
+ def emit_error(msg: str) -> None:
54
+ console.print(f"[bold red]{msg}[/bold red]")
55
+
56
+ def emit_info(msg: str) -> None:
57
+ console.print(msg)
58
+
59
+ def emit_success(msg: str) -> None:
60
+ console.print(f"[bold green]{msg}[/bold green]")
61
+
62
+ def emit_warning(msg: str) -> None:
63
+ console.print(f"[bold yellow]{msg}[/bold yellow]")
64
+
65
+
66
+ def should_suppress_browser() -> bool:
67
+ """Check if browsers should be suppressed (headless mode).
68
+
69
+ Returns:
70
+ True if browsers should be suppressed, False if they can open normally
71
+
72
+ This respects multiple headless mode controls:
73
+ - HEADLESS=true environment variable (suppresses ALL browsers)
74
+ - BROWSER_HEADLESS=true environment variable (for browser automation)
75
+ - CI=true environment variable (continuous integration)
76
+ - PYTEST_CURRENT_TEST environment variable (running under pytest)
77
+ """
78
+ # Explicit headless mode
79
+ if os.getenv("HEADLESS", "").lower() == "true":
80
+ return True
81
+
82
+ # Browser-specific headless mode
83
+ if os.getenv("BROWSER_HEADLESS", "").lower() == "true":
84
+ return True
85
+
86
+ # Continuous integration environments
87
+ if os.getenv("CI", "").lower() == "true":
88
+ return True
89
+
90
+ # Running under pytest
91
+ if "PYTEST_CURRENT_TEST" in os.environ:
92
+ return True
93
+
94
+ # Default to allowing browsers
95
+ return False
96
+
97
+
98
+ # -------------------
99
+ # Shared ignore patterns/helpers
100
+ # Split into directory vs file patterns so tools can choose appropriately
101
+ # - list_files should ignore only directories (still show binary files inside non-ignored dirs)
102
+ # - grep should ignore both directories and files (avoid grepping binaries)
103
+ # -------------------
104
+ DIR_IGNORE_PATTERNS = [
105
+ # Version control
106
+ "**/.git/**",
107
+ "**/.git",
108
+ ".git/**",
109
+ ".git",
110
+ "**/.svn/**",
111
+ "**/.hg/**",
112
+ "**/.bzr/**",
113
+ # Node.js / JavaScript / TypeScript
114
+ "**/node_modules/**",
115
+ "**/node_modules/**/*.js",
116
+ "node_modules/**",
117
+ "node_modules",
118
+ "**/npm-debug.log*",
119
+ "**/yarn-debug.log*",
120
+ "**/yarn-error.log*",
121
+ "**/pnpm-debug.log*",
122
+ "**/.npm/**",
123
+ "**/.yarn/**",
124
+ "**/.pnpm-store/**",
125
+ "**/coverage/**",
126
+ "**/.nyc_output/**",
127
+ "**/dist/**",
128
+ "**/dist",
129
+ "**/build/**",
130
+ "**/build",
131
+ "**/.next/**",
132
+ "**/.nuxt/**",
133
+ "**/out/**",
134
+ "**/.cache/**",
135
+ "**/.parcel-cache/**",
136
+ "**/.vite/**",
137
+ "**/storybook-static/**",
138
+ "**/*.tsbuildinfo/**",
139
+ # Python
140
+ "**/__pycache__/**",
141
+ "**/__pycache__",
142
+ "__pycache__/**",
143
+ "__pycache__",
144
+ "**/*.pyc",
145
+ "**/*.pyo",
146
+ "**/*.pyd",
147
+ "**/.pytest_cache/**",
148
+ "**/.mypy_cache/**",
149
+ "**/.coverage",
150
+ "**/htmlcov/**",
151
+ "**/.tox/**",
152
+ "**/.nox/**",
153
+ "**/site-packages/**",
154
+ "**/.venv/**",
155
+ "**/.venv",
156
+ "**/venv/**",
157
+ "**/venv",
158
+ "**/env/**",
159
+ "**/ENV/**",
160
+ "**/.env",
161
+ "**/pip-wheel-metadata/**",
162
+ "**/*.egg-info/**",
163
+ "**/dist/**",
164
+ "**/wheels/**",
165
+ "**/pytest-reports/**",
166
+ # Java (Maven, Gradle, SBT)
167
+ "**/target/**",
168
+ "**/target",
169
+ "**/build/**",
170
+ "**/build",
171
+ "**/.gradle/**",
172
+ "**/gradle-app.setting",
173
+ "**/*.class",
174
+ "**/*.jar",
175
+ "**/*.war",
176
+ "**/*.ear",
177
+ "**/*.nar",
178
+ "**/hs_err_pid*",
179
+ "**/.classpath",
180
+ "**/.project",
181
+ "**/.settings/**",
182
+ "**/bin/**",
183
+ "**/project/target/**",
184
+ "**/project/project/**",
185
+ # Go
186
+ "**/vendor/**",
187
+ "**/*.exe",
188
+ "**/*.exe~",
189
+ "**/*.dll",
190
+ "**/*.so",
191
+ "**/*.dylib",
192
+ "**/*.test",
193
+ "**/*.out",
194
+ "**/go.work",
195
+ "**/go.work.sum",
196
+ # Rust
197
+ "**/target/**",
198
+ "**/Cargo.lock",
199
+ "**/*.pdb",
200
+ # Ruby
201
+ "**/vendor/**",
202
+ "**/.bundle/**",
203
+ "**/Gemfile.lock",
204
+ "**/*.gem",
205
+ "**/.rvm/**",
206
+ "**/.rbenv/**",
207
+ "**/coverage/**",
208
+ "**/.yardoc/**",
209
+ "**/doc/**",
210
+ "**/rdoc/**",
211
+ "**/.sass-cache/**",
212
+ "**/.jekyll-cache/**",
213
+ "**/_site/**",
214
+ # PHP
215
+ "**/vendor/**",
216
+ "**/composer.lock",
217
+ "**/.phpunit.result.cache",
218
+ "**/storage/logs/**",
219
+ "**/storage/framework/cache/**",
220
+ "**/storage/framework/sessions/**",
221
+ "**/storage/framework/testing/**",
222
+ "**/storage/framework/views/**",
223
+ "**/bootstrap/cache/**",
224
+ # .NET / C#
225
+ "**/bin/**",
226
+ "**/obj/**",
227
+ "**/packages/**",
228
+ "**/*.cache",
229
+ "**/*.dll",
230
+ "**/*.exe",
231
+ "**/*.pdb",
232
+ "**/*.user",
233
+ "**/*.suo",
234
+ "**/.vs/**",
235
+ "**/TestResults/**",
236
+ "**/BenchmarkDotNet.Artifacts/**",
237
+ # C/C++
238
+ "**/*.o",
239
+ "**/*.obj",
240
+ "**/*.so",
241
+ "**/*.dll",
242
+ "**/*.a",
243
+ "**/*.lib",
244
+ "**/*.dylib",
245
+ "**/*.exe",
246
+ "**/CMakeFiles/**",
247
+ "**/CMakeCache.txt",
248
+ "**/cmake_install.cmake",
249
+ "**/Makefile",
250
+ "**/compile_commands.json",
251
+ "**/.deps/**",
252
+ "**/.libs/**",
253
+ "**/autom4te.cache/**",
254
+ # Perl
255
+ "**/blib/**",
256
+ "**/_build/**",
257
+ "**/Build",
258
+ "**/Build.bat",
259
+ "**/*.tmp",
260
+ "**/*.bak",
261
+ "**/*.old",
262
+ "**/Makefile.old",
263
+ "**/MANIFEST.bak",
264
+ "**/META.yml",
265
+ "**/META.json",
266
+ "**/MYMETA.*",
267
+ "**/.prove",
268
+ # Scala
269
+ "**/target/**",
270
+ "**/project/target/**",
271
+ "**/project/project/**",
272
+ "**/.bloop/**",
273
+ "**/.metals/**",
274
+ "**/.ammonite/**",
275
+ "**/*.class",
276
+ # Elixir
277
+ "**/_build/**",
278
+ "**/deps/**",
279
+ "**/*.beam",
280
+ "**/.fetch",
281
+ "**/erl_crash.dump",
282
+ "**/*.ez",
283
+ "**/doc/**",
284
+ "**/.elixir_ls/**",
285
+ # Swift
286
+ "**/.build/**",
287
+ "**/Packages/**",
288
+ "**/*.xcodeproj/**",
289
+ "**/*.xcworkspace/**",
290
+ "**/DerivedData/**",
291
+ "**/xcuserdata/**",
292
+ "**/*.dSYM/**",
293
+ # Kotlin
294
+ "**/build/**",
295
+ "**/.gradle/**",
296
+ "**/*.class",
297
+ "**/*.jar",
298
+ "**/*.kotlin_module",
299
+ # Clojure
300
+ "**/target/**",
301
+ "**/.lein-**",
302
+ "**/.nrepl-port",
303
+ "**/pom.xml.asc",
304
+ "**/*.jar",
305
+ "**/*.class",
306
+ # Dart/Flutter
307
+ "**/.dart_tool/**",
308
+ "**/build/**",
309
+ "**/.packages",
310
+ "**/pubspec.lock",
311
+ "**/*.g.dart",
312
+ "**/*.freezed.dart",
313
+ "**/*.gr.dart",
314
+ # Haskell
315
+ "**/dist/**",
316
+ "**/dist-newstyle/**",
317
+ "**/.stack-work/**",
318
+ "**/*.hi",
319
+ "**/*.o",
320
+ "**/*.prof",
321
+ "**/*.aux",
322
+ "**/*.hp",
323
+ "**/*.eventlog",
324
+ "**/*.tix",
325
+ # Erlang
326
+ "**/ebin/**",
327
+ "**/rel/**",
328
+ "**/deps/**",
329
+ "**/*.beam",
330
+ "**/*.boot",
331
+ "**/*.plt",
332
+ "**/erl_crash.dump",
333
+ # Common cache and temp directories
334
+ "**/.cache/**",
335
+ "**/cache/**",
336
+ "**/tmp/**",
337
+ "**/temp/**",
338
+ "**/.tmp/**",
339
+ "**/.temp/**",
340
+ "**/logs/**",
341
+ "**/*.log",
342
+ "**/*.log.*",
343
+ # IDE and editor files
344
+ "**/.idea/**",
345
+ "**/.idea",
346
+ "**/.vscode/**",
347
+ "**/.vscode",
348
+ "**/*.swp",
349
+ "**/*.swo",
350
+ "**/*~",
351
+ "**/.#*",
352
+ "**/#*#",
353
+ "**/.emacs.d/auto-save-list/**",
354
+ "**/.vim/**",
355
+ "**/.netrwhist",
356
+ "**/Session.vim",
357
+ "**/.sublime-project",
358
+ "**/.sublime-workspace",
359
+ # OS-specific files
360
+ "**/.DS_Store",
361
+ ".DS_Store",
362
+ "**/Thumbs.db",
363
+ "**/Desktop.ini",
364
+ "**/.directory",
365
+ "**/*.lnk",
366
+ # Common artifacts
367
+ "**/*.orig",
368
+ "**/*.rej",
369
+ "**/*.patch",
370
+ "**/*.diff",
371
+ "**/.*.orig",
372
+ "**/.*.rej",
373
+ # Backup files
374
+ "**/*~",
375
+ "**/*.bak",
376
+ "**/*.backup",
377
+ "**/*.old",
378
+ "**/*.save",
379
+ # Hidden files (but be careful with this one)
380
+ "**/.*", # Commented out as it might be too aggressive
381
+ # Directory-only section ends here
382
+ ]
383
+
384
+ FILE_IGNORE_PATTERNS = [
385
+ # Binary image formats
386
+ "**/*.png",
387
+ "**/*.jpg",
388
+ "**/*.jpeg",
389
+ "**/*.gif",
390
+ "**/*.bmp",
391
+ "**/*.tiff",
392
+ "**/*.tif",
393
+ "**/*.webp",
394
+ "**/*.ico",
395
+ "**/*.svg",
396
+ # Binary document formats
397
+ "**/*.pdf",
398
+ "**/*.doc",
399
+ "**/*.docx",
400
+ "**/*.xls",
401
+ "**/*.xlsx",
402
+ "**/*.ppt",
403
+ "**/*.pptx",
404
+ # Archive formats
405
+ "**/*.zip",
406
+ "**/*.tar",
407
+ "**/*.gz",
408
+ "**/*.bz2",
409
+ "**/*.xz",
410
+ "**/*.rar",
411
+ "**/*.7z",
412
+ # Media files
413
+ "**/*.mp3",
414
+ "**/*.mp4",
415
+ "**/*.avi",
416
+ "**/*.mov",
417
+ "**/*.wmv",
418
+ "**/*.flv",
419
+ "**/*.wav",
420
+ "**/*.ogg",
421
+ # Font files
422
+ "**/*.ttf",
423
+ "**/*.otf",
424
+ "**/*.woff",
425
+ "**/*.woff2",
426
+ "**/*.eot",
427
+ # Other binary formats
428
+ "**/*.bin",
429
+ "**/*.dat",
430
+ "**/*.db",
431
+ "**/*.sqlite",
432
+ "**/*.sqlite3",
433
+ ]
434
+
435
+ # Backwards compatibility for any imports still referring to IGNORE_PATTERNS
436
+ IGNORE_PATTERNS = DIR_IGNORE_PATTERNS + FILE_IGNORE_PATTERNS
437
+
438
+
439
+ def should_ignore_path(path: str) -> bool:
440
+ """Return True if *path* matches any pattern in IGNORE_PATTERNS."""
441
+ # Convert path to Path object for better pattern matching
442
+ path_obj = Path(path)
443
+
444
+ for pattern in IGNORE_PATTERNS:
445
+ # Try pathlib's match method which handles ** patterns properly
446
+ try:
447
+ if path_obj.match(pattern):
448
+ return True
449
+ except ValueError:
450
+ # If pathlib can't handle the pattern, fall back to fnmatch
451
+ if fnmatch.fnmatch(path, pattern):
452
+ return True
453
+
454
+ # Additional check: if pattern contains **, try matching against
455
+ # different parts of the path to handle edge cases
456
+ if "**" in pattern:
457
+ # Convert pattern to handle different path representations
458
+ simplified_pattern = pattern.replace("**/", "").replace("/**", "")
459
+
460
+ # Check if any part of the path matches the simplified pattern
461
+ path_parts = path_obj.parts
462
+ for i in range(len(path_parts)):
463
+ subpath = Path(*path_parts[i:])
464
+ if fnmatch.fnmatch(str(subpath), simplified_pattern):
465
+ return True
466
+ # Also check individual parts
467
+ if fnmatch.fnmatch(path_parts[i], simplified_pattern):
468
+ return True
469
+
470
+ return False
471
+
472
+
473
+ def should_ignore_dir_path(path: str) -> bool:
474
+ """Return True if path matches any directory ignore pattern (directories only)."""
475
+ path_obj = Path(path)
476
+ for pattern in DIR_IGNORE_PATTERNS:
477
+ try:
478
+ if path_obj.match(pattern):
479
+ return True
480
+ except ValueError:
481
+ if fnmatch.fnmatch(path, pattern):
482
+ return True
483
+ if "**" in pattern:
484
+ simplified = pattern.replace("**/", "").replace("/**", "")
485
+ parts = path_obj.parts
486
+ for i in range(len(parts)):
487
+ subpath = Path(*parts[i:])
488
+ if fnmatch.fnmatch(str(subpath), simplified):
489
+ return True
490
+ if fnmatch.fnmatch(parts[i], simplified):
491
+ return True
492
+ return False
493
+
494
+
495
+ # ============================================================================
496
+ # SYNTAX HIGHLIGHTING FOR DIFFS ("syntax" mode)
497
+ # ============================================================================
498
+
499
+ # Monokai color scheme - because we have taste 🎨
500
+ TOKEN_COLORS = (
501
+ {
502
+ Token.Keyword: "#f92672" if PYGMENTS_AVAILABLE else "magenta",
503
+ Token.Name.Builtin: "#66d9ef" if PYGMENTS_AVAILABLE else "cyan",
504
+ Token.Name.Function: "#a6e22e" if PYGMENTS_AVAILABLE else "green",
505
+ Token.String: "#e6db74" if PYGMENTS_AVAILABLE else "yellow",
506
+ Token.Number: "#ae81ff" if PYGMENTS_AVAILABLE else "magenta",
507
+ Token.Comment: "#75715e" if PYGMENTS_AVAILABLE else "bright_black",
508
+ Token.Operator: "#f92672" if PYGMENTS_AVAILABLE else "magenta",
509
+ }
510
+ if PYGMENTS_AVAILABLE
511
+ else {}
512
+ )
513
+
514
+ EXTENSION_TO_LEXER_NAME = {
515
+ ".py": "python",
516
+ ".js": "javascript",
517
+ ".jsx": "jsx",
518
+ ".ts": "typescript",
519
+ ".tsx": "tsx",
520
+ ".java": "java",
521
+ ".c": "c",
522
+ ".h": "c",
523
+ ".cpp": "cpp",
524
+ ".hpp": "cpp",
525
+ ".cc": "cpp",
526
+ ".cxx": "cpp",
527
+ ".cs": "csharp",
528
+ ".rs": "rust",
529
+ ".go": "go",
530
+ ".rb": "ruby",
531
+ ".php": "php",
532
+ ".html": "html",
533
+ ".htm": "html",
534
+ ".css": "css",
535
+ ".scss": "scss",
536
+ ".json": "json",
537
+ ".yaml": "yaml",
538
+ ".yml": "yaml",
539
+ ".md": "markdown",
540
+ ".sh": "bash",
541
+ ".bash": "bash",
542
+ ".sql": "sql",
543
+ ".txt": "text",
544
+ }
545
+
546
+
547
+ def _get_lexer_for_extension(extension: str):
548
+ """Get the appropriate Pygments lexer for a file extension.
549
+
550
+ Args:
551
+ extension: File extension (with or without leading dot)
552
+
553
+ Returns:
554
+ A Pygments lexer instance or None if Pygments not available
555
+ """
556
+ if not PYGMENTS_AVAILABLE:
557
+ return None
558
+
559
+ # Normalize extension to have leading dot and be lowercase
560
+ if not extension.startswith("."):
561
+ extension = f".{extension}"
562
+ extension = extension.lower()
563
+
564
+ lexer_name = EXTENSION_TO_LEXER_NAME.get(extension, "text")
565
+
566
+ try:
567
+ return get_lexer_by_name(lexer_name)
568
+ except Exception:
569
+ # Fallback to plain text if lexer not found
570
+ return TextLexer()
571
+
572
+
573
+ def _get_token_color(token_type) -> str:
574
+ """Get color for a token type from our Monokai scheme.
575
+
576
+ Args:
577
+ token_type: Pygments token type
578
+
579
+ Returns:
580
+ Hex color string or color name
581
+ """
582
+ if not PYGMENTS_AVAILABLE:
583
+ return "#cccccc"
584
+
585
+ for ttype, color in TOKEN_COLORS.items():
586
+ if token_type in ttype:
587
+ return color
588
+ return "#cccccc" # Default light-grey for unmatched tokens
589
+
590
+
591
+ def _highlight_code_line(code: str, bg_color: str | None, lexer) -> Text:
592
+ """Highlight a line of code with syntax highlighting and optional background color.
593
+
594
+ Args:
595
+ code: The code string to highlight
596
+ bg_color: Background color in hex format, or None for no background
597
+ lexer: Pygments lexer instance to use
598
+
599
+ Returns:
600
+ Rich Text object with styling applied
601
+ """
602
+ if not PYGMENTS_AVAILABLE or lexer is None:
603
+ # Fallback: just return text with optional background
604
+ if bg_color:
605
+ return Text(code, style=f"on {bg_color}")
606
+ return Text(code)
607
+
608
+ text = Text()
609
+
610
+ for token_type, value in lex(code, lexer):
611
+ # Strip trailing newlines that Pygments adds
612
+ # Pygments lexer always adds a \n at the end of the last token
613
+ value = value.rstrip("\n")
614
+
615
+ # Skip if the value is now empty (was only whitespace/newlines)
616
+ if not value:
617
+ continue
618
+
619
+ fg_color = _get_token_color(token_type)
620
+ # Apply foreground color and optional background
621
+ if bg_color:
622
+ text.append(value, style=f"{fg_color} on {bg_color}")
623
+ else:
624
+ text.append(value, style=fg_color)
625
+
626
+ return text
627
+
628
+
629
+ def _extract_file_extension_from_diff(diff_text: str) -> str:
630
+ """Extract file extension from diff headers.
631
+
632
+ Args:
633
+ diff_text: Unified diff text
634
+
635
+ Returns:
636
+ File extension (e.g., '.py') or '.txt' as fallback
637
+ """
638
+ import re
639
+
640
+ # Look for +++ b/filename.ext or --- a/filename.ext headers
641
+ pattern = r"^(?:\+\+\+|---) [ab]/.*?(\.[a-zA-Z0-9]+)$"
642
+
643
+ for line in diff_text.split("\n")[:10]: # Check first 10 lines
644
+ match = re.search(pattern, line)
645
+ if match:
646
+ return match.group(1)
647
+
648
+ return ".txt" # Fallback to plain text
649
+
650
+
651
+ # ============================================================================
652
+ # COLOR PAIR OPTIMIZATION (for "highlighted" mode)
653
+ # ============================================================================
654
+
655
+
656
+ def brighten_hex(hex_color: str, factor: float) -> str:
657
+ """
658
+ Darken a hex color by multiplying each RGB channel by `factor`.
659
+ factor=1.0 -> no change
660
+ factor=0.0 -> black
661
+ factor=0.18 -> good for diff backgrounds (recommended)
662
+ """
663
+ hex_color = hex_color.lstrip("#")
664
+ if len(hex_color) != 6:
665
+ raise ValueError(f"Expected #RRGGBB, got {hex_color!r}")
666
+
667
+ r = int(hex_color[0:2], 16)
668
+ g = int(hex_color[2:4], 16)
669
+ b = int(hex_color[4:6], 16)
670
+
671
+ r = max(0, min(255, int(r * (1 + factor))))
672
+ g = max(0, min(255, int(g * (1 + factor))))
673
+ b = max(0, min(255, int(b * (1 + factor))))
674
+
675
+ return f"#{r:02x}{g:02x}{b:02x}"
676
+
677
+
678
+ def _format_diff_with_syntax_highlighting(
679
+ diff_text: str,
680
+ addition_color: str | None = None,
681
+ deletion_color: str | None = None,
682
+ ) -> Text:
683
+ """Format diff with full syntax highlighting using Pygments.
684
+
685
+ This renders diffs with:
686
+ - Syntax highlighting for code tokens
687
+ - Colored backgrounds for context/added/removed lines
688
+ - Monokai color scheme
689
+ - Optional custom colors for additions/deletions
690
+
691
+ Args:
692
+ diff_text: Raw unified diff text
693
+ addition_color: Optional custom color for added lines (default: green)
694
+ deletion_color: Optional custom color for deleted lines (default: red)
695
+
696
+ Returns:
697
+ Rich Text object with syntax highlighting (can be passed to emit_info)
698
+ """
699
+ if not PYGMENTS_AVAILABLE:
700
+ return Text(diff_text)
701
+
702
+ # Extract file extension from diff headers
703
+ extension = _extract_file_extension_from_diff(diff_text)
704
+ lexer = _get_lexer_for_extension(extension)
705
+
706
+ # Generate background colors from foreground colors
707
+ add_fg = brighten_hex(addition_color, 0.6)
708
+ del_fg = brighten_hex(deletion_color, 0.6)
709
+
710
+ # Background colors for different line types
711
+ # Context lines have no background (None) for clean, minimal diffs
712
+ bg_colors = {
713
+ "removed": deletion_color,
714
+ "added": addition_color,
715
+ "context": None, # No background for unchanged lines
716
+ }
717
+
718
+ lines = diff_text.split("\n")
719
+ # Remove trailing empty line if it exists (from trailing \n in diff)
720
+ if lines and lines[-1] == "":
721
+ lines = lines[:-1]
722
+ result = Text()
723
+
724
+ for i, line in enumerate(lines):
725
+ if not line:
726
+ # Empty line - just add a newline if not the last line
727
+ if i < len(lines) - 1:
728
+ result.append("\n")
729
+ continue
730
+
731
+ # Skip diff headers - they're redundant noise since we show the filename in the banner
732
+ if line.startswith(("---", "+++", "@@", "diff ", "index ")):
733
+ continue
734
+ else:
735
+ # Determine line type and extract code content
736
+ if line.startswith("-"):
737
+ line_type = "removed"
738
+ code = line[1:] # Remove the '-' prefix
739
+ marker_style = f"bold {del_fg} on {bg_colors[line_type]}"
740
+ prefix = "- "
741
+ elif line.startswith("+"):
742
+ line_type = "added"
743
+ code = line[1:] # Remove the '+' prefix
744
+ marker_style = f"bold {add_fg} on {bg_colors[line_type]}"
745
+ prefix = "+ "
746
+ else:
747
+ line_type = "context"
748
+ code = line[1:] if line.startswith(" ") else line
749
+ # Context lines have no background - clean and minimal
750
+ marker_style = "" # No special styling for context markers
751
+ prefix = " "
752
+
753
+ # Add the marker prefix
754
+ if marker_style: # Only apply style if we have one
755
+ result.append(prefix, style=marker_style)
756
+ else:
757
+ result.append(prefix)
758
+
759
+ # Add syntax-highlighted code
760
+ highlighted = _highlight_code_line(code, bg_colors[line_type], lexer)
761
+ result.append_text(highlighted)
762
+
763
+ # Add newline after each line except the last
764
+ if i < len(lines) - 1:
765
+ result.append("\n")
766
+
767
+ return result
768
+
769
+
770
+ def format_diff_with_colors(diff_text: str) -> Text:
771
+ """Format diff text with beautiful syntax highlighting.
772
+
773
+ This is the canonical diff formatting function used across the codebase.
774
+ It applies user-configurable color coding with full syntax highlighting using Pygments.
775
+
776
+ The function respects user preferences from config:
777
+ - get_diff_addition_color(): Color for added lines (markers and backgrounds)
778
+ - get_diff_deletion_color(): Color for deleted lines (markers and backgrounds)
779
+
780
+ Args:
781
+ diff_text: Raw diff text to format
782
+
783
+ Returns:
784
+ Rich Text object with syntax highlighting
785
+ """
786
+ from code_puppy.config import (
787
+ get_diff_addition_color,
788
+ get_diff_deletion_color,
789
+ )
790
+
791
+ if not diff_text or not diff_text.strip():
792
+ return Text("-- no diff available --", style="dim")
793
+
794
+ addition_base_color = get_diff_addition_color()
795
+ deletion_base_color = get_diff_deletion_color()
796
+
797
+ # Always use beautiful syntax highlighting!
798
+ if not PYGMENTS_AVAILABLE:
799
+ emit_warning("Pygments not available, diffs will look plain")
800
+ # Return plain text as fallback
801
+ return Text(diff_text)
802
+
803
+ # Return Text object with custom colors - emit_info handles this correctly
804
+ return _format_diff_with_syntax_highlighting(
805
+ diff_text,
806
+ addition_color=addition_base_color,
807
+ deletion_color=deletion_base_color,
808
+ )
809
+
810
+
811
+ async def arrow_select_async(
812
+ message: str,
813
+ choices: list[str],
814
+ preview_callback: Optional[Callable[[int], str]] = None,
815
+ ) -> str:
816
+ """Async version: Show an arrow-key navigable selector with optional preview.
817
+
818
+ Args:
819
+ message: The prompt message to display
820
+ choices: List of choice strings
821
+ preview_callback: Optional callback that takes the selected index and returns
822
+ preview text to display below the choices
823
+
824
+ Returns:
825
+ The selected choice string
826
+
827
+ Raises:
828
+ KeyboardInterrupt: If user cancels with Ctrl-C
829
+ """
830
+ import html
831
+
832
+ selected_index = [0] # Mutable container for selected index
833
+ result = [None] # Mutable container for result
834
+
835
+ def get_formatted_text():
836
+ """Generate the formatted text for display."""
837
+ # Escape XML special characters to prevent parsing errors
838
+ safe_message = html.escape(message)
839
+ lines = [f"<b>{safe_message}</b>", ""]
840
+ for i, choice in enumerate(choices):
841
+ safe_choice = html.escape(choice)
842
+ if i == selected_index[0]:
843
+ lines.append(f"<ansigreen>❯ {safe_choice}</ansigreen>")
844
+ else:
845
+ lines.append(f" {safe_choice}")
846
+ lines.append("")
847
+
848
+ # Add preview section if callback provided
849
+ if preview_callback is not None:
850
+ preview_text = preview_callback(selected_index[0])
851
+ if preview_text:
852
+ import textwrap
853
+
854
+ # Box width (excluding borders and padding)
855
+ box_width = 60
856
+ border_top = (
857
+ "<ansiyellow>┌─ Preview "
858
+ + "─" * (box_width - 10)
859
+ + "┐</ansiyellow>"
860
+ )
861
+ border_bottom = "<ansiyellow>└" + "─" * box_width + "┘</ansiyellow>"
862
+
863
+ lines.append(border_top)
864
+
865
+ # Wrap text to fit within box width (minus padding)
866
+ wrapped_lines = textwrap.wrap(preview_text, width=box_width - 2)
867
+
868
+ # If no wrapped lines (empty text), add empty line
869
+ if not wrapped_lines:
870
+ wrapped_lines = [""]
871
+
872
+ for wrapped_line in wrapped_lines:
873
+ safe_preview = html.escape(wrapped_line)
874
+ # Pad line to box width for consistent appearance
875
+ padded_line = safe_preview.ljust(box_width - 2)
876
+ lines.append(f"<dim>│ {padded_line} │</dim>")
877
+
878
+ lines.append(border_bottom)
879
+ lines.append("")
880
+
881
+ lines.append(
882
+ "<ansicyan>(Use ↑↓ or Ctrl+P/N to select, Enter to confirm)</ansicyan>"
883
+ )
884
+ return HTML("\n".join(lines))
885
+
886
+ # Key bindings
887
+ kb = KeyBindings()
888
+
889
+ @kb.add("up")
890
+ @kb.add("c-p") # Ctrl+P = previous (Emacs-style)
891
+ def move_up(event):
892
+ selected_index[0] = (selected_index[0] - 1) % len(choices)
893
+ event.app.invalidate() # Force redraw to update preview
894
+
895
+ @kb.add("down")
896
+ @kb.add("c-n") # Ctrl+N = next (Emacs-style)
897
+ def move_down(event):
898
+ selected_index[0] = (selected_index[0] + 1) % len(choices)
899
+ event.app.invalidate() # Force redraw to update preview
900
+
901
+ @kb.add("enter")
902
+ def accept(event):
903
+ result[0] = choices[selected_index[0]]
904
+ event.app.exit()
905
+
906
+ @kb.add("c-c") # Ctrl-C
907
+ def cancel(event):
908
+ result[0] = None
909
+ event.app.exit()
910
+
911
+ # Layout
912
+ control = FormattedTextControl(get_formatted_text)
913
+ layout = Layout(Window(content=control))
914
+
915
+ # Application
916
+ app = Application(
917
+ layout=layout,
918
+ key_bindings=kb,
919
+ full_screen=False,
920
+ )
921
+
922
+ # Flush output before prompt_toolkit takes control
923
+ sys.stdout.flush()
924
+ sys.stderr.flush()
925
+
926
+ # Run the app asynchronously
927
+ await app.run_async()
928
+
929
+ if result[0] is None:
930
+ raise KeyboardInterrupt()
931
+
932
+ return result[0]
933
+
934
+
935
+ def arrow_select(message: str, choices: list[str]) -> str:
936
+ """Show an arrow-key navigable selector (synchronous version).
937
+
938
+ Args:
939
+ message: The prompt message to display
940
+ choices: List of choice strings
941
+
942
+ Returns:
943
+ The selected choice string
944
+
945
+ Raises:
946
+ KeyboardInterrupt: If user cancels with Ctrl-C
947
+ """
948
+
949
+ selected_index = [0] # Mutable container for selected index
950
+ result = [None] # Mutable container for result
951
+
952
+ def get_formatted_text():
953
+ """Generate the formatted text for display."""
954
+ lines = [f"<b>{message}</b>", ""]
955
+ for i, choice in enumerate(choices):
956
+ if i == selected_index[0]:
957
+ lines.append(f"<ansigreen>❯ {choice}</ansigreen>")
958
+ else:
959
+ lines.append(f" {choice}")
960
+ lines.append("")
961
+ lines.append(
962
+ "<ansicyan>(Use ↑↓ or Ctrl+P/N to select, Enter to confirm)</ansicyan>"
963
+ )
964
+ return HTML("\n".join(lines))
965
+
966
+ # Key bindings
967
+ kb = KeyBindings()
968
+
969
+ @kb.add("up")
970
+ @kb.add("c-p") # Ctrl+P = previous (Emacs-style)
971
+ def move_up(event):
972
+ selected_index[0] = (selected_index[0] - 1) % len(choices)
973
+ event.app.invalidate() # Force redraw to update preview
974
+
975
+ @kb.add("down")
976
+ @kb.add("c-n") # Ctrl+N = next (Emacs-style)
977
+ def move_down(event):
978
+ selected_index[0] = (selected_index[0] + 1) % len(choices)
979
+ event.app.invalidate() # Force redraw to update preview
980
+
981
+ @kb.add("enter")
982
+ def accept(event):
983
+ result[0] = choices[selected_index[0]]
984
+ event.app.exit()
985
+
986
+ @kb.add("c-c") # Ctrl-C
987
+ def cancel(event):
988
+ result[0] = None
989
+ event.app.exit()
990
+
991
+ # Layout
992
+ control = FormattedTextControl(get_formatted_text)
993
+ layout = Layout(Window(content=control))
994
+
995
+ # Application
996
+ app = Application(
997
+ layout=layout,
998
+ key_bindings=kb,
999
+ full_screen=False,
1000
+ )
1001
+
1002
+ # Flush output before prompt_toolkit takes control
1003
+ sys.stdout.flush()
1004
+ sys.stderr.flush()
1005
+
1006
+ # Check if we're already in an async context
1007
+ try:
1008
+ asyncio.get_running_loop()
1009
+ # We're in an async context - can't use app.run()
1010
+ # Caller should use arrow_select_async instead
1011
+ raise RuntimeError(
1012
+ "arrow_select() called from async context. Use arrow_select_async() instead."
1013
+ )
1014
+ except RuntimeError as e:
1015
+ if "no running event loop" in str(e).lower():
1016
+ # No event loop, safe to use app.run()
1017
+ app.run()
1018
+ else:
1019
+ # Re-raise if it's our error message
1020
+ raise
1021
+
1022
+ if result[0] is None:
1023
+ raise KeyboardInterrupt()
1024
+
1025
+ return result[0]
1026
+
1027
+
1028
+ def get_user_approval(
1029
+ title: str,
1030
+ content: Text | str,
1031
+ preview: str | None = None,
1032
+ border_style: str = "dim white",
1033
+ puppy_name: str | None = None,
1034
+ ) -> tuple[bool, str | None]:
1035
+ """Show a beautiful approval panel with arrow-key selector.
1036
+
1037
+ Args:
1038
+ title: Title for the panel (e.g., "File Operation", "Shell Command")
1039
+ content: Main content to display (Rich Text object or string)
1040
+ preview: Optional preview content (like a diff)
1041
+ border_style: Border color/style for the panel
1042
+ puppy_name: Name of the assistant (defaults to config value)
1043
+
1044
+ Returns:
1045
+ Tuple of (confirmed: bool, user_feedback: str | None)
1046
+ - confirmed: True if approved, False if rejected
1047
+ - user_feedback: Optional feedback text if user provided it
1048
+ """
1049
+ import time
1050
+
1051
+ from code_puppy.tools.command_runner import set_awaiting_user_input
1052
+
1053
+ if puppy_name is None:
1054
+ from code_puppy.config import get_puppy_name
1055
+
1056
+ puppy_name = get_puppy_name().title()
1057
+
1058
+ # Build panel content
1059
+ if isinstance(content, str):
1060
+ panel_content = Text(content)
1061
+ else:
1062
+ panel_content = content
1063
+
1064
+ # Add preview if provided
1065
+ if preview:
1066
+ panel_content.append("\n\n", style="")
1067
+ panel_content.append("Preview of changes:", style="bold underline")
1068
+ panel_content.append("\n", style="")
1069
+ formatted_preview = format_diff_with_colors(preview)
1070
+
1071
+ # Handle both string (text mode) and Text object (highlight mode)
1072
+ if isinstance(formatted_preview, Text):
1073
+ preview_text = formatted_preview
1074
+ else:
1075
+ preview_text = Text.from_markup(formatted_preview)
1076
+
1077
+ panel_content.append(preview_text)
1078
+
1079
+ # Mark that we showed a diff preview
1080
+ try:
1081
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
1082
+ set_diff_already_shown,
1083
+ )
1084
+
1085
+ set_diff_already_shown(True)
1086
+ except ImportError:
1087
+ pass
1088
+
1089
+ # Create panel
1090
+ panel = Panel(
1091
+ panel_content,
1092
+ title=f"[bold white]{title}[/bold white]",
1093
+ border_style=border_style,
1094
+ padding=(1, 2),
1095
+ )
1096
+
1097
+ # Pause spinners BEFORE showing panel
1098
+ set_awaiting_user_input(True)
1099
+ # Also explicitly pause spinners to ensure they're fully stopped
1100
+ try:
1101
+ from code_puppy.messaging.spinner import pause_all_spinners
1102
+
1103
+ pause_all_spinners()
1104
+ except (ImportError, Exception):
1105
+ pass
1106
+
1107
+ time.sleep(0.3) # Let spinners fully stop
1108
+
1109
+ # Display panel
1110
+ local_console = Console()
1111
+ emit_info("")
1112
+ local_console.print(panel)
1113
+ emit_info("")
1114
+
1115
+ # Flush and buffer before selector
1116
+ sys.stdout.flush()
1117
+ sys.stderr.flush()
1118
+ time.sleep(0.1)
1119
+
1120
+ user_feedback = None
1121
+ confirmed = False
1122
+
1123
+ try:
1124
+ # Final flush
1125
+ sys.stdout.flush()
1126
+
1127
+ # Show arrow-key selector
1128
+ choice = arrow_select(
1129
+ "💭 What would you like to do?",
1130
+ [
1131
+ "✓ Approve",
1132
+ "✗ Reject",
1133
+ f"💬 Reject with feedback (tell {puppy_name} what to change)",
1134
+ ],
1135
+ )
1136
+
1137
+ if choice == "✓ Approve":
1138
+ confirmed = True
1139
+ elif choice == "✗ Reject":
1140
+ confirmed = False
1141
+ else:
1142
+ # User wants to provide feedback
1143
+ confirmed = False
1144
+ emit_info("")
1145
+ emit_info(f"Tell {puppy_name} what to change:")
1146
+ user_feedback = Prompt.ask(
1147
+ "[bold green]➤[/bold green]",
1148
+ default="",
1149
+ ).strip()
1150
+
1151
+ if not user_feedback:
1152
+ user_feedback = None
1153
+
1154
+ except (KeyboardInterrupt, EOFError):
1155
+ emit_error("Cancelled by user")
1156
+ confirmed = False
1157
+
1158
+ finally:
1159
+ set_awaiting_user_input(False)
1160
+
1161
+ # Force Rich console to reset display state to prevent artifacts
1162
+ try:
1163
+ # Clear Rich's internal display state to prevent artifacts
1164
+ local_console.file.write("\r") # Return to start of line
1165
+ local_console.file.write("\x1b[K") # Clear current line
1166
+ local_console.file.flush()
1167
+ except Exception:
1168
+ pass
1169
+
1170
+ # Ensure streams are flushed
1171
+ sys.stdout.flush()
1172
+ sys.stderr.flush()
1173
+
1174
+ # Show result BEFORE resuming spinners (no puppy litter!)
1175
+ emit_info("")
1176
+ if not confirmed:
1177
+ if user_feedback:
1178
+ emit_error("Rejected with feedback!")
1179
+ emit_warning(f'Telling {puppy_name}: "{user_feedback}"')
1180
+ else:
1181
+ emit_error("Rejected.")
1182
+ else:
1183
+ emit_success("Approved!")
1184
+
1185
+ # NOW resume spinners after showing the result
1186
+ try:
1187
+ from code_puppy.messaging.spinner import resume_all_spinners
1188
+
1189
+ resume_all_spinners()
1190
+ except (ImportError, Exception):
1191
+ pass
1192
+
1193
+ return confirmed, user_feedback
1194
+
1195
+
1196
+ async def get_user_approval_async(
1197
+ title: str,
1198
+ content: Text | str,
1199
+ preview: str | None = None,
1200
+ border_style: str = "dim white",
1201
+ puppy_name: str | None = None,
1202
+ ) -> tuple[bool, str | None]:
1203
+ """Async version of get_user_approval - show a beautiful approval panel with arrow-key selector.
1204
+
1205
+ Args:
1206
+ title: Title for the panel (e.g., "File Operation", "Shell Command")
1207
+ content: Main content to display (Rich Text object or string)
1208
+ preview: Optional preview content (like a diff)
1209
+ border_style: Border color/style for the panel
1210
+ puppy_name: Name of the assistant (defaults to config value)
1211
+
1212
+ Returns:
1213
+ Tuple of (confirmed: bool, user_feedback: str | None)
1214
+ - confirmed: True if approved, False if rejected
1215
+ - user_feedback: Optional feedback text if user provided it
1216
+ """
1217
+
1218
+ from code_puppy.tools.command_runner import set_awaiting_user_input
1219
+
1220
+ if puppy_name is None:
1221
+ from code_puppy.config import get_puppy_name
1222
+
1223
+ puppy_name = get_puppy_name().title()
1224
+
1225
+ # Build panel content
1226
+ if isinstance(content, str):
1227
+ panel_content = Text(content)
1228
+ else:
1229
+ panel_content = content
1230
+
1231
+ # Add preview if provided
1232
+ if preview:
1233
+ panel_content.append("\n\n", style="")
1234
+ panel_content.append("Preview of changes:", style="bold underline")
1235
+ panel_content.append("\n", style="")
1236
+ formatted_preview = format_diff_with_colors(preview)
1237
+
1238
+ # Handle both string (text mode) and Text object (highlight mode)
1239
+ if isinstance(formatted_preview, Text):
1240
+ preview_text = formatted_preview
1241
+ else:
1242
+ preview_text = Text.from_markup(formatted_preview)
1243
+
1244
+ panel_content.append(preview_text)
1245
+
1246
+ # Mark that we showed a diff preview
1247
+ try:
1248
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
1249
+ set_diff_already_shown,
1250
+ )
1251
+
1252
+ set_diff_already_shown(True)
1253
+ except ImportError:
1254
+ pass
1255
+
1256
+ # Create panel
1257
+ panel = Panel(
1258
+ panel_content,
1259
+ title=f"[bold white]{title}[/bold white]",
1260
+ border_style=border_style,
1261
+ padding=(1, 2),
1262
+ )
1263
+
1264
+ # Pause spinners BEFORE showing panel
1265
+ set_awaiting_user_input(True)
1266
+ # Also explicitly pause spinners to ensure they're fully stopped
1267
+ try:
1268
+ from code_puppy.messaging.spinner import pause_all_spinners
1269
+
1270
+ pause_all_spinners()
1271
+ except (ImportError, Exception):
1272
+ pass
1273
+
1274
+ await asyncio.sleep(0.3) # Let spinners fully stop
1275
+
1276
+ # Display panel
1277
+ local_console = Console()
1278
+ emit_info("")
1279
+ local_console.print(panel)
1280
+ emit_info("")
1281
+
1282
+ # Flush and buffer before selector
1283
+ sys.stdout.flush()
1284
+ sys.stderr.flush()
1285
+ await asyncio.sleep(0.1)
1286
+
1287
+ user_feedback = None
1288
+ confirmed = False
1289
+
1290
+ try:
1291
+ # Final flush
1292
+ sys.stdout.flush()
1293
+
1294
+ # Show arrow-key selector (ASYNC VERSION)
1295
+ choice = await arrow_select_async(
1296
+ "💭 What would you like to do?",
1297
+ [
1298
+ "✓ Approve",
1299
+ "✗ Reject",
1300
+ f"💬 Reject with feedback (tell {puppy_name} what to change)",
1301
+ ],
1302
+ )
1303
+
1304
+ if choice == "✓ Approve":
1305
+ confirmed = True
1306
+ elif choice == "✗ Reject":
1307
+ confirmed = False
1308
+ else:
1309
+ # User wants to provide feedback
1310
+ confirmed = False
1311
+ emit_info("")
1312
+ emit_info(f"Tell {puppy_name} what to change:")
1313
+ user_feedback = Prompt.ask(
1314
+ "[bold green]➤[/bold green]",
1315
+ default="",
1316
+ ).strip()
1317
+
1318
+ if not user_feedback:
1319
+ user_feedback = None
1320
+
1321
+ except (KeyboardInterrupt, EOFError):
1322
+ emit_error("Cancelled by user")
1323
+ confirmed = False
1324
+
1325
+ finally:
1326
+ set_awaiting_user_input(False)
1327
+
1328
+ # Force Rich console to reset display state to prevent artifacts
1329
+ try:
1330
+ # Clear Rich's internal display state to prevent artifacts
1331
+ local_console.file.write("\r") # Return to start of line
1332
+ local_console.file.write("\x1b[K") # Clear current line
1333
+ local_console.file.flush()
1334
+ except Exception:
1335
+ pass
1336
+
1337
+ # Ensure streams are flushed
1338
+ sys.stdout.flush()
1339
+ sys.stderr.flush()
1340
+
1341
+ # Show result BEFORE resuming spinners (no puppy litter!)
1342
+ emit_info("")
1343
+ if not confirmed:
1344
+ if user_feedback:
1345
+ emit_error("Rejected with feedback!")
1346
+ emit_warning(f'Telling {puppy_name}: "{user_feedback}"')
1347
+ else:
1348
+ emit_error("Rejected.")
1349
+ else:
1350
+ emit_success("Approved!")
1351
+
1352
+ # NOW resume spinners after showing the result
1353
+ try:
1354
+ from code_puppy.messaging.spinner import resume_all_spinners
1355
+
1356
+ resume_all_spinners()
1357
+ except (ImportError, Exception):
1358
+ pass
1359
+
1360
+ return confirmed, user_feedback
1361
+
1362
+
1363
+ def _find_best_window(
1364
+ haystack_lines: list[str],
1365
+ needle: str,
1366
+ ) -> Tuple[Optional[Tuple[int, int]], float]:
1367
+ """
1368
+ Return (start, end) indices of the window with the highest
1369
+ Jaro-Winkler similarity to `needle`, along with that score.
1370
+ If nothing clears JW_THRESHOLD, return (None, score).
1371
+ """
1372
+ needle = needle.rstrip("\n")
1373
+ needle_lines = needle.splitlines()
1374
+ win_size = len(needle_lines)
1375
+ best_score = 0.0
1376
+ best_span: Optional[Tuple[int, int]] = None
1377
+ # Pre-join the needle once; join windows on the fly
1378
+ for i in range(len(haystack_lines) - win_size + 1):
1379
+ window = "\n".join(haystack_lines[i : i + win_size])
1380
+ score = JaroWinkler.normalized_similarity(window, needle)
1381
+ if score > best_score:
1382
+ best_score = score
1383
+ best_span = (i, i + win_size)
1384
+
1385
+ return best_span, best_score
1386
+
1387
+
1388
+ def generate_group_id(tool_name: str, extra_context: str = "") -> str:
1389
+ """Generate a unique group_id for tool output grouping.
1390
+
1391
+ Args:
1392
+ tool_name: Name of the tool (e.g., 'list_files', 'edit_file')
1393
+ extra_context: Optional extra context to make group_id more unique
1394
+
1395
+ Returns:
1396
+ A string in format: tool_name_hash
1397
+ """
1398
+ # Create a unique identifier using timestamp, context, and a random component
1399
+ import random
1400
+
1401
+ timestamp = str(int(time.time() * 1000000)) # microseconds for more uniqueness
1402
+ random_component = random.randint(1000, 9999) # Add randomness
1403
+ context_string = f"{tool_name}_{timestamp}_{random_component}_{extra_context}"
1404
+
1405
+ # Generate a short hash
1406
+ hash_obj = hashlib.md5(context_string.encode())
1407
+ short_hash = hash_obj.hexdigest()[:8]
1408
+
1409
+ return f"{tool_name}_{short_hash}"