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,365 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, ClassVar, Literal
4
+
5
+ from textual import events
6
+ from textual.binding import Binding
7
+ from textual.message import Message
8
+ from textual.widgets import TextArea
9
+
10
+ from vibe.cli.autocompletion.base import CompletionResult
11
+ from vibe.cli.textual_ui.external_editor import ExternalEditor
12
+ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
13
+ MultiCompletionManager,
14
+ )
15
+
16
+ InputMode = Literal["!", "/", ">", "&"]
17
+
18
+
19
+ class ChatTextArea(TextArea):
20
+ BINDINGS: ClassVar[list[Binding]] = [
21
+ Binding(
22
+ "shift+enter,ctrl+j",
23
+ "insert_newline",
24
+ "New Line",
25
+ show=False,
26
+ priority=True,
27
+ ),
28
+ Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
29
+ ]
30
+
31
+ DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
32
+
33
+ class Submitted(Message):
34
+ def __init__(self, value: str) -> None:
35
+ self.value = value
36
+ super().__init__()
37
+
38
+ class HistoryPrevious(Message):
39
+ def __init__(self, prefix: str) -> None:
40
+ self.prefix = prefix
41
+ super().__init__()
42
+
43
+ class HistoryNext(Message):
44
+ def __init__(self, prefix: str) -> None:
45
+ self.prefix = prefix
46
+ super().__init__()
47
+
48
+ class HistoryReset(Message):
49
+ """Message sent when history navigation should be reset."""
50
+
51
+ class ModeChanged(Message):
52
+ """Message sent when the input mode changes (>, !, /, &)."""
53
+
54
+ def __init__(self, mode: InputMode) -> None:
55
+ self.mode = mode
56
+ super().__init__()
57
+
58
+ def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
59
+ super().__init__(**kwargs)
60
+ self._nuage_enabled = nuage_enabled
61
+ self._input_mode: InputMode = self.DEFAULT_MODE
62
+ self._history_prefix: str | None = None
63
+ self._last_text = ""
64
+ self._navigating_history = False
65
+ self._last_cursor_col: int = 0
66
+ self._last_used_prefix: str | None = None
67
+ self._original_text: str = ""
68
+ self._cursor_pos_after_load: tuple[int, int] | None = None
69
+ self._cursor_moved_since_load: bool = False
70
+ self._completion_manager: MultiCompletionManager | None = None
71
+ self._app_has_focus: bool = True
72
+
73
+ def on_blur(self, event: events.Blur) -> None:
74
+ if self._app_has_focus:
75
+ self.call_after_refresh(self.focus)
76
+
77
+ def set_app_focus(self, has_focus: bool) -> None:
78
+ self._app_has_focus = has_focus
79
+ self.cursor_blink = has_focus
80
+ if has_focus and not self.has_focus:
81
+ self.call_after_refresh(self.focus)
82
+
83
+ def on_click(self, event: events.Click) -> None:
84
+ self._mark_cursor_moved_if_needed()
85
+
86
+ def action_insert_newline(self) -> None:
87
+ self.insert("\n")
88
+
89
+ def action_open_external_editor(self) -> None:
90
+ editor = ExternalEditor()
91
+ current_text = self.get_full_text()
92
+
93
+ with self.app.suspend():
94
+ result = editor.edit(current_text)
95
+
96
+ if result is not None:
97
+ self.clear()
98
+ self.insert(result)
99
+
100
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
101
+ if not self._navigating_history and self.text != self._last_text:
102
+ self._reset_prefix()
103
+ self._original_text = ""
104
+ self._cursor_pos_after_load = None
105
+ self._cursor_moved_since_load = False
106
+ self.post_message(self.HistoryReset())
107
+ self._last_text = self.text
108
+ was_navigating_history = self._navigating_history
109
+ self._navigating_history = False
110
+
111
+ if self._completion_manager and not was_navigating_history:
112
+ self._completion_manager.on_text_changed(
113
+ self.get_full_text(), self._get_full_cursor_offset()
114
+ )
115
+
116
+ def _reset_prefix(self) -> None:
117
+ self._history_prefix = None
118
+ self._last_used_prefix = None
119
+
120
+ def _mark_cursor_moved_if_needed(self) -> None:
121
+ if (
122
+ self._cursor_pos_after_load is not None
123
+ and not self._cursor_moved_since_load
124
+ and self.cursor_location != self._cursor_pos_after_load
125
+ ):
126
+ self._cursor_moved_since_load = True
127
+ self._reset_prefix()
128
+
129
+ def _get_prefix_up_to_cursor(self) -> str:
130
+ cursor_row, cursor_col = self.cursor_location
131
+ lines = self.text.split("\n")
132
+ if cursor_row < len(lines):
133
+ visible_prefix = lines[cursor_row][:cursor_col]
134
+ if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
135
+ return self._input_mode + visible_prefix
136
+ return visible_prefix
137
+ return ""
138
+
139
+ def _handle_history_up(self) -> bool:
140
+ cursor_row, cursor_col = self.cursor_location
141
+ if cursor_row == 0:
142
+ if self._history_prefix is not None and cursor_col != self._last_cursor_col:
143
+ self._reset_prefix()
144
+ self._last_cursor_col = 0
145
+
146
+ if self._history_prefix is None:
147
+ self._history_prefix = self._get_prefix_up_to_cursor()
148
+
149
+ self._navigating_history = True
150
+ self.post_message(self.HistoryPrevious(self._history_prefix))
151
+ return True
152
+ return False
153
+
154
+ def _handle_history_down(self) -> bool:
155
+ cursor_row, cursor_col = self.cursor_location
156
+ total_lines = self.text.count("\n") + 1
157
+
158
+ on_first_line_unmoved = cursor_row == 0 and not self._cursor_moved_since_load
159
+ on_last_line = cursor_row == total_lines - 1
160
+
161
+ should_intercept = (
162
+ on_first_line_unmoved and self._history_prefix is not None
163
+ ) or on_last_line
164
+
165
+ if not should_intercept:
166
+ return False
167
+
168
+ if self._history_prefix is not None and cursor_col != self._last_cursor_col:
169
+ self._reset_prefix()
170
+ self._last_cursor_col = 0
171
+
172
+ if self._history_prefix is None:
173
+ self._history_prefix = self._get_prefix_up_to_cursor()
174
+
175
+ self._navigating_history = True
176
+ self.post_message(self.HistoryNext(self._history_prefix))
177
+ return True
178
+
179
+ async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
180
+ self._mark_cursor_moved_if_needed()
181
+
182
+ manager = self._completion_manager
183
+ if manager:
184
+ match manager.on_key(
185
+ event, self.get_full_text(), self._get_full_cursor_offset()
186
+ ):
187
+ case CompletionResult.HANDLED:
188
+ event.prevent_default()
189
+ event.stop()
190
+ return
191
+ case CompletionResult.SUBMIT:
192
+ event.prevent_default()
193
+ event.stop()
194
+ value = self.get_full_text().strip()
195
+ if value:
196
+ self._reset_prefix()
197
+ self.post_message(self.Submitted(value))
198
+ return
199
+
200
+ if event.key == "enter":
201
+ event.prevent_default()
202
+ event.stop()
203
+ value = self.get_full_text().strip()
204
+ if value:
205
+ self._reset_prefix()
206
+ self.post_message(self.Submitted(value))
207
+ return
208
+
209
+ if event.key == "shift+enter":
210
+ event.prevent_default()
211
+ event.stop()
212
+ return
213
+
214
+ if (
215
+ event.character
216
+ and event.character in self.mode_characters
217
+ and not self.text
218
+ and self._input_mode == self.DEFAULT_MODE
219
+ ):
220
+ self._set_mode(event.character)
221
+ event.prevent_default()
222
+ event.stop()
223
+ return
224
+
225
+ if event.key == "backspace" and self._should_reset_mode_on_backspace():
226
+ self._set_mode(self.DEFAULT_MODE)
227
+ event.prevent_default()
228
+ event.stop()
229
+ return
230
+
231
+ if event.key == "up" and self._handle_history_up():
232
+ event.prevent_default()
233
+ event.stop()
234
+ return
235
+
236
+ if event.key == "down" and self._handle_history_down():
237
+ event.prevent_default()
238
+ event.stop()
239
+ return
240
+
241
+ await super()._on_key(event)
242
+ self._mark_cursor_moved_if_needed()
243
+
244
+ def set_completion_manager(self, manager: MultiCompletionManager | None) -> None:
245
+ self._completion_manager = manager
246
+ if self._completion_manager:
247
+ self._completion_manager.on_text_changed(
248
+ self.get_full_text(), self._get_full_cursor_offset()
249
+ )
250
+
251
+ def get_cursor_offset(self) -> int:
252
+ text = self.text
253
+ row, col = self.cursor_location
254
+
255
+ if not text:
256
+ return 0
257
+
258
+ lines = text.split("\n")
259
+ row = max(0, min(row, len(lines) - 1))
260
+ col = max(0, col)
261
+
262
+ offset = sum(len(lines[i]) + 1 for i in range(row))
263
+ return offset + min(col, len(lines[row]))
264
+
265
+ def set_cursor_offset(self, offset: int) -> None:
266
+ text = self.text
267
+ if offset <= 0:
268
+ self.move_cursor((0, 0))
269
+ return
270
+
271
+ if offset >= len(text):
272
+ lines = text.split("\n")
273
+ if not lines:
274
+ self.move_cursor((0, 0))
275
+ return
276
+ last_row = len(lines) - 1
277
+ self.move_cursor((last_row, len(lines[last_row])))
278
+ return
279
+
280
+ remaining = offset
281
+ lines = text.split("\n")
282
+
283
+ for row, line in enumerate(lines):
284
+ line_length = len(line)
285
+ if remaining <= line_length:
286
+ self.move_cursor((row, remaining))
287
+ return
288
+ remaining -= line_length + 1
289
+
290
+ last_row = len(lines) - 1
291
+ self.move_cursor((last_row, len(lines[last_row])))
292
+
293
+ def reset_history_state(self) -> None:
294
+ self._reset_prefix()
295
+ self._original_text = ""
296
+ self._cursor_pos_after_load = None
297
+ self._cursor_moved_since_load = False
298
+ self._last_text = self.text
299
+
300
+ def clear_text(self) -> None:
301
+ self.clear()
302
+ self.reset_history_state()
303
+ self._set_mode(self.DEFAULT_MODE)
304
+
305
+ def _set_mode(self, mode: InputMode) -> None:
306
+ if self._input_mode == mode:
307
+ return
308
+ self._input_mode = mode
309
+ self.post_message(self.ModeChanged(mode))
310
+ if self._completion_manager:
311
+ self._completion_manager.on_text_changed(
312
+ self.get_full_text(), self._get_full_cursor_offset()
313
+ )
314
+
315
+ def _should_reset_mode_on_backspace(self) -> bool:
316
+ return (
317
+ self._input_mode != self.DEFAULT_MODE
318
+ and not self.text
319
+ and self.get_cursor_offset() == 0
320
+ )
321
+
322
+ def get_full_text(self) -> str:
323
+ if self._input_mode != self.DEFAULT_MODE:
324
+ return self._input_mode + self.text
325
+ return self.text
326
+
327
+ def _get_full_cursor_offset(self) -> int:
328
+ return self.get_cursor_offset() + self._get_mode_prefix_length()
329
+
330
+ def _get_mode_prefix_length(self) -> int:
331
+ return {">": 0, "/": 1, "!": 1, "&": 1}[self._input_mode]
332
+
333
+ @property
334
+ def mode_characters(self) -> set[InputMode]:
335
+ chars: set[InputMode] = {"!", "/"}
336
+ if self._nuage_enabled:
337
+ chars.add("&")
338
+ return chars
339
+
340
+ @property
341
+ def input_mode(self) -> InputMode:
342
+ return self._input_mode
343
+
344
+ def set_mode(self, mode: InputMode) -> None:
345
+ if self._input_mode != mode:
346
+ self._input_mode = mode
347
+ self.post_message(self.ModeChanged(mode))
348
+
349
+ def adjust_from_full_text_coords(
350
+ self, start: int, end: int, replacement: str
351
+ ) -> tuple[int, int, str]:
352
+ """Translate from full-text coordinates to widget coordinates.
353
+
354
+ The completion manager works with 'full text' that includes the mode prefix.
355
+ This adjusts coordinates and replacement text for the actual widget text.
356
+ """
357
+ mode_len = self._get_mode_prefix_length()
358
+
359
+ adj_start = max(0, start - mode_len)
360
+ adj_end = max(adj_start, end - mode_len)
361
+
362
+ if mode_len > 0 and replacement.startswith(self._input_mode):
363
+ replacement = replacement[mode_len:]
364
+
365
+ return adj_start, adj_end, replacement
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.message import Message
4
+
5
+ from vibe.cli.textual_ui.widgets.status_message import StatusMessage
6
+ from vibe.core.utils import compact_reduction_display
7
+
8
+
9
+ class CompactMessage(StatusMessage):
10
+ class Completed(Message):
11
+ def __init__(self, compact_widget: CompactMessage) -> None:
12
+ super().__init__()
13
+ self.compact_widget = compact_widget
14
+
15
+ def __init__(self) -> None:
16
+ super().__init__()
17
+ self.add_class("compact-message")
18
+ self.old_tokens: int | None = None
19
+ self.new_tokens: int | None = None
20
+ self.error_message: str | None = None
21
+
22
+ def get_content(self) -> str:
23
+ if self._is_spinning:
24
+ return "Compacting conversation history..."
25
+
26
+ if self.error_message:
27
+ return f"Error: {self.error_message}"
28
+
29
+ return compact_reduction_display(self.old_tokens, self.new_tokens)
30
+
31
+ def set_complete(
32
+ self, old_tokens: int | None = None, new_tokens: int | None = None
33
+ ) -> None:
34
+ self.old_tokens = old_tokens
35
+ self.new_tokens = new_tokens
36
+ self.stop_spinning(success=True)
37
+ self.post_message(self.Completed(self))
38
+
39
+ def set_error(self, error_message: str) -> None:
40
+ self.error_message = error_message
41
+ self.stop_spinning(success=False)
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, TypedDict
4
+
5
+ from textual import events
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding, BindingType
8
+ from textual.containers import Container, Vertical
9
+ from textual.message import Message
10
+ from textual.widgets import Static
11
+
12
+ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
13
+
14
+ if TYPE_CHECKING:
15
+ from vibe.core.config import VibeConfig
16
+
17
+
18
+ class SettingDefinition(TypedDict):
19
+ key: str
20
+ label: str
21
+ type: str
22
+ options: list[str]
23
+
24
+
25
+ class ConfigApp(Container):
26
+ can_focus = True
27
+ can_focus_children = False
28
+
29
+ BINDINGS: ClassVar[list[BindingType]] = [
30
+ Binding("up", "move_up", "Up", show=False),
31
+ Binding("down", "move_down", "Down", show=False),
32
+ Binding("space", "toggle_setting", "Toggle", show=False),
33
+ Binding("enter", "cycle", "Next", show=False),
34
+ ]
35
+
36
+ class SettingChanged(Message):
37
+ def __init__(self, key: str, value: str) -> None:
38
+ super().__init__()
39
+ self.key = key
40
+ self.value = value
41
+
42
+ class ConfigClosed(Message):
43
+ def __init__(self, changes: dict[str, str | bool]) -> None:
44
+ super().__init__()
45
+ self.changes = changes
46
+
47
+ def __init__(self, config: VibeConfig) -> None:
48
+ super().__init__(id="config-app")
49
+ self.config = config
50
+ self.selected_index = 0
51
+ self.changes: dict[str, str] = {}
52
+
53
+ self.settings: list[SettingDefinition] = [
54
+ {
55
+ "key": "active_model",
56
+ "label": "Model",
57
+ "type": "cycle",
58
+ "options": [m.alias for m in self.config.models],
59
+ },
60
+ {
61
+ "key": "autocopy_to_clipboard",
62
+ "label": "Auto-copy",
63
+ "type": "cycle",
64
+ "options": ["On", "Off"],
65
+ },
66
+ ]
67
+
68
+ self.title_widget: Static | None = None
69
+ self.setting_widgets: list[Static] = []
70
+ self.help_widget: Static | None = None
71
+
72
+ def compose(self) -> ComposeResult:
73
+ with Vertical(id="config-content"):
74
+ self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
75
+ yield self.title_widget
76
+
77
+ yield NoMarkupStatic("")
78
+
79
+ for _ in self.settings:
80
+ widget = NoMarkupStatic("", classes="settings-option")
81
+ self.setting_widgets.append(widget)
82
+ yield widget
83
+
84
+ yield NoMarkupStatic("")
85
+
86
+ self.help_widget = NoMarkupStatic(
87
+ "↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
88
+ )
89
+ yield self.help_widget
90
+
91
+ def on_mount(self) -> None:
92
+ self._update_display()
93
+ self.focus()
94
+
95
+ def _get_display_value(self, setting: SettingDefinition) -> str:
96
+ key = setting["key"]
97
+ if key in self.changes:
98
+ return self.changes[key]
99
+ raw_value = getattr(self.config, key, "")
100
+ if isinstance(raw_value, bool):
101
+ return "On" if raw_value else "Off"
102
+ return str(raw_value)
103
+
104
+ def _update_display(self) -> None:
105
+ for i, (setting, widget) in enumerate(
106
+ zip(self.settings, self.setting_widgets, strict=True)
107
+ ):
108
+ is_selected = i == self.selected_index
109
+ cursor = "› " if is_selected else " "
110
+
111
+ label: str = setting["label"]
112
+ value: str = self._get_display_value(setting)
113
+
114
+ text = f"{cursor}{label}: {value}"
115
+
116
+ widget.update(text)
117
+
118
+ widget.remove_class("settings-cursor-selected")
119
+ widget.remove_class("settings-value-cycle-selected")
120
+ widget.remove_class("settings-value-cycle-unselected")
121
+
122
+ if is_selected:
123
+ widget.add_class("settings-value-cycle-selected")
124
+ else:
125
+ widget.add_class("settings-value-cycle-unselected")
126
+
127
+ def action_move_up(self) -> None:
128
+ self.selected_index = (self.selected_index - 1) % len(self.settings)
129
+ self._update_display()
130
+
131
+ def action_move_down(self) -> None:
132
+ self.selected_index = (self.selected_index + 1) % len(self.settings)
133
+ self._update_display()
134
+
135
+ def action_toggle_setting(self) -> None:
136
+ setting = self.settings[self.selected_index]
137
+ key: str = setting["key"]
138
+ current: str = self._get_display_value(setting)
139
+
140
+ options: list[str] = setting["options"]
141
+ new_value = ""
142
+ try:
143
+ current_idx = options.index(current)
144
+ next_idx = (current_idx + 1) % len(options)
145
+ new_value = options[next_idx]
146
+ except (ValueError, IndexError):
147
+ new_value = options[0] if options else current
148
+
149
+ self.changes[key] = new_value
150
+
151
+ self.post_message(self.SettingChanged(key=key, value=new_value))
152
+
153
+ self._update_display()
154
+
155
+ def action_cycle(self) -> None:
156
+ self.action_toggle_setting()
157
+
158
+ def _convert_changes_for_save(self) -> dict[str, str | bool]:
159
+ result: dict[str, str | bool] = {}
160
+ for key, value in self.changes.items():
161
+ if value in {"On", "Off"}:
162
+ result[key] = value == "On"
163
+ else:
164
+ result[key] = value
165
+ return result
166
+
167
+ def action_close(self) -> None:
168
+ self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
169
+
170
+ def on_blur(self, event: events.Blur) -> None:
171
+ self.call_after_refresh(self.focus)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from textual.reactive import reactive
7
+
8
+ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
9
+
10
+
11
+ @dataclass
12
+ class TokenState:
13
+ max_tokens: int = 0
14
+ current_tokens: int = 0
15
+
16
+
17
+ class ContextProgress(NoMarkupStatic):
18
+ tokens = reactive(TokenState())
19
+
20
+ def __init__(self, **kwargs: Any) -> None:
21
+ super().__init__(**kwargs)
22
+
23
+ def watch_tokens(self, new_state: TokenState) -> None:
24
+ if new_state.max_tokens == 0:
25
+ self.update("")
26
+ return
27
+
28
+ ratio = min(1, new_state.current_tokens / new_state.max_tokens)
29
+ text = f"{ratio:.0%} of {new_state.max_tokens // 1000}k tokens"
30
+ self.update(text)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal
5
+ from textual.message import Message
6
+ from textual.widgets import Button, Static
7
+
8
+
9
+ class HistoryLoadMoreRequested(Message):
10
+ pass
11
+
12
+
13
+ class HistoryLoadMoreMessage(Static):
14
+ def __init__(self) -> None:
15
+ super().__init__()
16
+ self.add_class("history-load-more-message")
17
+ self._label_widget: Button | None = None
18
+ self._remaining: int | None = None
19
+
20
+ def compose(self) -> ComposeResult:
21
+ with Horizontal(classes="history-load-more-container"):
22
+ self._label_widget = Button(
23
+ self._label_text(), classes="history-load-more-button"
24
+ )
25
+ yield self._label_widget
26
+
27
+ def _label_text(self) -> str:
28
+ if self._remaining is None:
29
+ return "Load more messages"
30
+ return f"Load more messages ({self._remaining})"
31
+
32
+ def set_enabled(self, enabled: bool) -> None:
33
+ if self._label_widget:
34
+ self._label_widget.disabled = not enabled
35
+
36
+ def set_remaining(self, remaining: int | None) -> None:
37
+ self._remaining = remaining
38
+ if self._label_widget:
39
+ self._label_widget.label = self._label_text()
40
+
41
+ def on_button_pressed(self, event: Button.Pressed) -> None:
42
+ event.stop()
43
+ self.post_message(HistoryLoadMoreRequested())