zjcode 0.0.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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""Ask user widget for interactive questions during agent execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
8
|
+
|
|
9
|
+
from textual.binding import Binding, BindingType
|
|
10
|
+
from textual.containers import Container, Vertical
|
|
11
|
+
from textual.content import Content
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widgets import Markdown, Static, TextArea
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import asyncio
|
|
17
|
+
|
|
18
|
+
from textual import events
|
|
19
|
+
from textual.app import ComposeResult
|
|
20
|
+
|
|
21
|
+
from deepagents_code._ask_user_types import (
|
|
22
|
+
AskUserWidgetResult,
|
|
23
|
+
Choice,
|
|
24
|
+
Question,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from deepagents_code import theme
|
|
28
|
+
from deepagents_code.config import (
|
|
29
|
+
get_glyphs,
|
|
30
|
+
is_ascii_mode,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
OTHER_CHOICE_LABEL = "Other (type your answer)"
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_TRAILING_ANNOTATION_RE = re.compile(
|
|
37
|
+
# \u2013 = en-dash, \u2014 = em-dash.
|
|
38
|
+
r"""
|
|
39
|
+
\s*
|
|
40
|
+
(?:
|
|
41
|
+
[-\u2013\u2014]\s*(?:optional|required)
|
|
42
|
+
| \((?:optional|required)[.!?]?\)
|
|
43
|
+
| \[(?:optional|required)[.!?]?\]
|
|
44
|
+
)
|
|
45
|
+
[.!?]*
|
|
46
|
+
\s*$
|
|
47
|
+
""",
|
|
48
|
+
re.IGNORECASE | re.VERBOSE,
|
|
49
|
+
)
|
|
50
|
+
"""Strip LLM-appended trailing annotations like ' - optional', ' (optional)',
|
|
51
|
+
or ' [required]' from question text before rendering.
|
|
52
|
+
|
|
53
|
+
Defense-in-depth alongside the instruction in `ASK_USER_TOOL_DESCRIPTION`
|
|
54
|
+
(`ask_user.py`). The UI already renders a `*(required)*` marker based on the
|
|
55
|
+
`required` field, so any LLM-authored duplicate is redundant noise."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AskUserTextArea(TextArea):
|
|
59
|
+
"""Soft-wrapping text input for free-form ask-user questions.
|
|
60
|
+
|
|
61
|
+
Long answers wrap visually and the widget grows up to its CSS `max-height`.
|
|
62
|
+
Enter submits; Shift/Alt/Ctrl+Enter and Ctrl+J insert a literal newline
|
|
63
|
+
for users who want to author multi-paragraph answers.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
67
|
+
Binding(
|
|
68
|
+
"shift+enter,alt+enter,ctrl+enter,ctrl+j",
|
|
69
|
+
"insert_newline",
|
|
70
|
+
"New Line",
|
|
71
|
+
show=False,
|
|
72
|
+
priority=True,
|
|
73
|
+
),
|
|
74
|
+
Binding(
|
|
75
|
+
"ctrl+backspace,alt+backspace",
|
|
76
|
+
"delete_word_left",
|
|
77
|
+
"Delete left to start of word",
|
|
78
|
+
show=False,
|
|
79
|
+
),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
class Submitted(Message):
|
|
83
|
+
"""Posted when the user presses Enter to submit the answer."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, text_area: AskUserTextArea, value: str) -> None: # noqa: D107
|
|
86
|
+
super().__init__()
|
|
87
|
+
self.text_area = text_area
|
|
88
|
+
self.value = value
|
|
89
|
+
|
|
90
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
91
|
+
"""Initialize the ask-user text area."""
|
|
92
|
+
super().__init__(**kwargs)
|
|
93
|
+
self.show_line_numbers = False
|
|
94
|
+
self.soft_wrap = True
|
|
95
|
+
|
|
96
|
+
def action_insert_newline(self) -> None:
|
|
97
|
+
"""Insert a newline at the cursor."""
|
|
98
|
+
self.insert("\n")
|
|
99
|
+
|
|
100
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
101
|
+
if event.key == "enter":
|
|
102
|
+
event.prevent_default()
|
|
103
|
+
event.stop()
|
|
104
|
+
self.post_message(self.Submitted(self, self.text))
|
|
105
|
+
return
|
|
106
|
+
if event.key in {"up", "down"}:
|
|
107
|
+
cursor_location = self.cursor_location
|
|
108
|
+
at_top = self.get_cursor_up_location() == cursor_location
|
|
109
|
+
at_bottom = self.get_cursor_down_location() == cursor_location
|
|
110
|
+
if (event.key == "up" and at_top) or (event.key == "down" and at_bottom):
|
|
111
|
+
question = self._find_question_widget()
|
|
112
|
+
if question is not None and question._q_type == "multiple_choice":
|
|
113
|
+
event.prevent_default()
|
|
114
|
+
event.stop()
|
|
115
|
+
if event.key == "up":
|
|
116
|
+
question.action_move_up()
|
|
117
|
+
else:
|
|
118
|
+
question.action_move_down()
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
def _find_question_widget(self) -> _QuestionWidget | None:
|
|
122
|
+
"""Walk up to find the enclosing `_QuestionWidget`, if any.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The enclosing `_QuestionWidget` ancestor, or `None` if not found.
|
|
126
|
+
"""
|
|
127
|
+
node: Any = self.parent
|
|
128
|
+
while node is not None:
|
|
129
|
+
if isinstance(node, _QuestionWidget):
|
|
130
|
+
return node
|
|
131
|
+
node = node.parent
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class AskUserMenu(Container):
|
|
136
|
+
"""Interactive widget for asking the user questions.
|
|
137
|
+
|
|
138
|
+
Supports text input and multiple choice questions. Multiple choice
|
|
139
|
+
questions always include an "Other" option for free-form input.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
can_focus = True
|
|
143
|
+
can_focus_children = True
|
|
144
|
+
|
|
145
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
146
|
+
Binding("escape", "cancel", "Cancel", show=False),
|
|
147
|
+
Binding("tab", "next_question", "Next question", show=False, priority=True),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
class Answered(Message):
|
|
151
|
+
"""Message sent when user submits all answers."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, answers: list[str]) -> None: # noqa: D107
|
|
154
|
+
super().__init__()
|
|
155
|
+
self.answers = answers
|
|
156
|
+
|
|
157
|
+
class Cancelled(Message):
|
|
158
|
+
"""Message sent when user cancels the ask_user prompt."""
|
|
159
|
+
|
|
160
|
+
def __init__(self) -> None: # noqa: D107
|
|
161
|
+
super().__init__()
|
|
162
|
+
|
|
163
|
+
def __init__( # noqa: D107
|
|
164
|
+
self,
|
|
165
|
+
questions: list[Question],
|
|
166
|
+
id: str | None = None, # noqa: A002
|
|
167
|
+
**kwargs: Any,
|
|
168
|
+
) -> None:
|
|
169
|
+
super().__init__(id=id or "ask-user-menu", classes="ask-user-menu", **kwargs)
|
|
170
|
+
self._questions = questions
|
|
171
|
+
self._answers: list[str] = [""] * len(questions)
|
|
172
|
+
self._current_question = 0
|
|
173
|
+
self._confirmed: list[bool] = [False] * len(questions)
|
|
174
|
+
self._future: asyncio.Future[AskUserWidgetResult] | None = None
|
|
175
|
+
self._question_widgets: list[_QuestionWidget] = []
|
|
176
|
+
self._submitted = False
|
|
177
|
+
|
|
178
|
+
def set_future(self, future: asyncio.Future[AskUserWidgetResult]) -> None:
|
|
179
|
+
"""Set the future to resolve when user answers."""
|
|
180
|
+
self._future = future
|
|
181
|
+
|
|
182
|
+
def compose(self) -> ComposeResult: # noqa: D102
|
|
183
|
+
glyphs = get_glyphs()
|
|
184
|
+
count = len(self._questions)
|
|
185
|
+
if count == 1:
|
|
186
|
+
title = "Agent has a question for you"
|
|
187
|
+
else:
|
|
188
|
+
title = f"Agent has {count} Questions for you"
|
|
189
|
+
yield Static(
|
|
190
|
+
f"{glyphs.cursor} {title}",
|
|
191
|
+
classes="ask-user-title",
|
|
192
|
+
)
|
|
193
|
+
yield Static("")
|
|
194
|
+
|
|
195
|
+
with Vertical(classes="ask-user-questions"):
|
|
196
|
+
for i, q in enumerate(self._questions):
|
|
197
|
+
qw = _QuestionWidget(q, index=i)
|
|
198
|
+
self._question_widgets.append(qw)
|
|
199
|
+
yield qw
|
|
200
|
+
|
|
201
|
+
yield Static("")
|
|
202
|
+
parts = [
|
|
203
|
+
f"{glyphs.arrow_up}/{glyphs.arrow_down} Select",
|
|
204
|
+
"Enter to continue",
|
|
205
|
+
]
|
|
206
|
+
if len(self._questions) > 1:
|
|
207
|
+
parts.append("Tab/Shift+Tab switch question")
|
|
208
|
+
parts.append("Esc to cancel")
|
|
209
|
+
yield Static(
|
|
210
|
+
f" {glyphs.bullet} ".join(parts),
|
|
211
|
+
classes="ask-user-help",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
async def on_mount(self) -> None: # noqa: D102
|
|
215
|
+
if is_ascii_mode():
|
|
216
|
+
colors = theme.get_theme_colors(self)
|
|
217
|
+
self.styles.border = ("ascii", colors.success)
|
|
218
|
+
self._set_active_question(0)
|
|
219
|
+
|
|
220
|
+
def focus_active(self) -> None:
|
|
221
|
+
"""Focus the current active question's input."""
|
|
222
|
+
self._set_active_question(self._current_question)
|
|
223
|
+
|
|
224
|
+
def on_ask_user_text_area_submitted(self, event: AskUserTextArea.Submitted) -> None:
|
|
225
|
+
"""Confirm the question whose text area was submitted."""
|
|
226
|
+
event.stop()
|
|
227
|
+
for qw in self._question_widgets:
|
|
228
|
+
if (qw._text_input and qw._text_input is event.text_area) or (
|
|
229
|
+
qw._other_input and qw._other_input is event.text_area
|
|
230
|
+
):
|
|
231
|
+
answer = qw.get_answer()
|
|
232
|
+
if answer.strip() or not qw._required:
|
|
233
|
+
self.confirm_and_advance(qw._index)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
def confirm_and_advance(self, index: int) -> None:
|
|
237
|
+
"""Confirm the answer at `index` and advance to the next question."""
|
|
238
|
+
self._answers[index] = self._question_widgets[index].get_answer()
|
|
239
|
+
self._confirmed[index] = True
|
|
240
|
+
|
|
241
|
+
# Find next unconfirmed question.
|
|
242
|
+
for i in range(index + 1, len(self._question_widgets)):
|
|
243
|
+
if not self._confirmed[i]:
|
|
244
|
+
self._set_active_question(i)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
# All confirmed — collect final answers and submit.
|
|
248
|
+
for i, qw in enumerate(self._question_widgets):
|
|
249
|
+
self._answers[i] = qw.get_answer()
|
|
250
|
+
if all(
|
|
251
|
+
a.strip() or not self._question_widgets[i]._required
|
|
252
|
+
for i, a in enumerate(self._answers)
|
|
253
|
+
):
|
|
254
|
+
self._submit()
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Edge case: a confirmed required text field was left empty
|
|
258
|
+
# (shouldn't happen normally). Re-open it.
|
|
259
|
+
for i, a in enumerate(self._answers):
|
|
260
|
+
if not a.strip() and self._question_widgets[i]._required:
|
|
261
|
+
self._confirmed[i] = False
|
|
262
|
+
self._set_active_question(i)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
def _set_active_question(self, index: int) -> None:
|
|
266
|
+
"""Update the visual indicator and focus for the active question."""
|
|
267
|
+
self._highlight_question(index)
|
|
268
|
+
self._question_widgets[index].focus_input()
|
|
269
|
+
|
|
270
|
+
def _highlight_question(self, index: int) -> None:
|
|
271
|
+
"""Highlight `index` and dim the rest without changing focus."""
|
|
272
|
+
self._current_question = index
|
|
273
|
+
for i, qw in enumerate(self._question_widgets):
|
|
274
|
+
if i == index:
|
|
275
|
+
qw.add_class("ask-user-question-active")
|
|
276
|
+
qw.remove_class("ask-user-question-inactive")
|
|
277
|
+
else:
|
|
278
|
+
qw.remove_class("ask-user-question-active")
|
|
279
|
+
qw.add_class("ask-user-question-inactive")
|
|
280
|
+
|
|
281
|
+
def _submit(self) -> None:
|
|
282
|
+
if self._submitted:
|
|
283
|
+
return
|
|
284
|
+
self._submitted = True
|
|
285
|
+
if self._future and not self._future.done():
|
|
286
|
+
self._future.set_result({"type": "answered", "answers": self._answers})
|
|
287
|
+
self.post_message(self.Answered(self._answers))
|
|
288
|
+
|
|
289
|
+
def action_next_question(self) -> None:
|
|
290
|
+
"""Navigate to the next question without confirming."""
|
|
291
|
+
if self._current_question < len(self._question_widgets) - 1:
|
|
292
|
+
self._set_active_question(self._current_question + 1)
|
|
293
|
+
|
|
294
|
+
def action_previous_question(self) -> None:
|
|
295
|
+
"""Navigate to the previous question without confirming."""
|
|
296
|
+
if self._current_question > 0:
|
|
297
|
+
self._set_active_question(self._current_question - 1)
|
|
298
|
+
|
|
299
|
+
def action_cancel(self) -> None: # noqa: D102
|
|
300
|
+
if self._submitted:
|
|
301
|
+
return
|
|
302
|
+
self._submitted = True
|
|
303
|
+
if self._future and not self._future.done():
|
|
304
|
+
self._future.set_result({"type": "cancelled"})
|
|
305
|
+
self.post_message(self.Cancelled())
|
|
306
|
+
|
|
307
|
+
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
|
|
308
|
+
"""Keep the active-question highlight in sync with focus.
|
|
309
|
+
|
|
310
|
+
A mouse click moves focus into another question's text input, or onto
|
|
311
|
+
the question container itself for multiple-choice (whose choices are
|
|
312
|
+
not individually focusable), without going through
|
|
313
|
+
`_set_active_question`, which would otherwise leave the highlight on
|
|
314
|
+
the previously active question. Sync the highlight to the focused
|
|
315
|
+
question so exactly one question is ever active. Focus is not moved
|
|
316
|
+
here, so the widget the user clicked keeps focus.
|
|
317
|
+
"""
|
|
318
|
+
node: Any = event.widget
|
|
319
|
+
while node is not None and not isinstance(node, _QuestionWidget):
|
|
320
|
+
node = node.parent
|
|
321
|
+
if node is not None and node._index != self._current_question:
|
|
322
|
+
self._highlight_question(node._index)
|
|
323
|
+
|
|
324
|
+
def on_blur(self, event: events.Blur) -> None: # noqa: PLR6301 # Textual event handler
|
|
325
|
+
"""Prevent blur from propagating and dismissing the menu."""
|
|
326
|
+
event.stop()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class _ChoiceOption(Static):
|
|
330
|
+
"""A single selectable choice option."""
|
|
331
|
+
|
|
332
|
+
def __init__(
|
|
333
|
+
self, text: str, index: int, *, selected: bool = False, **kwargs: Any
|
|
334
|
+
) -> None:
|
|
335
|
+
self.choice_index: int = index
|
|
336
|
+
self.selected: bool = selected
|
|
337
|
+
self._text: str = text
|
|
338
|
+
super().__init__(self._render(), classes="ask-user-choice", **kwargs)
|
|
339
|
+
|
|
340
|
+
def toggle(self) -> None:
|
|
341
|
+
"""Toggle the selected state."""
|
|
342
|
+
self.selected = not self.selected
|
|
343
|
+
self.update(self._render())
|
|
344
|
+
|
|
345
|
+
def select(self) -> None:
|
|
346
|
+
"""Mark this choice as selected."""
|
|
347
|
+
self.selected = True
|
|
348
|
+
self.update(self._render())
|
|
349
|
+
|
|
350
|
+
def deselect(self) -> None:
|
|
351
|
+
"""Mark this choice as deselected."""
|
|
352
|
+
self.selected = False
|
|
353
|
+
self.update(self._render())
|
|
354
|
+
|
|
355
|
+
def _render(self) -> Content:
|
|
356
|
+
"""Build display content with cursor prefix.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Styled Content with selection cursor and label text.
|
|
360
|
+
"""
|
|
361
|
+
glyphs = get_glyphs()
|
|
362
|
+
prefix = f"{glyphs.cursor} " if self.selected else " "
|
|
363
|
+
return Content.from_markup("$prefix$text", prefix=prefix, text=self._text)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class _QuestionWidget(Vertical):
|
|
367
|
+
"""Widget for a single question (text or multiple choice)."""
|
|
368
|
+
|
|
369
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
370
|
+
Binding("up", "move_up", "Up", show=False),
|
|
371
|
+
Binding("k", "move_up", "Up", show=False),
|
|
372
|
+
Binding("down", "move_down", "Down", show=False),
|
|
373
|
+
Binding("j", "move_down", "Down", show=False),
|
|
374
|
+
Binding("enter", "select_or_submit", "Select", show=False),
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
can_focus = True
|
|
378
|
+
can_focus_children = True
|
|
379
|
+
|
|
380
|
+
def __init__(self, question: Question, index: int, **kwargs: Any) -> None:
|
|
381
|
+
super().__init__(classes="ask-user-question", **kwargs)
|
|
382
|
+
question_type = question.get("type", "text")
|
|
383
|
+
self._question: Question = question
|
|
384
|
+
self._index: int = index
|
|
385
|
+
self._q_type: Literal["text", "multiple_choice"] = (
|
|
386
|
+
"multiple_choice" if question_type == "multiple_choice" else "text"
|
|
387
|
+
)
|
|
388
|
+
self._choices: list[Choice] = question.get("choices", [])
|
|
389
|
+
self._required: bool = question.get("required", True)
|
|
390
|
+
self._choice_widgets: list[_ChoiceOption] = []
|
|
391
|
+
self._selected_choice: int = 0
|
|
392
|
+
self._text_input: AskUserTextArea | None = None
|
|
393
|
+
self._other_input: AskUserTextArea | None = None
|
|
394
|
+
self._is_other_selected: bool = False
|
|
395
|
+
|
|
396
|
+
def compose(self) -> ComposeResult:
|
|
397
|
+
q_text = _TRAILING_ANNOTATION_RE.sub("", self._question.get("question", ""))
|
|
398
|
+
num = self._index + 1
|
|
399
|
+
suffix = " *(required)*" if self._required else ""
|
|
400
|
+
# q_text is agent-authored; rendered as markdown intentionally so
|
|
401
|
+
# agents can use inline formatting, links, and code spans in questions.
|
|
402
|
+
yield Markdown(f"**{num}.** {q_text}{suffix}", classes="ask-user-question-text")
|
|
403
|
+
|
|
404
|
+
if self._q_type == "multiple_choice" and self._choices:
|
|
405
|
+
for i, choice in enumerate(self._choices):
|
|
406
|
+
label = choice.get("value", str(choice))
|
|
407
|
+
cw = _ChoiceOption(label, index=i, selected=(i == 0))
|
|
408
|
+
self._choice_widgets.append(cw)
|
|
409
|
+
yield cw
|
|
410
|
+
|
|
411
|
+
other_cw = _ChoiceOption(OTHER_CHOICE_LABEL, index=len(self._choices))
|
|
412
|
+
self._choice_widgets.append(other_cw)
|
|
413
|
+
yield other_cw
|
|
414
|
+
|
|
415
|
+
self._other_input = AskUserTextArea(classes="ask-user-other-input")
|
|
416
|
+
self._other_input.display = False
|
|
417
|
+
yield self._other_input
|
|
418
|
+
else:
|
|
419
|
+
self._text_input = AskUserTextArea(classes="ask-user-text-input")
|
|
420
|
+
yield self._text_input
|
|
421
|
+
|
|
422
|
+
def focus_input(self) -> None:
|
|
423
|
+
"""Focus the appropriate input for this question."""
|
|
424
|
+
if self._text_input:
|
|
425
|
+
self._text_input.focus()
|
|
426
|
+
elif self._is_other_selected and self._other_input:
|
|
427
|
+
self._other_input.focus()
|
|
428
|
+
elif self._choice_widgets:
|
|
429
|
+
self.focus()
|
|
430
|
+
|
|
431
|
+
def get_answer(self) -> str:
|
|
432
|
+
"""Return the current answer text for this question."""
|
|
433
|
+
if self._q_type == "text" or not self._choices:
|
|
434
|
+
return self._text_input.text if self._text_input else ""
|
|
435
|
+
|
|
436
|
+
if self._is_other_selected and self._other_input:
|
|
437
|
+
return self._other_input.text
|
|
438
|
+
|
|
439
|
+
if self._choice_widgets and self._selected_choice < len(self._choices):
|
|
440
|
+
return self._choices[self._selected_choice].get("value", "")
|
|
441
|
+
|
|
442
|
+
return ""
|
|
443
|
+
|
|
444
|
+
def action_move_up(self) -> None:
|
|
445
|
+
"""Move selection up in the choice list."""
|
|
446
|
+
if self._q_type != "multiple_choice" or not self._choice_widgets:
|
|
447
|
+
return
|
|
448
|
+
if (
|
|
449
|
+
self._is_other_selected
|
|
450
|
+
and self._other_input
|
|
451
|
+
and self._other_input.has_focus
|
|
452
|
+
):
|
|
453
|
+
# Jump directly to the last real choice instead of requiring
|
|
454
|
+
# two presses (one to defocus, one to navigate).
|
|
455
|
+
self._selected_choice = max(0, len(self._choices) - 1)
|
|
456
|
+
self._update_choice_selection()
|
|
457
|
+
self.focus()
|
|
458
|
+
return
|
|
459
|
+
old = self._selected_choice
|
|
460
|
+
self._selected_choice = max(0, self._selected_choice - 1)
|
|
461
|
+
if old != self._selected_choice:
|
|
462
|
+
self._update_choice_selection()
|
|
463
|
+
|
|
464
|
+
def action_move_down(self) -> None:
|
|
465
|
+
"""Move selection down in the choice list."""
|
|
466
|
+
if self._q_type != "multiple_choice" or not self._choice_widgets:
|
|
467
|
+
return
|
|
468
|
+
max_idx = len(self._choice_widgets) - 1
|
|
469
|
+
old = self._selected_choice
|
|
470
|
+
self._selected_choice = min(max_idx, self._selected_choice + 1)
|
|
471
|
+
if old != self._selected_choice:
|
|
472
|
+
self._update_choice_selection()
|
|
473
|
+
|
|
474
|
+
def action_select_or_submit(self) -> None:
|
|
475
|
+
"""Confirm current choice or open the Other input."""
|
|
476
|
+
if self._q_type == "multiple_choice" and self._choice_widgets:
|
|
477
|
+
is_other = self._selected_choice == len(self._choices)
|
|
478
|
+
if is_other:
|
|
479
|
+
self._is_other_selected = True
|
|
480
|
+
if self._other_input:
|
|
481
|
+
self._other_input.display = True
|
|
482
|
+
self._other_input.focus()
|
|
483
|
+
else:
|
|
484
|
+
self._is_other_selected = False
|
|
485
|
+
if self._other_input:
|
|
486
|
+
self._other_input.display = False
|
|
487
|
+
menu = self._find_menu()
|
|
488
|
+
if menu is not None:
|
|
489
|
+
menu.confirm_and_advance(self._index)
|
|
490
|
+
|
|
491
|
+
def _find_menu(self) -> AskUserMenu | None:
|
|
492
|
+
node: Any = self.parent
|
|
493
|
+
while node is not None:
|
|
494
|
+
if isinstance(node, AskUserMenu):
|
|
495
|
+
return node
|
|
496
|
+
node = node.parent
|
|
497
|
+
logger.warning(
|
|
498
|
+
"Failed to find AskUserMenu ancestor for question index %d",
|
|
499
|
+
self._index,
|
|
500
|
+
)
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
def _update_choice_selection(self) -> None:
|
|
504
|
+
for i, cw in enumerate(self._choice_widgets):
|
|
505
|
+
if i == self._selected_choice:
|
|
506
|
+
cw.select()
|
|
507
|
+
else:
|
|
508
|
+
cw.deselect()
|
|
509
|
+
|
|
510
|
+
is_other = self._selected_choice == len(self._choices)
|
|
511
|
+
self._is_other_selected = is_other
|
|
512
|
+
if self._other_input:
|
|
513
|
+
self._other_input.display = is_other
|
|
514
|
+
if is_other:
|
|
515
|
+
self._other_input.focus()
|