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.
- code_puppy/__init__.py +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +147 -0
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +630 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +122 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +380 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +167 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_scheduler.py +121 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +2145 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +296 -0
- code_puppy/agents/pack/husky.py +307 -0
- code_puppy/agents/pack/retriever.py +380 -0
- code_puppy/agents/pack/shepherd.py +327 -0
- code_puppy/agents/pack/terrier.py +281 -0
- code_puppy/agents/pack/watchdog.py +357 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +453 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +674 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +664 -0
- code_puppy/cli_runner.py +1038 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +526 -0
- code_puppy/command_line/command_handler.py +283 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +853 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +91 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1787 -0
- code_puppy/error_logging.py +133 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +15 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +195 -0
- code_puppy/hook_engine/executor.py +293 -0
- code_puppy/hook_engine/matcher.py +145 -0
- code_puppy/hook_engine/models.py +222 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +141 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1153 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +96 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +130 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +131 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +601 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +277 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
- code_puppy/plugins/oauth_puppy_html.py +224 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +317 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +470 -0
- code_puppy/tools/agent_tools.py +616 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +36 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +378 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +739 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +251 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- newcode-0.1.1.data/data/code_puppy/models.json +130 -0
- newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
- newcode-0.1.1.dist-info/METADATA +154 -0
- newcode-0.1.1.dist-info/RECORD +289 -0
- newcode-0.1.1.dist-info/WHEEL +4 -0
- newcode-0.1.1.dist-info/entry_points.txt +3 -0
- 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)
|