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,309 @@
1
+ """Rendering functions for the ask_user_question TUI.
2
+
3
+ This module contains the panel rendering logic, separated from the main
4
+ TUI logic to keep files under 600 lines.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import shutil
11
+ from typing import TYPE_CHECKING
12
+
13
+ from prompt_toolkit.formatted_text import ANSI
14
+ from rich.console import Console
15
+ from rich.markup import escape as rich_escape
16
+
17
+ from .constants import (
18
+ ARROW_DOWN,
19
+ ARROW_LEFT,
20
+ ARROW_RIGHT,
21
+ ARROW_UP,
22
+ AUTO_ADD_OTHER_OPTION,
23
+ BORDER_DOUBLE,
24
+ CHECK_MARK,
25
+ CURSOR_POINTER,
26
+ HELP_BORDER_WIDTH,
27
+ MAX_READABLE_WIDTH,
28
+ OTHER_OPTION_DESCRIPTION,
29
+ OTHER_OPTION_LABEL,
30
+ PANEL_CONTENT_PADDING,
31
+ PIPE_SEPARATOR,
32
+ RADIO_FILLED,
33
+ )
34
+ from .theme import get_rich_colors
35
+
36
+ if TYPE_CHECKING:
37
+ from .terminal_ui import QuestionUIState
38
+ from .theme import RichColors
39
+
40
+
41
+ def render_question_panel(
42
+ state: QuestionUIState,
43
+ colors: RichColors | None = None,
44
+ available_width: int | None = None,
45
+ ) -> ANSI:
46
+ """Render the right panel with the current question.
47
+
48
+ Args:
49
+ state: The current UI state
50
+ colors: Optional cached RichColors instance. If None, fetches from config.
51
+ """
52
+ if colors is None:
53
+ colors = get_rich_colors()
54
+
55
+ buffer = io.StringIO()
56
+ # Use available panel width if provided, otherwise fall back to terminal width
57
+ # Subtract padding to avoid overflow into frame borders
58
+ if available_width is not None:
59
+ terminal_width = min(available_width, MAX_READABLE_WIDTH)
60
+ else:
61
+ terminal_width = min(shutil.get_terminal_size().columns, MAX_READABLE_WIDTH)
62
+ console = Console(
63
+ file=buffer,
64
+ force_terminal=True,
65
+ width=terminal_width,
66
+ legacy_windows=False,
67
+ color_system="truecolor",
68
+ no_color=False,
69
+ force_interactive=True,
70
+ )
71
+
72
+ # Show help overlay if requested
73
+ if state.show_help:
74
+ return _render_help_overlay(console, buffer, colors)
75
+
76
+ question = state.current_question
77
+ q_num = state.current_question_index + 1
78
+ total = len(state.questions)
79
+ pad = PANEL_CONTENT_PADDING # Left padding for visual alignment
80
+
81
+ # Header
82
+ console.print(
83
+ f"{pad}[{colors.header}][{question.header}][/{colors.header}] "
84
+ f"[{colors.progress}]({q_num}/{total})[/{colors.progress}]"
85
+ )
86
+ console.print()
87
+
88
+ # Question text
89
+ if question.multi_select:
90
+ console.print(
91
+ f"{pad}[bold]? {question.question}[/bold] [dim](select multiple)[/dim]"
92
+ )
93
+ else:
94
+ console.print(f"{pad}[bold]? {question.question}[/bold]")
95
+ console.print()
96
+
97
+ # Render options
98
+ for i, option in enumerate(question.options):
99
+ _render_option(
100
+ console,
101
+ label=option.label,
102
+ description=option.description,
103
+ is_cursor=state.current_cursor == i,
104
+ is_selected=state.is_option_selected(i),
105
+ multi_select=question.multi_select,
106
+ colors=colors,
107
+ padding=pad,
108
+ )
109
+
110
+ # Render "Other" option if enabled
111
+ if AUTO_ADD_OTHER_OPTION:
112
+ other_idx = len(question.options)
113
+ # Get the stored "Other" text for this question
114
+ other_text = state.get_other_text_for_question(state.current_question_index)
115
+ # Build the description - show stored text if available
116
+ # Escape user input to prevent Rich markup injection
117
+ if other_text:
118
+ other_desc = f'"{rich_escape(other_text)}"'
119
+ else:
120
+ other_desc = OTHER_OPTION_DESCRIPTION
121
+ _render_option(
122
+ console,
123
+ label=OTHER_OPTION_LABEL,
124
+ description=other_desc,
125
+ is_cursor=state.current_cursor == other_idx,
126
+ is_selected=state.is_option_selected(other_idx),
127
+ multi_select=question.multi_select,
128
+ colors=colors,
129
+ padding=pad,
130
+ )
131
+
132
+ # If entering "Other" text, show the input field
133
+ if state.entering_other_text:
134
+ console.print()
135
+ console.print(
136
+ f"{pad}[{colors.input_label}]Enter your custom option:[/{colors.input_label}]"
137
+ )
138
+ console.print(
139
+ f"{pad}[{colors.input_text}]> {state.other_text_buffer}_[/{colors.input_text}]"
140
+ )
141
+ console.print()
142
+ console.print(
143
+ f"{pad}[{colors.input_hint}]Enter to confirm, Esc to cancel[/{colors.input_hint}]"
144
+ )
145
+
146
+ # Help text at bottom - build dynamically, filtering out None entries
147
+ console.print()
148
+ is_last = state.current_question_index == total - 1
149
+ help_parts = [
150
+ "Space Toggle" if question.multi_select else "Space Select",
151
+ "Enter Next" if not is_last else None,
152
+ f"{ARROW_LEFT}{ARROW_RIGHT} Questions" if total > 1 else None,
153
+ "Ctrl+S Submit",
154
+ "? Help",
155
+ ]
156
+ separator = f" {PIPE_SEPARATOR} "
157
+ console.print(
158
+ f"{pad}[{colors.description}]{separator.join(p for p in help_parts if p)}[/{colors.description}]"
159
+ )
160
+
161
+ # Show timeout warning if approaching timeout
162
+ if state.should_show_timeout_warning():
163
+ remaining = state.get_time_remaining()
164
+ console.print()
165
+ console.print(
166
+ f"{pad}[{colors.timeout_warning}]⚠ Timeout in {remaining}s - press any key to continue[/{colors.timeout_warning}]"
167
+ )
168
+
169
+ return ANSI(buffer.getvalue())
170
+
171
+
172
+ # Help overlay shortcut data: (section_name, [(primary_key, alt_key_or_None, description), ...])
173
+ _HELP_SECTIONS: list[tuple[str, list[tuple[str, str | None, str]]]] = [
174
+ (
175
+ "Navigation:",
176
+ [
177
+ (ARROW_UP, "k", "Move up"),
178
+ (ARROW_DOWN, "j", "Move down"),
179
+ (ARROW_LEFT, "h", "Previous question"),
180
+ (ARROW_RIGHT, "l", "Next question"),
181
+ ("g", None, "Jump to first option"),
182
+ ("G", None, "Jump to last option"),
183
+ ],
184
+ ),
185
+ (
186
+ "Selection:",
187
+ [
188
+ ("Space", None, "Select option (radio) / Toggle (checkbox)"),
189
+ ("Enter", None, "Next question (select + advance)"),
190
+ ("a", None, "Select all (multi-select)"),
191
+ ("n", None, "Select none (multi-select)"),
192
+ ("Ctrl+S", None, "Submit all answers"),
193
+ ],
194
+ ),
195
+ (
196
+ "Other:",
197
+ [
198
+ ("Tab", None, "Peek behind (toggle TUI visibility)"),
199
+ ("?", None, "Toggle this help"),
200
+ ("Esc", None, "Cancel"),
201
+ ("Ctrl+C", None, "Cancel"),
202
+ ],
203
+ ),
204
+ ]
205
+
206
+
207
+ def _render_help_overlay(
208
+ console: Console, buffer: io.StringIO, colors: RichColors
209
+ ) -> ANSI:
210
+ """Render the help overlay using data-driven approach."""
211
+ pad = PANEL_CONTENT_PADDING
212
+ border = colors.help_border
213
+ key_style = colors.help_key
214
+ section_style = colors.help_section
215
+
216
+ border_line = f"{pad}[{border}]{BORDER_DOUBLE * HELP_BORDER_WIDTH}[/{border}]"
217
+
218
+ console.print(border_line)
219
+ console.print(
220
+ f"{pad}[{colors.help_title}] KEYBOARD SHORTCUTS[/{colors.help_title}]"
221
+ )
222
+ console.print(border_line)
223
+ console.print()
224
+
225
+ for section_name, shortcuts in _HELP_SECTIONS:
226
+ console.print(f"{pad}[{section_style}]{section_name}[/{section_style}]")
227
+ for primary, alt, desc in shortcuts:
228
+ if alt:
229
+ console.print(
230
+ f"{pad} [{key_style}]{primary}[/{key_style}] / "
231
+ f"[{key_style}]{alt}[/{key_style}] {desc}"
232
+ )
233
+ else:
234
+ console.print(
235
+ f"{pad} [{key_style}]{primary}[/{key_style}] {desc}"
236
+ )
237
+ console.print()
238
+
239
+ console.print(border_line)
240
+ console.print(
241
+ f"{pad}[{colors.help_close}]Press [{key_style}]?[/{key_style}] to close this help[/{colors.help_close}]"
242
+ )
243
+ console.print(border_line)
244
+
245
+ return ANSI(buffer.getvalue())
246
+
247
+
248
+ def _render_option(
249
+ console: Console,
250
+ *,
251
+ label: str,
252
+ description: str,
253
+ is_cursor: bool,
254
+ is_selected: bool,
255
+ multi_select: bool,
256
+ colors: RichColors,
257
+ padding: str = "",
258
+ ) -> None:
259
+ """Render a single option line.
260
+
261
+ Args:
262
+ console: Rich console to render to
263
+ label: Option label text
264
+ description: Option description text
265
+ is_cursor: Whether cursor is on this option
266
+ is_selected: Whether this option is selected
267
+ multi_select: Whether this is a multi-select question
268
+ colors: RichColors instance (required to avoid repeated config lookups)
269
+ padding: Left padding string to prepend to each line
270
+ """
271
+ # Escape label and description to prevent Rich markup injection
272
+ label = rich_escape(label)
273
+ description = rich_escape(description) if description else ""
274
+
275
+ cursor_style = colors.cursor
276
+ selected_style = colors.selected
277
+ desc_style = colors.description
278
+
279
+ # Build the prefix with checkbox or radio button
280
+ if multi_select:
281
+ # Checkbox style: [✓] or [ ]
282
+ checkbox = f"[{CHECK_MARK}]" if is_selected else "[ ]"
283
+ if is_cursor:
284
+ prefix = f"[{cursor_style}]{CURSOR_POINTER} {checkbox}[/{cursor_style}]"
285
+ else:
286
+ prefix = f" {checkbox}"
287
+ else:
288
+ # Radio button style: (●) or ( )
289
+ radio = f"({RADIO_FILLED})" if is_selected else "( )"
290
+ if is_cursor:
291
+ prefix = f"[{cursor_style}]{CURSOR_POINTER} {radio}[/{cursor_style}]"
292
+ else:
293
+ prefix = f" {radio}"
294
+
295
+ # Build the label
296
+ if is_cursor:
297
+ label_styled = f"[{cursor_style}]{label}[/{cursor_style}]"
298
+ elif is_selected:
299
+ label_styled = f"[{selected_style}]{label}[/{selected_style}]"
300
+ else:
301
+ label_styled = label
302
+
303
+ # Print option
304
+ console.print(f"{padding} {prefix} {label_styled}")
305
+
306
+ # Print description if present
307
+ if description:
308
+ console.print(f"{padding} [{desc_style}]{description}[/{desc_style}]")
309
+ console.print()
@@ -0,0 +1,329 @@
1
+ """Terminal UI for ask_user_question tool.
2
+
3
+ Uses prompt_toolkit for a split-panel TUI similar to the /colors command.
4
+ Left panel (20%): Question headers/tabs
5
+ Right panel (80%): Current question with options
6
+
7
+ Navigation:
8
+ - Left/Right: Switch between questions
9
+ - Up/Down: Navigate options within current question
10
+ - Enter: Select option (single-select) or confirm (multi-select)
11
+ - Space: Toggle option (multi-select only)
12
+ - Esc/Ctrl+C: Cancel
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+
19
+ from .constants import (
20
+ AUTO_ADD_OTHER_OPTION,
21
+ DEFAULT_TIMEOUT_SECONDS,
22
+ LEFT_PANEL_PADDING,
23
+ MAX_LEFT_PANEL_WIDTH,
24
+ MIN_LEFT_PANEL_WIDTH,
25
+ OTHER_OPTION_LABEL,
26
+ TIMEOUT_WARNING_SECONDS,
27
+ )
28
+ from .models import Question, QuestionAnswer
29
+
30
+
31
+ class CancelledException(Exception):
32
+ """Raised when user cancels the interaction."""
33
+
34
+
35
+ class QuestionUIState:
36
+ """Holds the current UI state for the question interaction."""
37
+
38
+ def __init__(self, questions: list[Question]) -> None:
39
+ """Initialize state with questions.
40
+
41
+ Args:
42
+ questions: List of validated Question objects
43
+ """
44
+ self.questions = questions
45
+ self.current_question_index = 0
46
+ # For each question, track: cursor position and selected options
47
+ self.cursor_positions: list[int] = [0] * len(questions)
48
+ # For multi-select, track selected option indices per question
49
+ self.selected_options: list[set[int]] = [set() for _ in questions]
50
+ # For single-select, track the selected option index per question (None = not selected)
51
+ self.single_selections: list[int | None] = [None] * len(questions)
52
+ # Store "Other" text per question
53
+ self.other_texts: list[str | None] = [None] * len(questions)
54
+ # Track if we're in "Other" text input mode
55
+ self.entering_other_text = False
56
+ self.other_text_buffer = ""
57
+ # Track if help overlay is shown
58
+ self.show_help = False
59
+ # Timeout tracking (use monotonic to avoid clock drift/NTP issues)
60
+ self.timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
61
+ self.last_activity_time: float = time.monotonic()
62
+
63
+ def reset_activity_timer(self) -> None:
64
+ """Reset the activity timer (called on user input)."""
65
+ self.last_activity_time = time.monotonic()
66
+
67
+ def get_time_remaining(self) -> int:
68
+ """Get seconds remaining before timeout."""
69
+ elapsed = time.monotonic() - self.last_activity_time
70
+ remaining = self.timeout_seconds - elapsed
71
+ return max(0, int(remaining))
72
+
73
+ def is_timed_out(self) -> bool:
74
+ """Check if the interaction has timed out."""
75
+ return self.get_time_remaining() <= 0
76
+
77
+ def should_show_timeout_warning(self) -> bool:
78
+ """Check if we should show the timeout warning."""
79
+ remaining = self.get_time_remaining()
80
+ return remaining <= TIMEOUT_WARNING_SECONDS and remaining > 0
81
+
82
+ @property
83
+ def current_question(self) -> Question:
84
+ """Get the currently displayed question."""
85
+ return self.questions[self.current_question_index]
86
+
87
+ def get_left_panel_width(self) -> int:
88
+ """Calculate the left panel width based on longest header.
89
+
90
+ Returns:
91
+ Width in characters, including padding for cursor and checkmark.
92
+ """
93
+ max_header_len = max(len(q.header) for q in self.questions)
94
+ width = max_header_len + LEFT_PANEL_PADDING
95
+ return max(MIN_LEFT_PANEL_WIDTH, min(width, MAX_LEFT_PANEL_WIDTH))
96
+
97
+ def get_other_text_for_question(self, index: int) -> str | None:
98
+ """Get the 'Other' text for a specific question.
99
+
100
+ Args:
101
+ index: Question index
102
+
103
+ Returns:
104
+ The stored other_text or None if not set.
105
+ """
106
+ return self.other_texts[index]
107
+
108
+ def jump_to_first(self) -> None:
109
+ """Jump cursor to first option."""
110
+ self.current_cursor = 0
111
+
112
+ def jump_to_last(self) -> None:
113
+ """Jump cursor to last option."""
114
+ self.current_cursor = self.total_options - 1
115
+
116
+ @property
117
+ def current_cursor(self) -> int:
118
+ """Get cursor position for current question."""
119
+ return self.cursor_positions[self.current_question_index]
120
+
121
+ @current_cursor.setter
122
+ def current_cursor(self, value: int) -> None:
123
+ """Set cursor position for current question."""
124
+ self.cursor_positions[self.current_question_index] = value
125
+
126
+ @property
127
+ def total_options(self) -> int:
128
+ """Get total number of options including 'Other' if enabled."""
129
+ count = len(self.current_question.options)
130
+ if AUTO_ADD_OTHER_OPTION:
131
+ count += 1
132
+ return count
133
+
134
+ def is_question_answered(self, index: int) -> bool:
135
+ """Check if a question has at least one selection.
136
+
137
+ For multi-select: True if any option is selected or Other text provided.
138
+ For single-select: True if an option is selected.
139
+ """
140
+ question = self.questions[index]
141
+ if question.multi_select:
142
+ return (
143
+ len(self.selected_options[index]) > 0
144
+ or self.other_texts[index] is not None
145
+ )
146
+ return self.single_selections[index] is not None
147
+
148
+ def is_other_option(self, index: int) -> bool:
149
+ """Check if the given index is the 'Other' option."""
150
+ if not AUTO_ADD_OTHER_OPTION:
151
+ return False
152
+ return index == len(self.current_question.options)
153
+
154
+ def enter_other_text_mode(self) -> None:
155
+ """Enter text input mode for the 'Other' option.
156
+
157
+ This centralizes the logic for starting 'Other' text entry,
158
+ avoiding duplication in the keyboard handlers.
159
+ """
160
+ self.entering_other_text = True
161
+ self.other_text_buffer = self.other_texts[self.current_question_index] or ""
162
+
163
+ def commit_other_text(self) -> None:
164
+ """Save the other text buffer and mark the Other option as selected.
165
+
166
+ This centralizes the logic for confirming an 'Other' text entry,
167
+ avoiding duplication in the various keyboard handlers.
168
+ """
169
+ if not self.other_text_buffer.strip():
170
+ # Don't save empty/whitespace-only text
171
+ self.entering_other_text = False
172
+ self.other_text_buffer = ""
173
+ return
174
+
175
+ self.other_texts[self.current_question_index] = self.other_text_buffer
176
+ other_idx = len(self.current_question.options)
177
+ self._select_option_at(self.current_question_index, other_idx)
178
+ self.entering_other_text = False
179
+ self.other_text_buffer = ""
180
+
181
+ def _select_option_at(self, question_idx: int, option_idx: int) -> None:
182
+ """Mark an option as selected for the given question.
183
+
184
+ Handles both single-select and multi-select modes.
185
+ """
186
+ if self.questions[question_idx].multi_select:
187
+ self.selected_options[question_idx].add(option_idx)
188
+ else:
189
+ self.single_selections[question_idx] = option_idx
190
+
191
+ def select_all_options(self) -> None:
192
+ """Select all regular options for the current question (multi-select only)."""
193
+ if not self.current_question.multi_select:
194
+ return
195
+ for i in range(len(self.current_question.options)):
196
+ self.selected_options[self.current_question_index].add(i)
197
+
198
+ def select_no_options(self) -> None:
199
+ """Clear all selections for the current question (multi-select only)."""
200
+ if not self.current_question.multi_select:
201
+ return
202
+ self.selected_options[self.current_question_index].clear()
203
+ self.other_texts[self.current_question_index] = None
204
+
205
+ def move_cursor_up(self) -> None:
206
+ """Move cursor up within current question."""
207
+ if self.current_cursor > 0:
208
+ self.current_cursor -= 1
209
+
210
+ def move_cursor_down(self) -> None:
211
+ """Move cursor down within current question."""
212
+ if self.current_cursor < self.total_options - 1:
213
+ self.current_cursor += 1
214
+
215
+ def next_question(self) -> None:
216
+ """Move to next question."""
217
+ if self.current_question_index < len(self.questions) - 1:
218
+ self.current_question_index += 1
219
+
220
+ def prev_question(self) -> None:
221
+ """Move to previous question."""
222
+ if self.current_question_index > 0:
223
+ self.current_question_index -= 1
224
+
225
+ def toggle_current_option(self) -> None:
226
+ """Toggle the current option for multi-select questions."""
227
+ if not self.current_question.multi_select:
228
+ return
229
+ cursor = self.current_cursor
230
+ selected = self.selected_options[self.current_question_index]
231
+ if cursor in selected:
232
+ selected.discard(cursor)
233
+ else:
234
+ selected.add(cursor)
235
+
236
+ def select_current_option(self) -> None:
237
+ """Select current option for single-select questions."""
238
+ if self.current_question.multi_select:
239
+ return
240
+ self.single_selections[self.current_question_index] = self.current_cursor
241
+
242
+ def is_option_selected(self, index: int) -> bool:
243
+ """Check if an option is selected."""
244
+ if self.current_question.multi_select:
245
+ return index in self.selected_options[self.current_question_index]
246
+ else:
247
+ return self.single_selections[self.current_question_index] == index
248
+
249
+ def _resolve_option_label(
250
+ self, question: Question, question_idx: int, opt_idx: int
251
+ ) -> tuple[str, str | None]:
252
+ """Resolve the label and other_text for an option index.
253
+
254
+ Args:
255
+ question: The question being answered
256
+ question_idx: Index of the question in self.questions
257
+ opt_idx: Index of the selected option
258
+
259
+ Returns:
260
+ Tuple of (label, other_text) where other_text is set only for "Other" option
261
+ """
262
+ if AUTO_ADD_OTHER_OPTION and opt_idx == len(question.options):
263
+ return OTHER_OPTION_LABEL, self.other_texts[question_idx]
264
+ return question.options[opt_idx].label, None
265
+
266
+ def build_answers(self) -> list[QuestionAnswer]:
267
+ """Build the list of answers from current state."""
268
+ answers = []
269
+ for i, question in enumerate(self.questions):
270
+ selected_labels: list[str] = []
271
+ other_text: str | None = None
272
+
273
+ if question.multi_select:
274
+ # Multi-select: gather all selected option labels
275
+ for opt_idx in sorted(self.selected_options[i]):
276
+ label, opt_other = self._resolve_option_label(question, i, opt_idx)
277
+ selected_labels.append(label)
278
+ if opt_other is not None:
279
+ other_text = opt_other
280
+ else:
281
+ # Single-select: get the selected option
282
+ sel_idx = self.single_selections[i]
283
+ if sel_idx is not None:
284
+ label, other_text = self._resolve_option_label(question, i, sel_idx)
285
+ selected_labels.append(label)
286
+
287
+ answers.append(
288
+ QuestionAnswer(
289
+ question_header=question.header,
290
+ selected_options=selected_labels,
291
+ other_text=other_text,
292
+ )
293
+ )
294
+ return answers
295
+
296
+
297
+ async def interactive_question_picker(
298
+ questions: list[Question],
299
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
300
+ ) -> tuple[list[QuestionAnswer], bool, bool]:
301
+ """Show an interactive split-panel TUI for questions.
302
+
303
+ Args:
304
+ questions: List of validated Question objects
305
+ timeout_seconds: Inactivity timeout in seconds
306
+
307
+ Returns:
308
+ Tuple of (answers, cancelled, timed_out) where:
309
+ - answers: List of QuestionAnswer objects
310
+ - cancelled: True if user cancelled
311
+ - timed_out: True if interaction timed out
312
+
313
+ Raises:
314
+ CancelledException: If user cancels with Esc/Ctrl+C
315
+ """
316
+ # Import here to avoid circular dependency with command_runner
317
+ from code_puppy.tools.command_runner import set_awaiting_user_input
318
+
319
+ state = QuestionUIState(questions)
320
+ state.timeout_seconds = timeout_seconds
321
+ set_awaiting_user_input(True)
322
+
323
+ try:
324
+ from .tui_loop import run_question_tui
325
+
326
+ # prompt_toolkit manages alt screen via full_screen=True
327
+ return await run_question_tui(state)
328
+ finally:
329
+ set_awaiting_user_input(False)