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.
- 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 +117 -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 +638 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +124 -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 +385 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +169 -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 +2156 -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 +304 -0
- code_puppy/agents/pack/husky.py +327 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -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 +692 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +672 -0
- code_puppy/cli_runner.py +1073 -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 +532 -0
- code_puppy/command_line/command_handler.py +293 -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 +867 -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 +96 -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/shell_passthrough.py +145 -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 +1770 -0
- code_puppy/error_logging.py +134 -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 +21 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +221 -0
- code_puppy/hook_engine/executor.py +296 -0
- code_puppy/hook_engine/matcher.py +156 -0
- code_puppy/hook_engine/models.py +240 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +144 -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 +1158 -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 +95 -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 +174 -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 +60 -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 +329 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +137 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -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 +640 -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 +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -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 +290 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -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 +356 -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 +501 -0
- code_puppy/tools/agent_tools.py +603 -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 +26 -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 +886 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +244 -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
- codepp-0.0.437.dist-info/METADATA +766 -0
- codepp-0.0.437.dist-info/RECORD +288 -0
- codepp-0.0.437.dist-info/WHEEL +4 -0
- codepp-0.0.437.dist-info/entry_points.txt +3 -0
- 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)
|