codemaster-cli 2.2.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.
Files changed (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. vibe/whats_new.md +5 -0
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ from typing import TYPE_CHECKING, ClassVar
5
+
6
+ from textual import events
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding, BindingType
9
+ from textual.containers import Container, Horizontal, Vertical
10
+ from textual.message import Message
11
+ from textual.reactive import reactive
12
+ from textual.widgets import Input
13
+
14
+ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
15
+
16
+ if TYPE_CHECKING:
17
+ from vibe.core.tools.builtins.ask_user_question import (
18
+ AskUserQuestionArgs,
19
+ Choice,
20
+ Question,
21
+ )
22
+
23
+ from vibe.core.tools.builtins.ask_user_question import Answer
24
+
25
+
26
+ class QuestionApp(Container):
27
+ MAX_OPTIONS: ClassVar[int] = 4
28
+
29
+ can_focus = True
30
+ can_focus_children = False
31
+
32
+ current_question_idx: reactive[int] = reactive(0)
33
+ selected_option: reactive[int] = reactive(0)
34
+
35
+ BINDINGS: ClassVar[list[BindingType]] = [
36
+ Binding("up", "move_up", "Up", show=False),
37
+ Binding("down", "move_down", "Down", show=False),
38
+ Binding("enter", "select", "Select", show=False),
39
+ Binding("escape", "cancel", "Cancel", show=False),
40
+ ]
41
+
42
+ class Answered(Message):
43
+ def __init__(self, answers: list[Answer]) -> None:
44
+ super().__init__()
45
+ self.answers = answers
46
+
47
+ class Cancelled(Message):
48
+ pass
49
+
50
+ def __init__(self, args: AskUserQuestionArgs) -> None:
51
+ super().__init__(id="question-app")
52
+ self.args = args
53
+ self.questions = args.questions
54
+
55
+ self.answers: dict[int, tuple[str, bool]] = {}
56
+ self.multi_selections: dict[int, set[int]] = {}
57
+ self.other_texts: dict[int, str] = {}
58
+
59
+ self.option_widgets: list[NoMarkupStatic] = []
60
+ self.title_widget: NoMarkupStatic | None = None
61
+ self.other_prefix: NoMarkupStatic | None = None
62
+ self.other_input: Input | None = None
63
+ self.other_static: NoMarkupStatic | None = None
64
+ self.submit_widget: NoMarkupStatic | None = None
65
+ self.help_widget: NoMarkupStatic | None = None
66
+ self.tabs_widget: NoMarkupStatic | None = None
67
+
68
+ @property
69
+ def _current_question(self) -> Question:
70
+ return self.questions[self.current_question_idx]
71
+
72
+ @property
73
+ def _has_other(self) -> bool:
74
+ return not self._current_question.hide_other
75
+
76
+ @property
77
+ def _total_options(self) -> int:
78
+ base = len(self._current_question.options)
79
+ if self._has_other:
80
+ base += 1
81
+ if self._current_question.multi_select:
82
+ base += 1
83
+ return base
84
+
85
+ @property
86
+ def _other_option_idx(self) -> int:
87
+ if not self._has_other:
88
+ return -1
89
+ return len(self._current_question.options)
90
+
91
+ @property
92
+ def _submit_option_idx(self) -> int:
93
+ if not self._current_question.multi_select:
94
+ return -1
95
+ if self._has_other:
96
+ return self._other_option_idx + 1
97
+ return len(self._current_question.options)
98
+
99
+ @property
100
+ def _is_other_selected(self) -> bool:
101
+ return self._has_other and self.selected_option == self._other_option_idx
102
+
103
+ @property
104
+ def _is_submit_selected(self) -> bool:
105
+ return (
106
+ self._current_question.multi_select
107
+ and self.selected_option == self._submit_option_idx
108
+ )
109
+
110
+ def compose(self) -> ComposeResult:
111
+ with Vertical(id="question-content"):
112
+ if len(self.questions) > 1:
113
+ self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
114
+ yield self.tabs_widget
115
+
116
+ self.title_widget = NoMarkupStatic("", classes="question-title")
117
+ yield self.title_widget
118
+
119
+ for _ in range(self.MAX_OPTIONS):
120
+ widget = NoMarkupStatic("", classes="question-option")
121
+ self.option_widgets.append(widget)
122
+ yield widget
123
+
124
+ with Horizontal(classes="question-other-row"):
125
+ self.other_prefix = NoMarkupStatic("", classes="question-other-prefix")
126
+ yield self.other_prefix
127
+ self.other_input = Input(
128
+ placeholder="Type your answer...", classes="question-other-input"
129
+ )
130
+ yield self.other_input
131
+ self.other_static = NoMarkupStatic(
132
+ "Type your answer...", classes="question-other-static"
133
+ )
134
+ yield self.other_static
135
+
136
+ self.submit_widget = NoMarkupStatic("", classes="question-submit")
137
+ yield self.submit_widget
138
+
139
+ self.help_widget = NoMarkupStatic("", classes="question-help")
140
+ yield self.help_widget
141
+
142
+ async def on_mount(self) -> None:
143
+ self._update_display()
144
+ self.focus()
145
+
146
+ def _watch_current_question_idx(self) -> None:
147
+ self._update_display()
148
+
149
+ def _watch_selected_option(self) -> None:
150
+ self._update_display()
151
+
152
+ def _update_display(self) -> None:
153
+ self._update_tabs()
154
+ self._update_title()
155
+ self._update_options()
156
+ self._update_other_row()
157
+ self._update_submit()
158
+ self._update_help()
159
+
160
+ def _update_tabs(self) -> None:
161
+ if not self.tabs_widget or len(self.questions) <= 1:
162
+ return
163
+ tabs = []
164
+ for i, question in enumerate(self.questions):
165
+ header = question.header or f"Q{i + 1}"
166
+ if i in self.answers:
167
+ header += " ✓"
168
+ if i == self.current_question_idx:
169
+ tabs.append(f"[{header}]")
170
+ else:
171
+ tabs.append(f" {header} ")
172
+ self.tabs_widget.update(" ".join(tabs))
173
+
174
+ def _update_title(self) -> None:
175
+ if self.title_widget:
176
+ self.title_widget.update(self._current_question.question)
177
+
178
+ def _update_options(self) -> None:
179
+ q = self._current_question
180
+ options = q.options
181
+ is_multi = q.multi_select
182
+ multi_selected = self.multi_selections.get(self.current_question_idx, set())
183
+
184
+ for i, widget in enumerate(self.option_widgets):
185
+ if i < len(options):
186
+ is_focused = i == self.selected_option
187
+ is_selected = i in multi_selected
188
+ self._render_option(
189
+ widget, i, options[i], is_multi, is_focused, is_selected
190
+ )
191
+ else:
192
+ widget.update("")
193
+ widget.display = False
194
+
195
+ def _format_option_prefix(
196
+ self, idx: int, is_focused: bool, is_multi: bool, is_selected: bool
197
+ ) -> str:
198
+ """Format the prefix for an option line (cursor + number + checkbox if multi)."""
199
+ cursor = "› " if is_focused else " "
200
+ if is_multi:
201
+ check = "[x]" if is_selected else "[ ]"
202
+ return f"{cursor}{idx + 1}. {check} "
203
+ return f"{cursor}{idx + 1}. "
204
+
205
+ def _render_option(
206
+ self,
207
+ widget: NoMarkupStatic,
208
+ idx: int,
209
+ opt: Choice,
210
+ is_multi: bool,
211
+ is_focused: bool,
212
+ is_selected: bool,
213
+ ) -> None:
214
+ prefix = self._format_option_prefix(idx, is_focused, is_multi, is_selected)
215
+ text = f"{prefix}{opt.label}"
216
+
217
+ if opt.description:
218
+ text += f" - {opt.description}"
219
+
220
+ widget.update(text)
221
+ widget.display = True
222
+ widget.remove_class("question-option-selected")
223
+ if is_focused:
224
+ widget.add_class("question-option-selected")
225
+
226
+ def _update_other_row(self) -> None:
227
+ if not self.other_prefix or not self.other_input or not self.other_static:
228
+ return
229
+
230
+ if not self._has_other:
231
+ self.other_prefix.display = False
232
+ self.other_input.display = False
233
+ self.other_static.display = False
234
+ return
235
+
236
+ q = self._current_question
237
+ is_multi = q.multi_select
238
+ multi_selected = self.multi_selections.get(self.current_question_idx, set())
239
+ other_idx = self._other_option_idx
240
+ is_focused = self._is_other_selected
241
+ is_selected = other_idx in multi_selected
242
+
243
+ prefix = self._format_option_prefix(
244
+ other_idx, is_focused, is_multi, is_selected
245
+ )
246
+ self.other_prefix.update(prefix)
247
+
248
+ stored_text = self.other_texts.get(self.current_question_idx, "")
249
+ if self.other_input.value != stored_text:
250
+ self.other_input.value = stored_text
251
+
252
+ show_input = is_focused or bool(stored_text)
253
+
254
+ self.other_prefix.display = True
255
+ self.other_input.display = show_input
256
+ self.other_static.display = not show_input
257
+
258
+ self.other_prefix.remove_class("question-option-selected")
259
+ if is_focused:
260
+ self.other_prefix.add_class("question-option-selected")
261
+
262
+ if is_focused and show_input:
263
+ self.other_input.focus()
264
+ elif not is_focused and not self._is_submit_selected:
265
+ self.focus()
266
+
267
+ def _update_submit(self) -> None:
268
+ if not self.submit_widget:
269
+ return
270
+
271
+ q = self._current_question
272
+ if not q.multi_select:
273
+ self.submit_widget.display = False
274
+ return
275
+
276
+ self.submit_widget.display = True
277
+ is_focused = self._is_submit_selected
278
+ cursor = "› " if is_focused else " "
279
+
280
+ text = (
281
+ "Submit"
282
+ if len(set(self.answers.keys()) | {self.current_question_idx})
283
+ == len(self.questions)
284
+ else "Next"
285
+ )
286
+ self.submit_widget.update(f"{cursor} {text} →")
287
+ self.submit_widget.remove_class("question-option-selected")
288
+ if is_focused:
289
+ self.submit_widget.add_class("question-option-selected")
290
+ self.focus()
291
+
292
+ def _update_help(self) -> None:
293
+ if not self.help_widget:
294
+ return
295
+ if self._current_question.multi_select:
296
+ help_text = "↑↓ navigate Enter toggle Esc cancel"
297
+ else:
298
+ help_text = "↑↓ navigate Enter select Esc cancel"
299
+ if len(self.questions) > 1:
300
+ help_text = "←→ questions " + help_text
301
+ self.help_widget.update(help_text)
302
+
303
+ def _store_other_text(self) -> None:
304
+ if self.other_input:
305
+ self.other_texts[self.current_question_idx] = self.other_input.value
306
+
307
+ def _get_other_text(self, idx: int) -> str:
308
+ return self.other_texts.get(idx, "")
309
+
310
+ def action_move_up(self) -> None:
311
+ self.selected_option = (self.selected_option - 1) % self._total_options
312
+
313
+ def action_move_down(self) -> None:
314
+ self.selected_option = (self.selected_option + 1) % self._total_options
315
+
316
+ def _switch_question(self, new_idx: int) -> None:
317
+ self.current_question_idx = new_idx
318
+ self.selected_option = 0
319
+
320
+ def action_next_question(self) -> None:
321
+ if self._is_other_selected:
322
+ other_text = self.other_texts.get(self.current_question_idx, "").strip()
323
+ if not other_text:
324
+ return
325
+ new_idx = (self.current_question_idx + 1) % len(self.questions)
326
+ self._switch_question(new_idx)
327
+
328
+ def action_prev_question(self) -> None:
329
+ new_idx = (self.current_question_idx - 1) % len(self.questions)
330
+ self._switch_question(new_idx)
331
+
332
+ def action_select(self) -> None:
333
+ if self._current_question.multi_select:
334
+ self._handle_multi_select_action()
335
+ else:
336
+ self._handle_single_select_action()
337
+
338
+ def _handle_multi_select_action(self) -> None:
339
+ """Handle Enter key in multi-select mode: toggle option or submit."""
340
+ if self._is_submit_selected:
341
+ self._save_current_answer()
342
+ self._advance_or_submit()
343
+ elif self._is_other_selected:
344
+ if self.other_input:
345
+ self.other_input.focus()
346
+ else:
347
+ self._toggle_selection(self.selected_option)
348
+
349
+ def _handle_single_select_action(self) -> None:
350
+ """Handle Enter key in single-select mode: select and advance."""
351
+ if self._is_other_selected:
352
+ if self.other_input:
353
+ other_text = self.other_texts.get(self.current_question_idx, "").strip()
354
+ if other_text:
355
+ self._save_current_answer()
356
+ self._advance_or_submit()
357
+ else:
358
+ self.other_input.focus()
359
+ else:
360
+ self._save_current_answer()
361
+ self._advance_or_submit()
362
+
363
+ def _toggle_selection(self, option_idx: int) -> None:
364
+ """Toggle an option's selection state (multi-select only)."""
365
+ selections = self.multi_selections.setdefault(self.current_question_idx, set())
366
+ if option_idx in selections:
367
+ selections.discard(option_idx)
368
+ else:
369
+ selections.add(option_idx)
370
+ self._update_display()
371
+
372
+ def _advance_or_submit(self) -> None:
373
+ if self._all_answered():
374
+ self._submit()
375
+ else:
376
+ new_idx = next(
377
+ i
378
+ for i in itertools.chain(
379
+ range(self.current_question_idx + 1, len(self.questions)),
380
+ range(self.current_question_idx),
381
+ )
382
+ if i not in self.answers
383
+ )
384
+ self._switch_question(new_idx)
385
+
386
+ def action_cancel(self) -> None:
387
+ self.post_message(self.Cancelled())
388
+
389
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
390
+ if not self.other_input or not self.other_input.value.strip():
391
+ return
392
+
393
+ q = self._current_question
394
+ if q.multi_select:
395
+ self.selected_option = self._submit_option_idx
396
+ else:
397
+ self._save_current_answer()
398
+ self._advance_or_submit()
399
+
400
+ def on_input_changed(self, _event: Input.Changed) -> None:
401
+ self._store_other_text()
402
+ self._sync_other_selection_with_text()
403
+ self._update_display()
404
+
405
+ def _sync_other_selection_with_text(self) -> None:
406
+ """Auto-select/deselect 'Other' option based on whether text is entered (multi-select only)."""
407
+ if not self._current_question.multi_select or not self.other_input:
408
+ return
409
+
410
+ other_idx = self._other_option_idx
411
+ selections = self.multi_selections.setdefault(self.current_question_idx, set())
412
+ has_text = bool(self.other_input.value.strip())
413
+
414
+ if has_text and other_idx not in selections:
415
+ selections.add(other_idx)
416
+ elif not has_text and other_idx in selections:
417
+ selections.discard(other_idx)
418
+
419
+ def on_key(self, event: events.Key) -> None:
420
+ if len(self.questions) <= 1:
421
+ return
422
+ if self.other_input and self.other_input.has_focus:
423
+ return
424
+ if event.key == "left":
425
+ self.action_prev_question()
426
+ event.stop()
427
+ elif event.key == "right":
428
+ self.action_next_question()
429
+ event.stop()
430
+
431
+ def _save_current_answer(self) -> None:
432
+ if self._current_question.multi_select:
433
+ self._save_multi_select_answer()
434
+ else:
435
+ self._save_single_select_answer()
436
+
437
+ def _save_multi_select_answer(self) -> None:
438
+ """Save answer for multi-select question (combines all selected options)."""
439
+ q = self._current_question
440
+ idx = self.current_question_idx
441
+ selections = self.multi_selections.get(idx, set())
442
+
443
+ if not selections:
444
+ return
445
+
446
+ other_text = self.other_texts.get(idx, "").strip()
447
+ answers = []
448
+ has_other = False
449
+ other_idx = len(q.options)
450
+
451
+ for sel_idx in sorted(selections):
452
+ if sel_idx < len(q.options):
453
+ answers.append(q.options[sel_idx].label)
454
+ elif sel_idx == other_idx and other_text:
455
+ answers.append(other_text)
456
+ has_other = True
457
+
458
+ if answers:
459
+ self.answers[idx] = (", ".join(answers), has_other)
460
+
461
+ def _save_single_select_answer(self) -> None:
462
+ """Save answer for single-select question."""
463
+ idx = self.current_question_idx
464
+
465
+ if self._is_other_selected:
466
+ other_text = self.other_texts.get(idx, "").strip()
467
+ if other_text:
468
+ self.answers[idx] = (other_text, True)
469
+ else:
470
+ self.answers[idx] = (
471
+ self._current_question.options[self.selected_option].label,
472
+ False,
473
+ )
474
+
475
+ def _all_answered(self) -> bool:
476
+ return all(i in self.answers for i in range(len(self.questions)))
477
+
478
+ def _submit(self) -> None:
479
+ result: list[Answer] = []
480
+ for i, q in enumerate(self.questions):
481
+ answer_text, is_other = self.answers.get(i, ("", False))
482
+ result.append(
483
+ Answer(question=q.question, answer=answer_text, is_other=is_other)
484
+ )
485
+ self.post_message(self.Answered(answers=result))
486
+
487
+ def on_blur(self, _event: events.Blur) -> None:
488
+ self.call_after_refresh(self._refocus_if_needed)
489
+
490
+ def on_input_blurred(self, _event: Input.Blurred) -> None:
491
+ self.call_after_refresh(self._refocus_if_needed)
492
+
493
+ def _refocus_if_needed(self) -> None:
494
+ if self.has_focus or (self.other_input and self.other_input.has_focus):
495
+ return
496
+ self.focus()
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from collections.abc import Callable
5
+ from enum import Enum, auto
6
+ import random
7
+ from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
8
+
9
+ from textual.timer import Timer
10
+
11
+ from vibe.cli.textual_ui.widgets.braille_renderer import render_braille
12
+
13
+ if TYPE_CHECKING:
14
+ from textual.widgets import Static
15
+
16
+
17
+ @runtime_checkable
18
+ class HasSetInterval(Protocol):
19
+ def set_interval(
20
+ self, interval: float, callback: Callable[[], None], *, name: str | None = None
21
+ ) -> Timer: ...
22
+
23
+
24
+ class Spinner(ABC):
25
+ FRAMES: ClassVar[tuple[str, ...]]
26
+
27
+ def __init__(self) -> None:
28
+ self._position = 0
29
+
30
+ def next_frame(self) -> str:
31
+ frame = self.FRAMES[self._position]
32
+ self._position = (self._position + 1) % len(self.FRAMES)
33
+ return frame
34
+
35
+ def current_frame(self) -> str:
36
+ return self.FRAMES[self._position]
37
+
38
+ def reset(self) -> None:
39
+ self._position = 0
40
+
41
+
42
+ class BrailleSpinner(Spinner):
43
+ FRAMES: ClassVar[tuple[str, ...]] = (
44
+ "⠋",
45
+ "⠙",
46
+ "⠹",
47
+ "⠸",
48
+ "⠼",
49
+ "⠴",
50
+ "⠦",
51
+ "⠧",
52
+ "⠇",
53
+ "⠏",
54
+ )
55
+
56
+
57
+ class PulseSpinner(Spinner):
58
+ FRAMES: ClassVar[tuple[str, ...]] = (
59
+ "■",
60
+ "■",
61
+ "■",
62
+ "■",
63
+ "■",
64
+ "■",
65
+ "□",
66
+ "□",
67
+ "□",
68
+ "□",
69
+ )
70
+
71
+
72
+ class SpinnerType(Enum):
73
+ BRAILLE = auto()
74
+ PULSE = auto()
75
+ SNAKE = auto()
76
+
77
+
78
+ class SnakeSpinner(Spinner):
79
+ MAP_WIDTH: ClassVar[int] = 4
80
+ MAP_HEIGHT: ClassVar[int] = 4
81
+ SNAKE_LENGTH: ClassVar[int] = 3
82
+
83
+ def __init__(self) -> None:
84
+ self._positions: list[complex] = [1, 0, 1j]
85
+ super().__init__()
86
+
87
+ @property
88
+ def current_direction(self) -> complex:
89
+ return self._positions[0] - self._positions[1]
90
+
91
+ def _is_in_bounds(self, position: complex) -> bool:
92
+ return (
93
+ 0 <= position.real < self.MAP_WIDTH and 0 <= position.imag < self.MAP_HEIGHT
94
+ )
95
+
96
+ def _get_direction(self) -> complex:
97
+ if (
98
+ len(set(z.real for z in self._positions)) > 1
99
+ and len(set(z.imag for z in self._positions)) > 1
100
+ and self._is_in_bounds(self._positions[0] + self.current_direction)
101
+ ):
102
+ return self.current_direction
103
+ valid_directions = []
104
+ for rotation in [1, 1j, -1j]:
105
+ offset = rotation * self.current_direction
106
+ new_position = self._positions[0] + offset
107
+ if self._is_in_bounds(new_position) and new_position not in self._positions:
108
+ valid_directions.append(offset)
109
+ return random.choice(valid_directions)
110
+
111
+ def _next_positions(self) -> list[complex]:
112
+ if len(self._positions) > self.SNAKE_LENGTH:
113
+ return self._positions[: self.SNAKE_LENGTH]
114
+ head_position = self._positions[0]
115
+ direction = self._get_direction()
116
+ if self.current_direction != direction:
117
+ return [head_position + direction] + self._positions
118
+ return [head_position + direction] + self._positions[:-1]
119
+
120
+ def current_frame(self) -> str:
121
+ return render_braille(self._positions, self.MAP_WIDTH, self.MAP_HEIGHT)
122
+
123
+ def next_frame(self) -> str:
124
+ self._positions = self._next_positions()
125
+ return self.current_frame()
126
+
127
+ def reset(self) -> None:
128
+ self._positions = [1, 0, 1j]
129
+
130
+
131
+ _SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
132
+ SpinnerType.BRAILLE: BrailleSpinner,
133
+ SpinnerType.PULSE: PulseSpinner,
134
+ SpinnerType.SNAKE: SnakeSpinner,
135
+ }
136
+
137
+
138
+ def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
139
+ spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
140
+ return spinner_class()
141
+
142
+
143
+ class SpinnerMixin:
144
+ SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
145
+ SPINNING_TEXT: ClassVar[str] = ""
146
+ COMPLETED_TEXT: ClassVar[str] = ""
147
+
148
+ _spinner: Spinner
149
+ _spinner_timer: Any
150
+ _is_spinning: bool
151
+ _indicator_widget: Static | None
152
+ _status_text_widget: Static | None
153
+
154
+ def init_spinner(self) -> None:
155
+ self._spinner = create_spinner(self.SPINNER_TYPE)
156
+ self._spinner_timer = None
157
+ self._is_spinning = True
158
+ self._status_text_widget = None
159
+
160
+ def start_spinner_timer(self) -> None:
161
+ if not isinstance(self, HasSetInterval):
162
+ raise TypeError(
163
+ "SpinnerMixin requires a class that implements HasSetInterval protocol"
164
+ )
165
+ self._spinner_timer = self.set_interval(0.1, self._update_spinner_frame)
166
+
167
+ def _update_spinner_frame(self) -> None:
168
+ if not self._is_spinning or not self._indicator_widget:
169
+ return
170
+ self._indicator_widget.update(self._spinner.next_frame())
171
+
172
+ def refresh_spinner(self) -> None:
173
+ if self._indicator_widget:
174
+ self._indicator_widget.refresh()
175
+
176
+ def stop_spinning(self, success: bool = True) -> None:
177
+ self._is_spinning = False
178
+ if self._spinner_timer:
179
+ self._spinner_timer.stop()
180
+ self._spinner_timer = None
181
+ if self._indicator_widget:
182
+ if success:
183
+ self._indicator_widget.update("✓")
184
+ self._indicator_widget.add_class("success")
185
+ else:
186
+ self._indicator_widget.update("✕")
187
+ self._indicator_widget.add_class("error")
188
+ if self._status_text_widget and self.COMPLETED_TEXT:
189
+ self._status_text_widget.update(self.COMPLETED_TEXT)
190
+
191
+ def on_unmount(self) -> None:
192
+ if self._spinner_timer:
193
+ self._spinner_timer.stop()
194
+ self._spinner_timer = None