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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. 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()