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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- 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
|