soothe-cli 0.1.0__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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Ask user widget for interactive questions during agent execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
7
|
+
|
|
8
|
+
from textual.binding import Binding, BindingType
|
|
9
|
+
from textual.containers import Container, Vertical
|
|
10
|
+
from textual.content import Content
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.widgets import Input, Markdown, Static
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import asyncio
|
|
16
|
+
|
|
17
|
+
from textual import events
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
|
|
20
|
+
from soothe_cli.tui._ask_user_types import (
|
|
21
|
+
AskUserWidgetResult,
|
|
22
|
+
Choice,
|
|
23
|
+
Question,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from soothe_cli.tui import theme
|
|
27
|
+
from soothe_cli.tui.config import (
|
|
28
|
+
get_glyphs,
|
|
29
|
+
is_ascii_mode,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
OTHER_CHOICE_LABEL = "Other (type your answer)"
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AskUserMenu(Container):
|
|
37
|
+
"""Interactive widget for asking the user questions.
|
|
38
|
+
|
|
39
|
+
Supports text input and multiple choice questions. Multiple choice
|
|
40
|
+
questions always include an "Other" option for free-form input.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
can_focus = True
|
|
44
|
+
can_focus_children = True
|
|
45
|
+
|
|
46
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
47
|
+
Binding("escape", "cancel", "Cancel", show=False),
|
|
48
|
+
Binding("tab", "next_question", "Next question", show=False, priority=True),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
class Answered(Message):
|
|
52
|
+
"""Message sent when user submits all answers."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, answers: list[str]) -> None: # noqa: D107
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.answers = answers
|
|
57
|
+
|
|
58
|
+
class Cancelled(Message):
|
|
59
|
+
"""Message sent when user cancels the ask_user prompt."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None: # noqa: D107
|
|
62
|
+
super().__init__()
|
|
63
|
+
|
|
64
|
+
def __init__( # noqa: D107
|
|
65
|
+
self,
|
|
66
|
+
questions: list[Question],
|
|
67
|
+
id: str | None = None, # noqa: A002
|
|
68
|
+
**kwargs: Any,
|
|
69
|
+
) -> None:
|
|
70
|
+
super().__init__(id=id or "ask-user-menu", classes="ask-user-menu", **kwargs)
|
|
71
|
+
self._questions = questions
|
|
72
|
+
self._answers: list[str] = [""] * len(questions)
|
|
73
|
+
self._current_question = 0
|
|
74
|
+
self._confirmed: list[bool] = [False] * len(questions)
|
|
75
|
+
self._future: asyncio.Future[AskUserWidgetResult] | None = None
|
|
76
|
+
self._question_widgets: list[_QuestionWidget] = []
|
|
77
|
+
self._submitted = False
|
|
78
|
+
|
|
79
|
+
def set_future(self, future: asyncio.Future[AskUserWidgetResult]) -> None:
|
|
80
|
+
"""Set the future to resolve when user answers."""
|
|
81
|
+
self._future = future
|
|
82
|
+
|
|
83
|
+
def compose(self) -> ComposeResult: # noqa: D102
|
|
84
|
+
glyphs = get_glyphs()
|
|
85
|
+
count = len(self._questions)
|
|
86
|
+
label = "Question" if count == 1 else "Questions"
|
|
87
|
+
yield Static(
|
|
88
|
+
f"{glyphs.cursor} Agent has {count} {label} for you",
|
|
89
|
+
classes="ask-user-title",
|
|
90
|
+
)
|
|
91
|
+
yield Static("")
|
|
92
|
+
|
|
93
|
+
with Vertical(classes="ask-user-questions"):
|
|
94
|
+
for i, q in enumerate(self._questions):
|
|
95
|
+
qw = _QuestionWidget(q, index=i)
|
|
96
|
+
self._question_widgets.append(qw)
|
|
97
|
+
yield qw
|
|
98
|
+
|
|
99
|
+
yield Static("")
|
|
100
|
+
parts = [
|
|
101
|
+
f"{glyphs.arrow_up}/{glyphs.arrow_down} Select",
|
|
102
|
+
"Enter to continue",
|
|
103
|
+
]
|
|
104
|
+
if len(self._questions) > 1:
|
|
105
|
+
parts.append("Tab/Shift+Tab switch question")
|
|
106
|
+
parts.append("Esc to cancel")
|
|
107
|
+
yield Static(
|
|
108
|
+
f" {glyphs.bullet} ".join(parts),
|
|
109
|
+
classes="ask-user-help",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def on_mount(self) -> None: # noqa: D102
|
|
113
|
+
if is_ascii_mode():
|
|
114
|
+
colors = theme.get_theme_colors(self)
|
|
115
|
+
self.styles.border = ("ascii", colors.success)
|
|
116
|
+
self._set_active_question(0)
|
|
117
|
+
|
|
118
|
+
def focus_active(self) -> None:
|
|
119
|
+
"""Focus the current active question's input."""
|
|
120
|
+
self._set_active_question(self._current_question)
|
|
121
|
+
|
|
122
|
+
def on_input_submitted(self, event: Input.Submitted) -> None: # noqa: D102
|
|
123
|
+
event.stop()
|
|
124
|
+
# Find which question owns this Input and confirm it.
|
|
125
|
+
for qw in self._question_widgets:
|
|
126
|
+
if (qw._text_input and qw._text_input is event.input) or (
|
|
127
|
+
qw._other_input and qw._other_input is event.input
|
|
128
|
+
):
|
|
129
|
+
answer = qw.get_answer()
|
|
130
|
+
if answer.strip() or not qw._required:
|
|
131
|
+
self.confirm_and_advance(qw._index)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
def confirm_and_advance(self, index: int) -> None:
|
|
135
|
+
"""Confirm the answer at `index` and advance to the next question."""
|
|
136
|
+
self._answers[index] = self._question_widgets[index].get_answer()
|
|
137
|
+
self._confirmed[index] = True
|
|
138
|
+
|
|
139
|
+
# Find next unconfirmed question.
|
|
140
|
+
for i in range(index + 1, len(self._question_widgets)):
|
|
141
|
+
if not self._confirmed[i]:
|
|
142
|
+
self._set_active_question(i)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# All confirmed — collect final answers and submit.
|
|
146
|
+
for i, qw in enumerate(self._question_widgets):
|
|
147
|
+
self._answers[i] = qw.get_answer()
|
|
148
|
+
if all(
|
|
149
|
+
a.strip() or not self._question_widgets[i]._required
|
|
150
|
+
for i, a in enumerate(self._answers)
|
|
151
|
+
):
|
|
152
|
+
self._submit()
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Edge case: a confirmed required text field was left empty
|
|
156
|
+
# (shouldn't happen normally). Re-open it.
|
|
157
|
+
for i, a in enumerate(self._answers):
|
|
158
|
+
if not a.strip() and self._question_widgets[i]._required:
|
|
159
|
+
self._confirmed[i] = False
|
|
160
|
+
self._set_active_question(i)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
def _set_active_question(self, index: int) -> None:
|
|
164
|
+
"""Update the visual indicator and focus for the active question."""
|
|
165
|
+
self._current_question = index
|
|
166
|
+
for i, qw in enumerate(self._question_widgets):
|
|
167
|
+
if i == index:
|
|
168
|
+
qw.add_class("ask-user-question-active")
|
|
169
|
+
qw.remove_class("ask-user-question-inactive")
|
|
170
|
+
qw.focus_input()
|
|
171
|
+
else:
|
|
172
|
+
qw.remove_class("ask-user-question-active")
|
|
173
|
+
qw.add_class("ask-user-question-inactive")
|
|
174
|
+
|
|
175
|
+
def _submit(self) -> None:
|
|
176
|
+
if self._submitted:
|
|
177
|
+
return
|
|
178
|
+
self._submitted = True
|
|
179
|
+
if self._future and not self._future.done():
|
|
180
|
+
self._future.set_result({"type": "answered", "answers": self._answers})
|
|
181
|
+
self.post_message(self.Answered(self._answers))
|
|
182
|
+
|
|
183
|
+
def action_next_question(self) -> None:
|
|
184
|
+
"""Navigate to the next question without confirming."""
|
|
185
|
+
if self._current_question < len(self._question_widgets) - 1:
|
|
186
|
+
self._set_active_question(self._current_question + 1)
|
|
187
|
+
|
|
188
|
+
def action_previous_question(self) -> None:
|
|
189
|
+
"""Navigate to the previous question without confirming."""
|
|
190
|
+
if self._current_question > 0:
|
|
191
|
+
self._set_active_question(self._current_question - 1)
|
|
192
|
+
|
|
193
|
+
def action_cancel(self) -> None: # noqa: D102
|
|
194
|
+
if self._submitted:
|
|
195
|
+
return
|
|
196
|
+
self._submitted = True
|
|
197
|
+
if self._future and not self._future.done():
|
|
198
|
+
self._future.set_result({"type": "cancelled"})
|
|
199
|
+
self.post_message(self.Cancelled())
|
|
200
|
+
|
|
201
|
+
def on_blur(self, event: events.Blur) -> None: # noqa: PLR6301 # Textual event handler
|
|
202
|
+
"""Prevent blur from propagating and dismissing the menu."""
|
|
203
|
+
event.stop()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _ChoiceOption(Static):
|
|
207
|
+
"""A single selectable choice option."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, text: str, index: int, *, selected: bool = False, **kwargs: Any) -> None:
|
|
210
|
+
self.choice_index: int = index
|
|
211
|
+
self.selected: bool = selected
|
|
212
|
+
self._text: str = text
|
|
213
|
+
super().__init__(self._render(), classes="ask-user-choice", **kwargs)
|
|
214
|
+
|
|
215
|
+
def toggle(self) -> None:
|
|
216
|
+
"""Toggle the selected state."""
|
|
217
|
+
self.selected = not self.selected
|
|
218
|
+
self.update(self._render())
|
|
219
|
+
|
|
220
|
+
def select(self) -> None:
|
|
221
|
+
"""Mark this choice as selected."""
|
|
222
|
+
self.selected = True
|
|
223
|
+
self.update(self._render())
|
|
224
|
+
|
|
225
|
+
def deselect(self) -> None:
|
|
226
|
+
"""Mark this choice as deselected."""
|
|
227
|
+
self.selected = False
|
|
228
|
+
self.update(self._render())
|
|
229
|
+
|
|
230
|
+
def _render(self) -> Content:
|
|
231
|
+
"""Build display content with cursor prefix.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Styled Content with selection cursor and label text.
|
|
235
|
+
"""
|
|
236
|
+
glyphs = get_glyphs()
|
|
237
|
+
prefix = f"{glyphs.cursor} " if self.selected else " "
|
|
238
|
+
return Content.from_markup("$prefix$text", prefix=prefix, text=self._text)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class _QuestionWidget(Vertical):
|
|
242
|
+
"""Widget for a single question (text or multiple choice)."""
|
|
243
|
+
|
|
244
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
245
|
+
Binding("up", "move_up", "Up", show=False),
|
|
246
|
+
Binding("k", "move_up", "Up", show=False),
|
|
247
|
+
Binding("down", "move_down", "Down", show=False),
|
|
248
|
+
Binding("j", "move_down", "Down", show=False),
|
|
249
|
+
Binding("enter", "select_or_submit", "Select", show=False),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
can_focus = True
|
|
253
|
+
can_focus_children = True
|
|
254
|
+
|
|
255
|
+
def __init__(self, question: Question, index: int, **kwargs: Any) -> None:
|
|
256
|
+
super().__init__(classes="ask-user-question", **kwargs)
|
|
257
|
+
question_type = question.get("type", "text")
|
|
258
|
+
self._question: Question = question
|
|
259
|
+
self._index: int = index
|
|
260
|
+
self._q_type: Literal["text", "multiple_choice"] = (
|
|
261
|
+
"multiple_choice" if question_type == "multiple_choice" else "text"
|
|
262
|
+
)
|
|
263
|
+
self._choices: list[Choice] = question.get("choices", [])
|
|
264
|
+
self._required: bool = question.get("required", True)
|
|
265
|
+
self._choice_widgets: list[_ChoiceOption] = []
|
|
266
|
+
self._selected_choice: int = 0
|
|
267
|
+
self._text_input: Input | None = None
|
|
268
|
+
self._other_input: Input | None = None
|
|
269
|
+
self._is_other_selected: bool = False
|
|
270
|
+
|
|
271
|
+
def compose(self) -> ComposeResult:
|
|
272
|
+
q_text = self._question.get("question", "")
|
|
273
|
+
num = self._index + 1
|
|
274
|
+
suffix = " *(required)*" if self._required else ""
|
|
275
|
+
# q_text is agent-authored; rendered as markdown intentionally so
|
|
276
|
+
# agents can use inline formatting, links, and code spans in questions.
|
|
277
|
+
yield Markdown(f"**{num}.** {q_text}{suffix}", classes="ask-user-question-text")
|
|
278
|
+
|
|
279
|
+
if self._q_type == "multiple_choice" and self._choices:
|
|
280
|
+
for i, choice in enumerate(self._choices):
|
|
281
|
+
label = choice.get("value", str(choice))
|
|
282
|
+
cw = _ChoiceOption(label, index=i, selected=(i == 0))
|
|
283
|
+
self._choice_widgets.append(cw)
|
|
284
|
+
yield cw
|
|
285
|
+
|
|
286
|
+
other_cw = _ChoiceOption(OTHER_CHOICE_LABEL, index=len(self._choices))
|
|
287
|
+
self._choice_widgets.append(other_cw)
|
|
288
|
+
yield other_cw
|
|
289
|
+
|
|
290
|
+
self._other_input = Input(
|
|
291
|
+
placeholder="Type your answer...",
|
|
292
|
+
classes="ask-user-other-input",
|
|
293
|
+
)
|
|
294
|
+
self._other_input.display = False
|
|
295
|
+
yield self._other_input
|
|
296
|
+
else:
|
|
297
|
+
self._text_input = Input(
|
|
298
|
+
placeholder="Type your answer...",
|
|
299
|
+
classes="ask-user-text-input",
|
|
300
|
+
)
|
|
301
|
+
yield self._text_input
|
|
302
|
+
|
|
303
|
+
def focus_input(self) -> None:
|
|
304
|
+
"""Focus the appropriate input for this question."""
|
|
305
|
+
if self._text_input:
|
|
306
|
+
self._text_input.focus()
|
|
307
|
+
elif self._is_other_selected and self._other_input:
|
|
308
|
+
self._other_input.focus()
|
|
309
|
+
elif self._choice_widgets:
|
|
310
|
+
self.focus()
|
|
311
|
+
|
|
312
|
+
def get_answer(self) -> str:
|
|
313
|
+
"""Return the current answer text for this question."""
|
|
314
|
+
if self._q_type == "text" or not self._choices:
|
|
315
|
+
return self._text_input.value if self._text_input else ""
|
|
316
|
+
|
|
317
|
+
if self._is_other_selected and self._other_input:
|
|
318
|
+
return self._other_input.value
|
|
319
|
+
|
|
320
|
+
if self._choice_widgets and self._selected_choice < len(self._choices):
|
|
321
|
+
return self._choices[self._selected_choice].get("value", "")
|
|
322
|
+
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
def action_move_up(self) -> None:
|
|
326
|
+
"""Move selection up in the choice list."""
|
|
327
|
+
if self._q_type != "multiple_choice" or not self._choice_widgets:
|
|
328
|
+
return
|
|
329
|
+
if self._is_other_selected and self._other_input and self._other_input.has_focus:
|
|
330
|
+
# Jump directly to the last real choice instead of requiring
|
|
331
|
+
# two presses (one to defocus, one to navigate).
|
|
332
|
+
self._selected_choice = max(0, len(self._choices) - 1)
|
|
333
|
+
self._update_choice_selection()
|
|
334
|
+
self.focus()
|
|
335
|
+
return
|
|
336
|
+
old = self._selected_choice
|
|
337
|
+
self._selected_choice = max(0, self._selected_choice - 1)
|
|
338
|
+
if old != self._selected_choice:
|
|
339
|
+
self._update_choice_selection()
|
|
340
|
+
|
|
341
|
+
def action_move_down(self) -> None:
|
|
342
|
+
"""Move selection down in the choice list."""
|
|
343
|
+
if self._q_type != "multiple_choice" or not self._choice_widgets:
|
|
344
|
+
return
|
|
345
|
+
max_idx = len(self._choice_widgets) - 1
|
|
346
|
+
old = self._selected_choice
|
|
347
|
+
self._selected_choice = min(max_idx, self._selected_choice + 1)
|
|
348
|
+
if old != self._selected_choice:
|
|
349
|
+
self._update_choice_selection()
|
|
350
|
+
|
|
351
|
+
def action_select_or_submit(self) -> None:
|
|
352
|
+
"""Confirm current choice or open the Other input."""
|
|
353
|
+
if self._q_type == "multiple_choice" and self._choice_widgets:
|
|
354
|
+
is_other = self._selected_choice == len(self._choices)
|
|
355
|
+
if is_other:
|
|
356
|
+
self._is_other_selected = True
|
|
357
|
+
if self._other_input:
|
|
358
|
+
self._other_input.display = True
|
|
359
|
+
self._other_input.focus()
|
|
360
|
+
else:
|
|
361
|
+
self._is_other_selected = False
|
|
362
|
+
if self._other_input:
|
|
363
|
+
self._other_input.display = False
|
|
364
|
+
menu = self._find_menu()
|
|
365
|
+
if menu is not None:
|
|
366
|
+
menu.confirm_and_advance(self._index)
|
|
367
|
+
|
|
368
|
+
def _find_menu(self) -> AskUserMenu | None:
|
|
369
|
+
node: Any = self.parent
|
|
370
|
+
while node is not None:
|
|
371
|
+
if isinstance(node, AskUserMenu):
|
|
372
|
+
return node
|
|
373
|
+
node = node.parent
|
|
374
|
+
logger.warning(
|
|
375
|
+
"Failed to find AskUserMenu ancestor for question index %d",
|
|
376
|
+
self._index,
|
|
377
|
+
)
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def _update_choice_selection(self) -> None:
|
|
381
|
+
for i, cw in enumerate(self._choice_widgets):
|
|
382
|
+
if i == self._selected_choice:
|
|
383
|
+
cw.select()
|
|
384
|
+
else:
|
|
385
|
+
cw.deselect()
|
|
386
|
+
|
|
387
|
+
is_other = self._selected_choice == len(self._choices)
|
|
388
|
+
self._is_other_selected = is_other
|
|
389
|
+
if self._other_input:
|
|
390
|
+
self._other_input.display = is_other
|
|
391
|
+
if is_other:
|
|
392
|
+
self._other_input.focus()
|