newcode 0.1.1__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 (289) 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 +147 -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 +630 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +122 -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 +380 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +167 -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 +2145 -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 +296 -0
  28. code_puppy/agents/pack/husky.py +307 -0
  29. code_puppy/agents/pack/retriever.py +380 -0
  30. code_puppy/agents/pack/shepherd.py +327 -0
  31. code_puppy/agents/pack/terrier.py +281 -0
  32. code_puppy/agents/pack/watchdog.py +357 -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 +674 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +664 -0
  49. code_puppy/cli_runner.py +1038 -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 +526 -0
  57. code_puppy/command_line/command_handler.py +283 -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 +853 -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 +91 -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/skills_completion.py +160 -0
  97. code_puppy/command_line/uc_menu.py +893 -0
  98. code_puppy/command_line/utils.py +93 -0
  99. code_puppy/command_line/wiggum_state.py +78 -0
  100. code_puppy/config.py +1787 -0
  101. code_puppy/error_logging.py +133 -0
  102. code_puppy/gemini_code_assist.py +385 -0
  103. code_puppy/gemini_model.py +754 -0
  104. code_puppy/hook_engine/README.md +105 -0
  105. code_puppy/hook_engine/__init__.py +15 -0
  106. code_puppy/hook_engine/aliases.py +155 -0
  107. code_puppy/hook_engine/engine.py +195 -0
  108. code_puppy/hook_engine/executor.py +293 -0
  109. code_puppy/hook_engine/matcher.py +145 -0
  110. code_puppy/hook_engine/models.py +222 -0
  111. code_puppy/hook_engine/registry.py +106 -0
  112. code_puppy/hook_engine/validator.py +141 -0
  113. code_puppy/http_utils.py +361 -0
  114. code_puppy/keymap.py +128 -0
  115. code_puppy/main.py +10 -0
  116. code_puppy/mcp_/__init__.py +66 -0
  117. code_puppy/mcp_/async_lifecycle.py +286 -0
  118. code_puppy/mcp_/blocking_startup.py +469 -0
  119. code_puppy/mcp_/captured_stdio_server.py +275 -0
  120. code_puppy/mcp_/circuit_breaker.py +290 -0
  121. code_puppy/mcp_/config_wizard.py +507 -0
  122. code_puppy/mcp_/dashboard.py +308 -0
  123. code_puppy/mcp_/error_isolation.py +407 -0
  124. code_puppy/mcp_/examples/retry_example.py +226 -0
  125. code_puppy/mcp_/health_monitor.py +589 -0
  126. code_puppy/mcp_/managed_server.py +428 -0
  127. code_puppy/mcp_/manager.py +807 -0
  128. code_puppy/mcp_/mcp_logs.py +224 -0
  129. code_puppy/mcp_/registry.py +451 -0
  130. code_puppy/mcp_/retry_manager.py +337 -0
  131. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  132. code_puppy/mcp_/status_tracker.py +355 -0
  133. code_puppy/mcp_/system_tools.py +209 -0
  134. code_puppy/mcp_prompts/__init__.py +1 -0
  135. code_puppy/mcp_prompts/hook_creator.py +103 -0
  136. code_puppy/messaging/__init__.py +255 -0
  137. code_puppy/messaging/bus.py +613 -0
  138. code_puppy/messaging/commands.py +167 -0
  139. code_puppy/messaging/markdown_patches.py +57 -0
  140. code_puppy/messaging/message_queue.py +361 -0
  141. code_puppy/messaging/messages.py +569 -0
  142. code_puppy/messaging/queue_console.py +271 -0
  143. code_puppy/messaging/renderers.py +311 -0
  144. code_puppy/messaging/rich_renderer.py +1153 -0
  145. code_puppy/messaging/spinner/__init__.py +83 -0
  146. code_puppy/messaging/spinner/console_spinner.py +240 -0
  147. code_puppy/messaging/spinner/spinner_base.py +96 -0
  148. code_puppy/messaging/subagent_console.py +460 -0
  149. code_puppy/model_factory.py +848 -0
  150. code_puppy/model_switching.py +63 -0
  151. code_puppy/model_utils.py +168 -0
  152. code_puppy/models.json +130 -0
  153. code_puppy/models_dev_api.json +1 -0
  154. code_puppy/models_dev_parser.py +592 -0
  155. code_puppy/plugins/__init__.py +186 -0
  156. code_puppy/plugins/agent_skills/__init__.py +22 -0
  157. code_puppy/plugins/agent_skills/config.py +175 -0
  158. code_puppy/plugins/agent_skills/discovery.py +136 -0
  159. code_puppy/plugins/agent_skills/downloader.py +392 -0
  160. code_puppy/plugins/agent_skills/installer.py +22 -0
  161. code_puppy/plugins/agent_skills/metadata.py +219 -0
  162. code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
  163. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  164. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  165. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  166. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  167. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  168. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  169. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  170. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  171. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  172. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  173. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  174. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  175. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  176. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  177. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  178. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  179. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  180. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  181. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  182. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  183. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  184. code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
  185. code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
  186. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  187. code_puppy/plugins/claude_code_hooks/config.py +131 -0
  188. code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
  189. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  190. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  191. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  192. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  193. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  194. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  195. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  196. code_puppy/plugins/claude_code_oauth/utils.py +601 -0
  197. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  198. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  199. code_puppy/plugins/example_custom_command/README.md +280 -0
  200. code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
  201. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  202. code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
  203. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  204. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  205. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  206. code_puppy/plugins/hook_creator/__init__.py +1 -0
  207. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  208. code_puppy/plugins/hook_manager/__init__.py +1 -0
  209. code_puppy/plugins/hook_manager/config.py +277 -0
  210. code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
  211. code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
  212. code_puppy/plugins/oauth_puppy_html.py +224 -0
  213. code_puppy/plugins/scheduler/__init__.py +1 -0
  214. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  215. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  216. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  217. code_puppy/plugins/shell_safety/__init__.py +6 -0
  218. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  219. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  220. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  221. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  222. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  223. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  224. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  225. code_puppy/plugins/universal_constructor/models.py +138 -0
  226. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  227. code_puppy/plugins/universal_constructor/registry.py +302 -0
  228. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  229. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  230. code_puppy/pydantic_patches.py +317 -0
  231. code_puppy/reopenable_async_client.py +232 -0
  232. code_puppy/round_robin_model.py +150 -0
  233. code_puppy/scheduler/__init__.py +41 -0
  234. code_puppy/scheduler/__main__.py +9 -0
  235. code_puppy/scheduler/cli.py +118 -0
  236. code_puppy/scheduler/config.py +126 -0
  237. code_puppy/scheduler/daemon.py +280 -0
  238. code_puppy/scheduler/executor.py +155 -0
  239. code_puppy/scheduler/platform.py +19 -0
  240. code_puppy/scheduler/platform_unix.py +22 -0
  241. code_puppy/scheduler/platform_win.py +32 -0
  242. code_puppy/session_storage.py +338 -0
  243. code_puppy/status_display.py +257 -0
  244. code_puppy/summarization_agent.py +176 -0
  245. code_puppy/terminal_utils.py +418 -0
  246. code_puppy/tools/__init__.py +470 -0
  247. code_puppy/tools/agent_tools.py +616 -0
  248. code_puppy/tools/ask_user_question/__init__.py +26 -0
  249. code_puppy/tools/ask_user_question/constants.py +73 -0
  250. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  251. code_puppy/tools/ask_user_question/handler.py +232 -0
  252. code_puppy/tools/ask_user_question/models.py +304 -0
  253. code_puppy/tools/ask_user_question/registration.py +36 -0
  254. code_puppy/tools/ask_user_question/renderers.py +309 -0
  255. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  256. code_puppy/tools/ask_user_question/theme.py +155 -0
  257. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  258. code_puppy/tools/browser/__init__.py +37 -0
  259. code_puppy/tools/browser/browser_control.py +289 -0
  260. code_puppy/tools/browser/browser_interactions.py +545 -0
  261. code_puppy/tools/browser/browser_locators.py +640 -0
  262. code_puppy/tools/browser/browser_manager.py +378 -0
  263. code_puppy/tools/browser/browser_navigation.py +251 -0
  264. code_puppy/tools/browser/browser_screenshot.py +179 -0
  265. code_puppy/tools/browser/browser_scripts.py +462 -0
  266. code_puppy/tools/browser/browser_workflows.py +221 -0
  267. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  268. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  269. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  270. code_puppy/tools/browser/terminal_tools.py +525 -0
  271. code_puppy/tools/command_runner.py +1346 -0
  272. code_puppy/tools/common.py +1409 -0
  273. code_puppy/tools/display.py +84 -0
  274. code_puppy/tools/file_modifications.py +739 -0
  275. code_puppy/tools/file_operations.py +802 -0
  276. code_puppy/tools/scheduler_tools.py +412 -0
  277. code_puppy/tools/skills_tools.py +251 -0
  278. code_puppy/tools/subagent_context.py +158 -0
  279. code_puppy/tools/tools_content.py +51 -0
  280. code_puppy/tools/universal_constructor.py +889 -0
  281. code_puppy/uvx_detection.py +242 -0
  282. code_puppy/version_checker.py +82 -0
  283. newcode-0.1.1.data/data/code_puppy/models.json +130 -0
  284. newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
  285. newcode-0.1.1.dist-info/METADATA +154 -0
  286. newcode-0.1.1.dist-info/RECORD +289 -0
  287. newcode-0.1.1.dist-info/WHEEL +4 -0
  288. newcode-0.1.1.dist-info/entry_points.txt +3 -0
  289. newcode-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,73 @@
1
+ """Constants for the ask_user_question tool."""
2
+
3
+ from typing import Final
4
+
5
+ # Question constraints
6
+ MAX_QUESTIONS_PER_CALL: Final[int] = 10 # Reasonable limit for a single TUI interaction
7
+ MIN_OPTIONS_PER_QUESTION: Final[int] = 2
8
+ MAX_OPTIONS_PER_QUESTION: Final[int] = 6
9
+ MAX_HEADER_LENGTH: Final[int] = 12
10
+ MAX_LABEL_LENGTH: Final[int] = 50
11
+ MAX_DESCRIPTION_LENGTH: Final[int] = 200
12
+ MAX_QUESTION_LENGTH: Final[int] = 500
13
+ MAX_OTHER_TEXT_LENGTH: Final[int] = 500
14
+
15
+ # UI settings
16
+ DEFAULT_TIMEOUT_SECONDS: Final[int] = 300 # 5 minutes
17
+ TIMEOUT_WARNING_SECONDS: Final[int] = 60 # Show warning at 60s remaining
18
+ AUTO_ADD_OTHER_OPTION: Final[bool] = True
19
+
20
+ # Other option configuration
21
+ OTHER_OPTION_LABEL: Final[str] = "Other"
22
+ OTHER_OPTION_DESCRIPTION: Final[str] = "Enter a custom option"
23
+
24
+ # Left panel width magic numbers (extracted for clarity)
25
+ LEFT_PANEL_PADDING: Final[int] = (
26
+ 14 # left(2) + cursor(2) + checkmark(2) + right(2) + buffer(6)
27
+ )
28
+ MIN_LEFT_PANEL_WIDTH: Final[int] = 21
29
+ MAX_LEFT_PANEL_WIDTH: Final[int] = 36
30
+
31
+ # Horizontal padding for panel content (matches left panel's " " prefix)
32
+ PANEL_CONTENT_PADDING: Final[str] = " "
33
+
34
+ # CI environment variables to check for non-interactive detection
35
+ # Use tuple for true immutability (Final only prevents reassignment, not mutation)
36
+ CI_ENV_VARS: Final[tuple[str, ...]] = (
37
+ "CI",
38
+ "GITHUB_ACTIONS",
39
+ "GITLAB_CI",
40
+ "JENKINS_URL",
41
+ "TRAVIS",
42
+ "CIRCLECI",
43
+ "BUILDKITE",
44
+ "AZURE_PIPELINES",
45
+ "TEAMCITY_VERSION",
46
+ )
47
+
48
+ # Terminal escape sequences for alternate screen buffer
49
+ ENTER_ALT_SCREEN: Final[str] = "\033[?1049h"
50
+ EXIT_ALT_SCREEN: Final[str] = "\033[?1049l"
51
+ CLEAR_AND_HOME: Final[str] = "\033[2J\033[H"
52
+
53
+ # Unicode symbols for TUI rendering
54
+ CURSOR_POINTER: Final[str] = "\u276f" # ❯
55
+ CURSOR_TRIANGLE: Final[str] = "\u25b6" # ▶
56
+ CHECK_MARK: Final[str] = "\u2713" # ✓
57
+ RADIO_FILLED: Final[str] = "\u25cf" # ●
58
+ BORDER_DOUBLE: Final[str] = "\u2550" # ═
59
+ ARROW_LEFT: Final[str] = "\u2190" # ←
60
+ ARROW_RIGHT: Final[str] = "\u2192" # →
61
+ ARROW_UP: Final[str] = "\u2191" # ↑
62
+ ARROW_DOWN: Final[str] = "\u2193" # ↓
63
+ PIPE_SEPARATOR: Final[str] = "\u2502" # │
64
+
65
+ # Panel rendering
66
+ MAX_READABLE_WIDTH: Final[int] = 120
67
+ HELP_BORDER_WIDTH: Final[int] = 50
68
+
69
+ # Error formatting
70
+ MAX_VALIDATION_ERRORS_SHOWN: Final[int] = 3
71
+
72
+ # Terminal synchronization delay (seconds)
73
+ TERMINAL_SYNC_DELAY: Final[float] = 0.05
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python
2
+ """Manual demo script for the ask_user_question TUI.
3
+
4
+ This is NOT an automated test - it's for interactive visual testing.
5
+ Run this script directly to demo the TUI:
6
+ python -m code_puppy.tools.ask_user_question.demo_tui
7
+ """
8
+
9
+ from .handler import ask_user_question
10
+
11
+
12
+ def main():
13
+ """Run a test of the ask_user_question TUI."""
14
+ print("Testing ask_user_question TUI...")
15
+ print("=" * 50)
16
+
17
+ # Test single question, single select
18
+ result = ask_user_question(
19
+ [
20
+ {
21
+ "question": "Which database should we use for this project?",
22
+ "header": "Database",
23
+ "multi_select": False,
24
+ "options": [
25
+ {
26
+ "label": "PostgreSQL",
27
+ "description": "Relational database, ACID compliant, great for complex queries",
28
+ },
29
+ {
30
+ "label": "MongoDB",
31
+ "description": "Document store, flexible schema, good for rapid iteration",
32
+ },
33
+ {
34
+ "label": "Redis",
35
+ "description": "In-memory store, ultra-fast, best for caching",
36
+ },
37
+ {
38
+ "label": "SQLite",
39
+ "description": "Lightweight, file-based, perfect for local development",
40
+ },
41
+ ],
42
+ }
43
+ ]
44
+ )
45
+
46
+ print("\n" + "=" * 50)
47
+ print("Result:")
48
+ print(f" Answers: {result.answers}")
49
+ print(f" Cancelled: {result.cancelled}")
50
+ print(f" Error: {result.error}")
51
+ print(f" Timed out: {result.timed_out}")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,232 @@
1
+ """Main handler for ask_user_question tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import sys
9
+ from typing import Any
10
+
11
+ from pydantic import ValidationError
12
+
13
+ from code_puppy.command_line.wiggum_state import is_wiggum_active
14
+ from code_puppy.tools.subagent_context import is_subagent
15
+
16
+ from .constants import CI_ENV_VARS, DEFAULT_TIMEOUT_SECONDS, MAX_VALIDATION_ERRORS_SHOWN
17
+ from .models import (
18
+ AskUserQuestionInput,
19
+ AskUserQuestionOutput,
20
+ Question,
21
+ QuestionAnswer,
22
+ )
23
+ from .terminal_ui import CancelledException, interactive_question_picker
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class AsyncContextError(RuntimeError):
29
+ """Raised when TUI is called from async context without await."""
30
+
31
+ pass
32
+
33
+
34
+ def _cancelled_response() -> AskUserQuestionOutput:
35
+ """Create a standardized cancelled response.
36
+
37
+ Note: cancelled=True means intentional user action, not an error.
38
+ The error field is left None since cancellation is expected behavior.
39
+ """
40
+ return AskUserQuestionOutput.cancelled_response()
41
+
42
+
43
+ def is_interactive() -> bool:
44
+ """
45
+ Check if we're running in an interactive terminal.
46
+
47
+ Returns:
48
+ True if stdin is a TTY and we're not in a CI environment.
49
+ """
50
+ # stdin might be replaced with a non-file object in some embedding scenarios
51
+ # (e.g., Jupyter, pytest capture, or custom wrappers), so we catch AttributeError
52
+ try:
53
+ if not sys.stdin.isatty():
54
+ return False
55
+ except (AttributeError, OSError):
56
+ return False
57
+
58
+ return not any(os.environ.get(var) for var in CI_ENV_VARS)
59
+
60
+
61
+ def ask_user_question(
62
+ questions: list[dict[str, Any]],
63
+ timeout: int = DEFAULT_TIMEOUT_SECONDS,
64
+ ) -> AskUserQuestionOutput:
65
+ """
66
+ Ask the user one or more interactive multiple-choice questions.
67
+
68
+ This tool displays questions in a split-panel terminal TUI and captures
69
+ user responses through keyboard navigation and selection.
70
+
71
+ Args:
72
+ questions: List of question objects, each containing:
73
+ - question (str): The full question text
74
+ - header (str): Short label (max 12 chars)
75
+ - multi_select (bool, optional): Allow multiple selections
76
+ - options (list): 2-6 options, each with label and optional description
77
+ timeout: Inactivity timeout in seconds (default: 300)
78
+
79
+ Returns:
80
+ AskUserQuestionOutput containing:
81
+ - answers (list): List of answer objects for each question
82
+ - cancelled (bool): True if user cancelled
83
+ - error (str | None): Error message if failed
84
+ - timed_out (bool): True if timed out
85
+
86
+ Example:
87
+ >>> result = ask_user_question([{
88
+ ... "question": "Which database?",
89
+ ... "header": "Database",
90
+ ... "options": [
91
+ ... {"label": "PostgreSQL", "description": "Relational DB"},
92
+ ... {"label": "MongoDB", "description": "Document store"}
93
+ ... ]
94
+ ... }])
95
+ >>> print(result.answers[0].selected_options)
96
+ ['PostgreSQL']
97
+ """
98
+ logger.info("ask_user_question called with %d questions", len(questions))
99
+
100
+ # Block interactive tools in sub-agent context
101
+ if is_subagent():
102
+ logger.warning("ask_user_question called from sub-agent context - disabled")
103
+ return AskUserQuestionOutput.error_response(
104
+ "Interactive tools are disabled for sub-agents. "
105
+ "Sub-agents should make reasonable decisions or return to the parent agent "
106
+ "if user input is required."
107
+ )
108
+
109
+ # Block interactive tools in wiggum (autonomous loop) mode
110
+ if is_wiggum_active():
111
+ logger.warning("ask_user_question called during wiggum mode - disabled")
112
+ return AskUserQuestionOutput.error_response(
113
+ "Interactive tools are disabled during /wiggum mode. "
114
+ "The agent is running autonomously in a loop. "
115
+ "Make a reasonable decision to proceed, or stop and wait for user input "
116
+ "by completing the current task."
117
+ )
118
+
119
+ # Check for interactive environment
120
+ if not is_interactive():
121
+ logger.warning("Non-interactive environment detected")
122
+ return AskUserQuestionOutput.error_response(
123
+ "Cannot ask questions: not running in an interactive terminal. "
124
+ "Please provide configuration through arguments or config files."
125
+ )
126
+
127
+ # Validate input
128
+ try:
129
+ validated_input = _validate_input(questions)
130
+ except ValidationError as e:
131
+ error_msg = _format_validation_error(e)
132
+ logger.warning("Validation error: %s", error_msg)
133
+ return AskUserQuestionOutput.error_response(error_msg)
134
+ except (TypeError, ValueError) as e:
135
+ logger.error("Unexpected validation error: %s", e, exc_info=True)
136
+ return AskUserQuestionOutput.error_response(f"Validation error: {e!s}")
137
+
138
+ # Run the interactive TUI
139
+ try:
140
+ answers, cancelled, timed_out = _run_interactive_picker(
141
+ validated_input.questions, timeout
142
+ )
143
+
144
+ if timed_out:
145
+ logger.info("Interaction timed out after %d seconds", timeout)
146
+ return AskUserQuestionOutput.timeout_response(timeout)
147
+
148
+ if cancelled:
149
+ logger.info("User cancelled the interaction")
150
+ return _cancelled_response()
151
+
152
+ logger.info("Successfully collected %d answers", len(answers))
153
+ return AskUserQuestionOutput(answers=answers)
154
+
155
+ except (CancelledException, KeyboardInterrupt):
156
+ logger.info("User cancelled the interaction")
157
+ return _cancelled_response()
158
+
159
+ except OSError as e:
160
+ logger.error("Unexpected error during interaction: %s", e)
161
+ return AskUserQuestionOutput.error_response(f"Interaction error: {e!s}")
162
+
163
+
164
+ def _run_interactive_picker(
165
+ questions: list[Question], timeout: int
166
+ ) -> tuple[list[QuestionAnswer], bool, bool]:
167
+ """Run the interactive TUI, handling async context detection.
168
+
169
+ If called from an async context, raises AsyncContextError with guidance.
170
+ For async callers, use `await interactive_question_picker()` directly.
171
+ """
172
+ # Check for async context BEFORE creating the coroutine to avoid
173
+ # "coroutine was never awaited" warnings on the error path.
174
+ try:
175
+ asyncio.get_running_loop()
176
+ # Already in async context - fail fast with helpful message
177
+ # Note: We avoid nest_asyncio.apply() as it globally patches the event loop,
178
+ # which can break other async code in the process and is not thread-safe.
179
+ raise AsyncContextError(
180
+ "Cannot run interactive TUI from within an async context. "
181
+ "Either call from synchronous code, or use "
182
+ "'await interactive_question_picker()' directly for async callers."
183
+ )
184
+ except RuntimeError:
185
+ # No running loop - safe to proceed with asyncio.run()
186
+ pass
187
+
188
+ return asyncio.run(interactive_question_picker(questions, timeout_seconds=timeout))
189
+
190
+
191
+ def _validate_input(questions: list[dict[str, Any]]) -> AskUserQuestionInput:
192
+ """
193
+ Validate and convert input dictionaries to Pydantic models.
194
+
195
+ Args:
196
+ questions: Raw question dictionaries from tool invocation
197
+
198
+ Returns:
199
+ Validated AskUserQuestionInput model
200
+
201
+ Raises:
202
+ ValidationError: If input doesn't match schema
203
+ """
204
+ # Single-pass validation - Pydantic handles nested dict->model conversion
205
+ return AskUserQuestionInput.model_validate({"questions": questions})
206
+
207
+
208
+ def _format_validation_error(error: ValidationError) -> str:
209
+ """
210
+ Format a Pydantic ValidationError into a readable string.
211
+
212
+ Args:
213
+ error: The Pydantic ValidationError
214
+
215
+ Returns:
216
+ Human-readable error message
217
+ """
218
+ errors = error.errors()
219
+ if not errors:
220
+ return "Validation error"
221
+
222
+ messages = []
223
+ for err in errors[:MAX_VALIDATION_ERRORS_SHOWN]:
224
+ loc = ".".join(str(x) for x in err["loc"])
225
+ msg = err["msg"]
226
+ messages.append(f"{loc}: {msg}")
227
+
228
+ result = "Validation error: " + "; ".join(messages)
229
+ if len(errors) > MAX_VALIDATION_ERRORS_SHOWN:
230
+ result += f" (and {len(errors) - MAX_VALIDATION_ERRORS_SHOWN} more)"
231
+
232
+ return result
@@ -0,0 +1,304 @@
1
+ """Pydantic models for the ask_user_question tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, Annotated, Any
7
+
8
+ from pydantic import BaseModel, BeforeValidator, Field, model_validator
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ from .constants import (
14
+ MAX_DESCRIPTION_LENGTH,
15
+ MAX_HEADER_LENGTH,
16
+ MAX_LABEL_LENGTH,
17
+ MAX_OPTIONS_PER_QUESTION,
18
+ MAX_OTHER_TEXT_LENGTH,
19
+ MAX_QUESTION_LENGTH,
20
+ MAX_QUESTIONS_PER_CALL,
21
+ MIN_OPTIONS_PER_QUESTION,
22
+ )
23
+
24
+ __all__ = [
25
+ "AskUserQuestionInput",
26
+ "AskUserQuestionOutput",
27
+ "Question",
28
+ "QuestionAnswer",
29
+ "QuestionOption",
30
+ "sanitize_text",
31
+ ]
32
+
33
+ # Regex to match ANSI escape codes
34
+ ANSI_ESCAPE_PATTERN = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
35
+
36
+
37
+ def sanitize_text(text: str) -> str:
38
+ """Remove ANSI escape codes and strip whitespace."""
39
+ return ANSI_ESCAPE_PATTERN.sub("", text).strip()
40
+
41
+
42
+ def _make_sanitizer(
43
+ *, allow_none: bool = False, default: str = ""
44
+ ) -> "Callable[[Any], str]":
45
+ """Create a sanitizer with configurable None handling.
46
+
47
+ Args:
48
+ allow_none: If True, None returns default. If False, raises ValueError.
49
+ default: Value to return when allow_none=True and input is None.
50
+
51
+ Returns:
52
+ A sanitizer function for use with BeforeValidator.
53
+ """
54
+
55
+ def sanitize(v: Any) -> str:
56
+ if v is None:
57
+ if allow_none:
58
+ return default
59
+ raise ValueError("Value cannot be None")
60
+ return sanitize_text(str(v))
61
+
62
+ return sanitize
63
+
64
+
65
+ # Pre-built sanitizers for common cases
66
+ _sanitize_required = _make_sanitizer(allow_none=False)
67
+ _sanitize_optional = _make_sanitizer(allow_none=True, default="")
68
+
69
+
70
+ def _sanitize_header(v: Any) -> str:
71
+ """Sanitize header: remove ANSI, strip, replace spaces with hyphens."""
72
+ return _sanitize_required(v).replace(" ", "-")
73
+
74
+
75
+ def _check_unique(items: list[str], field_name: str) -> None:
76
+ """Raise ValueError if items has duplicates (case-insensitive)."""
77
+ lowered = [i.lower() for i in items]
78
+ if len(lowered) != len(set(lowered)):
79
+ raise ValueError(f"{field_name} must be unique")
80
+
81
+
82
+ class QuestionOption(BaseModel):
83
+ """
84
+ A single selectable option for a question.
85
+
86
+ Attributes:
87
+ label: Short, descriptive name for the option (1-5 words recommended)
88
+ description: Longer explanation of what selecting this option means
89
+ """
90
+
91
+ label: Annotated[
92
+ str,
93
+ BeforeValidator(_sanitize_required),
94
+ Field(
95
+ min_length=1,
96
+ max_length=MAX_LABEL_LENGTH,
97
+ description="Short option name (1-5 words)",
98
+ ),
99
+ ]
100
+ description: Annotated[
101
+ str,
102
+ BeforeValidator(_sanitize_optional),
103
+ Field(
104
+ default="",
105
+ max_length=MAX_DESCRIPTION_LENGTH,
106
+ description="Explanation of what this option means",
107
+ ),
108
+ ]
109
+
110
+
111
+ class Question(BaseModel):
112
+ """
113
+ A single question with multiple-choice options.
114
+
115
+ Attributes:
116
+ question: The full question text displayed to the user
117
+ header: Short label used for compact display and response mapping
118
+ multi_select: Whether user can select multiple options
119
+ options: List of 2-6 selectable options
120
+ """
121
+
122
+ question: Annotated[
123
+ str,
124
+ BeforeValidator(_sanitize_required),
125
+ Field(
126
+ min_length=1,
127
+ max_length=MAX_QUESTION_LENGTH,
128
+ description="The full question text to display",
129
+ ),
130
+ ]
131
+ header: Annotated[
132
+ str,
133
+ BeforeValidator(_sanitize_header),
134
+ Field(
135
+ min_length=1,
136
+ max_length=MAX_HEADER_LENGTH,
137
+ description="Short label for compact display (max 12 chars)",
138
+ ),
139
+ ]
140
+ multi_select: Annotated[
141
+ bool,
142
+ Field(
143
+ default=False,
144
+ description="If true, user can select multiple options",
145
+ ),
146
+ ]
147
+ options: Annotated[
148
+ list[QuestionOption],
149
+ Field(
150
+ min_length=MIN_OPTIONS_PER_QUESTION,
151
+ max_length=MAX_OPTIONS_PER_QUESTION,
152
+ description="Array of 2-6 selectable options",
153
+ ),
154
+ ]
155
+
156
+ @model_validator(mode="after")
157
+ def validate_unique_labels(self) -> Question:
158
+ """Ensure all option labels are unique within a question."""
159
+ _check_unique([opt.label for opt in self.options], "Option labels")
160
+ return self
161
+
162
+
163
+ class AskUserQuestionInput(BaseModel):
164
+ """
165
+ Input schema for the ask_user_question tool.
166
+
167
+ Attributes:
168
+ questions: List of 1-10 questions to ask the user
169
+ """
170
+
171
+ questions: Annotated[
172
+ list[Question],
173
+ Field(
174
+ min_length=1,
175
+ max_length=MAX_QUESTIONS_PER_CALL,
176
+ description="Array of 1-10 questions to ask",
177
+ ),
178
+ ]
179
+
180
+ @model_validator(mode="after")
181
+ def validate_unique_headers(self) -> AskUserQuestionInput:
182
+ """Ensure all question headers are unique."""
183
+ _check_unique([q.header for q in self.questions], "Question headers")
184
+ return self
185
+
186
+
187
+ class QuestionAnswer(BaseModel):
188
+ """
189
+ Answer to a single question.
190
+
191
+ Attributes:
192
+ question_header: The header of the question being answered
193
+ selected_options: List of labels for selected options
194
+ other_text: Custom text if user selected "Other" option
195
+ """
196
+
197
+ question_header: Annotated[
198
+ str,
199
+ Field(description="Header of the answered question"),
200
+ ]
201
+ selected_options: Annotated[
202
+ list[str],
203
+ Field(
204
+ default_factory=list,
205
+ description="Labels of selected options",
206
+ ),
207
+ ]
208
+ other_text: Annotated[
209
+ str | None,
210
+ Field(
211
+ default=None,
212
+ max_length=MAX_OTHER_TEXT_LENGTH,
213
+ description="Custom text if 'Other' was selected",
214
+ ),
215
+ ]
216
+
217
+ @property
218
+ def has_other(self) -> bool:
219
+ """Check if user provided custom 'Other' input."""
220
+ return self.other_text is not None
221
+
222
+ @property
223
+ def is_empty(self) -> bool:
224
+ """Check if no options were selected."""
225
+ return not self.selected_options and self.other_text is None
226
+
227
+
228
+ class AskUserQuestionOutput(BaseModel):
229
+ """
230
+ Output schema for the ask_user_question tool.
231
+
232
+ Attributes:
233
+ answers: List of answers to all questions
234
+ cancelled: Whether user cancelled the interaction
235
+ error: Error message if something went wrong
236
+ timed_out: Whether the interaction timed out
237
+ """
238
+
239
+ answers: Annotated[
240
+ list[QuestionAnswer],
241
+ Field(
242
+ default_factory=list,
243
+ description="Answers to all questions",
244
+ ),
245
+ ]
246
+ cancelled: Annotated[
247
+ bool,
248
+ Field(
249
+ default=False,
250
+ description="True if user cancelled (Esc/Ctrl+C)",
251
+ ),
252
+ ]
253
+ error: Annotated[
254
+ str | None,
255
+ Field(
256
+ default=None,
257
+ description="Error message if interaction failed",
258
+ ),
259
+ ]
260
+ timed_out: Annotated[
261
+ bool,
262
+ Field(
263
+ default=False,
264
+ description="True if interaction timed out",
265
+ ),
266
+ ]
267
+
268
+ @property
269
+ def success(self) -> bool:
270
+ """Check if interaction completed successfully."""
271
+ return not self.cancelled and self.error is None and not self.timed_out
272
+
273
+ @classmethod
274
+ def error_response(cls, error: str) -> AskUserQuestionOutput:
275
+ """Create an error response."""
276
+ return cls(error=error)
277
+
278
+ @classmethod
279
+ def cancelled_response(cls) -> AskUserQuestionOutput:
280
+ """Create a cancelled response (intentional user action, not an error)."""
281
+ return cls(answers=[], cancelled=True, error=None)
282
+
283
+ @classmethod
284
+ def timeout_response(cls, timeout: int) -> AskUserQuestionOutput:
285
+ """Create a timeout response."""
286
+ return cls(
287
+ answers=[],
288
+ cancelled=False,
289
+ timed_out=True,
290
+ error=f"Interaction timed out after {timeout} seconds of inactivity",
291
+ )
292
+
293
+ def get_answer(self, header: str) -> QuestionAnswer | None:
294
+ """Get answer by question header (case-insensitive)."""
295
+ header_lower = header.lower()
296
+ return next(
297
+ (a for a in self.answers if a.question_header.lower() == header_lower),
298
+ None,
299
+ )
300
+
301
+ def get_selected(self, header: str) -> list[str]:
302
+ """Get selected options for a question by header."""
303
+ answer = self.get_answer(header)
304
+ return answer.selected_options if answer else []
@@ -0,0 +1,36 @@
1
+ """Tool registration for ask_user_question."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic_ai import RunContext
8
+
9
+ from .handler import ask_user_question as _ask_user_question_impl
10
+ from .models import AskUserQuestionOutput
11
+
12
+ if TYPE_CHECKING:
13
+ from pydantic_ai import Agent
14
+
15
+
16
+ def register_ask_user_question(agent: Agent) -> None:
17
+ """Register the ask_user_question tool with the given agent."""
18
+
19
+ @agent.tool
20
+ def ask_user_question(
21
+ context: RunContext, # noqa: ARG001 - Required by framework
22
+ questions: list[dict[str, Any]],
23
+ ) -> AskUserQuestionOutput:
24
+ """Ask the user multiple related questions in an interactive TUI.
25
+
26
+ Args:
27
+ questions: Array of 1-10 questions to ask. Keep it minimal! Each:
28
+ - question (str): The full question text to display
29
+ - header (str): Short label (max 12 chars) for left panel
30
+ - multi_select (bool, optional): Allow multiple selections
31
+ - options (list): 2-6 options, each with:
32
+ - label (str): Short option name (1-5 words)
33
+ - description (str, optional): Brief explanation
34
+ """
35
+ # Handler returns AskUserQuestionOutput directly - no revalidation needed
36
+ return _ask_user_question_impl(questions)