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,1546 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from enum import StrEnum, auto
5
+ from pathlib import Path
6
+ import subprocess
7
+ import time
8
+ from typing import Any, ClassVar, assert_never, cast
9
+ from weakref import WeakKeyDictionary
10
+
11
+ from pydantic import BaseModel
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding, BindingType
14
+ from textual.containers import Horizontal, VerticalGroup, VerticalScroll
15
+ from textual.events import AppBlur, AppFocus, MouseUp
16
+ from textual.widget import Widget
17
+ from textual.widgets import Static
18
+
19
+ from vibe import __version__ as CORE_VERSION
20
+ from vibe.cli.clipboard import copy_selection_to_clipboard
21
+ from vibe.cli.commands import CommandRegistry
22
+ from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
23
+ from vibe.cli.plan_offer.decide_plan_offer import (
24
+ PlanType,
25
+ decide_plan_offer,
26
+ plan_offer_cta,
27
+ resolve_api_key_for_plan,
28
+ )
29
+ from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
30
+ from vibe.cli.terminal_setup import setup_terminal
31
+ from vibe.cli.textual_ui.handlers.event_handler import EventHandler
32
+ from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
33
+ from vibe.cli.textual_ui.widgets.banner.banner import Banner
34
+ from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
35
+ from vibe.cli.textual_ui.widgets.compact import CompactMessage
36
+ from vibe.cli.textual_ui.widgets.config_app import ConfigApp
37
+ from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
38
+ from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
39
+ from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
40
+ from vibe.cli.textual_ui.widgets.messages import (
41
+ AssistantMessage,
42
+ BashOutputMessage,
43
+ ErrorMessage,
44
+ InterruptMessage,
45
+ ReasoningMessage,
46
+ StreamingMessageBase,
47
+ UserCommandMessage,
48
+ UserMessage,
49
+ WarningMessage,
50
+ WhatsNewMessage,
51
+ )
52
+ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
53
+ from vibe.cli.textual_ui.widgets.path_display import PathDisplay
54
+ from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
55
+ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
56
+ from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
57
+ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
58
+ from vibe.cli.textual_ui.windowing import (
59
+ HISTORY_RESUME_TAIL_MESSAGES,
60
+ LOAD_MORE_BATCH_SIZE,
61
+ HistoryLoadMoreManager,
62
+ SessionWindowing,
63
+ build_history_widgets,
64
+ create_resume_plan,
65
+ non_system_history_messages,
66
+ should_resume_history,
67
+ sync_backfill_state,
68
+ )
69
+ from vibe.cli.update_notifier import (
70
+ FileSystemUpdateCacheRepository,
71
+ PyPIUpdateGateway,
72
+ UpdateCacheRepository,
73
+ UpdateError,
74
+ UpdateGateway,
75
+ get_update_if_available,
76
+ load_whats_new_content,
77
+ mark_version_as_seen,
78
+ should_show_whats_new,
79
+ )
80
+ from vibe.cli.update_notifier.update import do_update
81
+ from vibe.core.agent_loop import AgentLoop, TeleportError
82
+ from vibe.core.agents import AgentProfile
83
+ from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
84
+ from vibe.core.config import VibeConfig
85
+ from vibe.core.paths.config_paths import HISTORY_FILE
86
+ from vibe.core.session.session_loader import SessionLoader
87
+ from vibe.core.teleport.types import (
88
+ TeleportAuthCompleteEvent,
89
+ TeleportAuthRequiredEvent,
90
+ TeleportCheckingGitEvent,
91
+ TeleportCompleteEvent,
92
+ TeleportPushingEvent,
93
+ TeleportPushRequiredEvent,
94
+ TeleportPushResponseEvent,
95
+ TeleportSendingGithubTokenEvent,
96
+ TeleportStartingWorkflowEvent,
97
+ )
98
+ from vibe.core.tools.base import ToolPermission
99
+ from vibe.core.tools.builtins.ask_user_question import (
100
+ AskUserQuestionArgs,
101
+ AskUserQuestionResult,
102
+ Choice,
103
+ Question,
104
+ )
105
+ from vibe.core.types import (
106
+ AgentStats,
107
+ ApprovalResponse,
108
+ LLMMessage,
109
+ RateLimitError,
110
+ Role,
111
+ )
112
+ from vibe.core.utils import (
113
+ CancellationReason,
114
+ get_user_cancellation_message,
115
+ is_dangerous_directory,
116
+ logger,
117
+ )
118
+
119
+
120
+ class BottomApp(StrEnum):
121
+ """Bottom panel app types.
122
+
123
+ Convention: Each value must match the widget class name with "App" suffix removed.
124
+ E.g., ApprovalApp -> Approval, ConfigApp -> Config, QuestionApp -> Question.
125
+ This allows dynamic lookup via: BottomApp[type(widget).__name__.removesuffix("App")]
126
+ """
127
+
128
+ Approval = auto()
129
+ Config = auto()
130
+ Input = auto()
131
+ ProxySetup = auto()
132
+ Question = auto()
133
+
134
+
135
+ class ChatScroll(VerticalScroll):
136
+ """Optimized scroll container that skips cascading style recalculations."""
137
+
138
+ def update_node_styles(self, animate: bool = True) -> None:
139
+ pass
140
+
141
+
142
+ PRUNE_LOW_MARK = 1000
143
+ PRUNE_HIGH_MARK = 1500
144
+
145
+
146
+ async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
147
+ """Remove older children to keep virtual height within bounds.
148
+ Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
149
+ """
150
+ height = messages_area.virtual_size.height
151
+ if height <= high_mark:
152
+ return False
153
+ prune_children: list[Widget] = []
154
+ bottom_margin = 0
155
+ prune_height = 0
156
+ for child in messages_area.children:
157
+ if not child.display:
158
+ prune_children.append(child)
159
+ continue
160
+ top, _, bottom, _ = child.styles.margin
161
+ child_height = child.outer_size.height
162
+ prune_height = (
163
+ (prune_height - bottom_margin + max(bottom_margin, top))
164
+ + bottom
165
+ + child_height
166
+ )
167
+ bottom_margin = bottom
168
+ if height - prune_height <= low_mark:
169
+ break
170
+ prune_children.append(child)
171
+ if prune_children:
172
+ await messages_area.remove_children(prune_children)
173
+ return bool(prune_children)
174
+
175
+
176
+ class VibeApp(App): # noqa: PLR0904
177
+ ENABLE_COMMAND_PALETTE = False
178
+ CSS_PATH = "app.tcss"
179
+
180
+ BINDINGS: ClassVar[list[BindingType]] = [
181
+ Binding("ctrl+c", "clear_quit", "Quit", show=False),
182
+ Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
183
+ Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
184
+ Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
185
+ Binding("ctrl+y", "copy_selection", "Copy", show=False, priority=True),
186
+ Binding("ctrl+shift+c", "copy_selection", "Copy", show=False, priority=True),
187
+ Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
188
+ Binding("shift+up", "scroll_chat_up", "Scroll Up", show=False, priority=True),
189
+ Binding(
190
+ "shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
191
+ ),
192
+ ]
193
+
194
+ def __init__(
195
+ self,
196
+ agent_loop: AgentLoop,
197
+ initial_prompt: str | None = None,
198
+ teleport_on_start: bool = False,
199
+ update_notifier: UpdateGateway | None = None,
200
+ update_cache_repository: UpdateCacheRepository | None = None,
201
+ current_version: str = CORE_VERSION,
202
+ plan_offer_gateway: WhoAmIGateway | None = None,
203
+ **kwargs: Any,
204
+ ) -> None:
205
+ super().__init__(**kwargs)
206
+ self.agent_loop = agent_loop
207
+ self._agent_running = False
208
+ self._interrupt_requested = False
209
+ self._agent_task: asyncio.Task | None = None
210
+
211
+ self._loading_widget: LoadingWidget | None = None
212
+ self._pending_approval: asyncio.Future | None = None
213
+ self._pending_question: asyncio.Future | None = None
214
+
215
+ self.event_handler: EventHandler | None = None
216
+
217
+ excluded_commands = []
218
+ if not self.config.nuage_enabled:
219
+ excluded_commands.append("teleport")
220
+ self.commands = CommandRegistry(excluded_commands=excluded_commands)
221
+
222
+ self._chat_input_container: ChatInputContainer | None = None
223
+ self._current_bottom_app: BottomApp = BottomApp.Input
224
+
225
+ self.history_file = HISTORY_FILE.path
226
+
227
+ self._tools_collapsed = True
228
+ self._current_streaming_message: AssistantMessage | None = None
229
+ self._current_streaming_reasoning: ReasoningMessage | None = None
230
+ self._windowing = SessionWindowing(load_more_batch_size=LOAD_MORE_BATCH_SIZE)
231
+ self._load_more = HistoryLoadMoreManager()
232
+ self._tool_call_map: dict[str, str] | None = None
233
+ self._history_widget_indices: WeakKeyDictionary[Widget, int] = (
234
+ WeakKeyDictionary()
235
+ )
236
+ self._update_notifier = update_notifier
237
+ self._update_cache_repository = update_cache_repository
238
+ self._current_version = current_version
239
+ self._plan_offer_gateway = plan_offer_gateway
240
+ self._initial_prompt = initial_prompt
241
+ self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
242
+ self._auto_scroll = True
243
+ self._last_escape_time: float | None = None
244
+ self._banner: Banner | None = None
245
+ self._cached_messages_area: Widget | None = None
246
+ self._cached_chat: ChatScroll | None = None
247
+ self._cached_loading_area: Widget | None = None
248
+
249
+ @property
250
+ def config(self) -> VibeConfig:
251
+ return self.agent_loop.config
252
+
253
+ def compose(self) -> ComposeResult:
254
+ with ChatScroll(id="chat"):
255
+ self._banner = Banner(self.config, self.agent_loop.skill_manager)
256
+ yield self._banner
257
+ yield VerticalGroup(id="messages")
258
+
259
+ with Horizontal(id="loading-area"):
260
+ yield Static(id="loading-area-content")
261
+
262
+ with Static(id="bottom-app-container"):
263
+ yield ChatInputContainer(
264
+ history_file=self.history_file,
265
+ command_registry=self.commands,
266
+ id="input-container",
267
+ safety=self.agent_loop.agent_profile.safety,
268
+ agent_name=self.agent_loop.agent_profile.display_name.lower(),
269
+ skill_entries_getter=self._get_skill_entries,
270
+ nuage_enabled=self.config.nuage_enabled,
271
+ )
272
+
273
+ with Horizontal(id="bottom-bar"):
274
+ yield PathDisplay(self.config.displayed_workdir or Path.cwd())
275
+ yield NoMarkupStatic(id="spacer")
276
+ yield ContextProgress()
277
+
278
+ async def on_mount(self) -> None:
279
+ self.theme = "textual-ansi"
280
+
281
+ self._cached_messages_area = self.query_one("#messages")
282
+ self._cached_chat = self.query_one("#chat", ChatScroll)
283
+ self._cached_loading_area = self.query_one("#loading-area-content")
284
+
285
+ self.event_handler = EventHandler(
286
+ mount_callback=self._mount_and_scroll,
287
+ scroll_callback=self._scroll_to_bottom_deferred,
288
+ get_tools_collapsed=lambda: self._tools_collapsed,
289
+ )
290
+
291
+ self._chat_input_container = self.query_one(ChatInputContainer)
292
+ context_progress = self.query_one(ContextProgress)
293
+
294
+ def update_context_progress(stats: AgentStats) -> None:
295
+ context_progress.tokens = TokenState(
296
+ max_tokens=self.config.auto_compact_threshold,
297
+ current_tokens=stats.context_tokens,
298
+ )
299
+
300
+ self.agent_loop.stats.add_listener("context_tokens", update_context_progress)
301
+ self.agent_loop.stats.trigger_listeners()
302
+
303
+ self.agent_loop.set_approval_callback(self._approval_callback)
304
+ self.agent_loop.set_user_input_callback(self._user_input_callback)
305
+ self._refresh_profile_widgets()
306
+
307
+ chat_input_container = self.query_one(ChatInputContainer)
308
+ chat_input_container.focus_input()
309
+ await self._show_dangerous_directory_warning()
310
+ await self._resume_history_from_messages()
311
+ await self._check_and_show_whats_new()
312
+ self._schedule_update_notification()
313
+ self.agent_loop.emit_new_session_telemetry("cli")
314
+
315
+ if self._initial_prompt or self._teleport_on_start:
316
+ self.call_after_refresh(self._process_initial_prompt)
317
+
318
+ def _process_initial_prompt(self) -> None:
319
+ if self._teleport_on_start:
320
+ self.run_worker(
321
+ self._handle_teleport_command(self._initial_prompt), exclusive=False
322
+ )
323
+ elif self._initial_prompt:
324
+ self.run_worker(
325
+ self._handle_user_message(self._initial_prompt), exclusive=False
326
+ )
327
+
328
+ async def on_chat_input_container_submitted(
329
+ self, event: ChatInputContainer.Submitted
330
+ ) -> None:
331
+ if self._banner:
332
+ self._banner.freeze_animation()
333
+
334
+ value = event.value.strip()
335
+ if not value:
336
+ return
337
+
338
+ input_widget = self.query_one(ChatInputContainer)
339
+ input_widget.value = ""
340
+
341
+ if self._agent_running:
342
+ await self._interrupt_agent_loop()
343
+
344
+ if value.startswith("!"):
345
+ await self._handle_bash_command(value[1:])
346
+ return
347
+
348
+ if value.startswith("&"):
349
+ if self.config.nuage_enabled:
350
+ await self._handle_teleport_command(value[1:])
351
+ return
352
+
353
+ if await self._handle_command(value):
354
+ return
355
+
356
+ if await self._handle_skill(value):
357
+ return
358
+
359
+ await self._handle_user_message(value)
360
+
361
+ async def on_approval_app_approval_granted(
362
+ self, message: ApprovalApp.ApprovalGranted
363
+ ) -> None:
364
+ if self._pending_approval and not self._pending_approval.done():
365
+ self._pending_approval.set_result((ApprovalResponse.YES, None))
366
+
367
+ await self._switch_to_input_app()
368
+
369
+ async def on_approval_app_approval_granted_always_tool(
370
+ self, message: ApprovalApp.ApprovalGrantedAlwaysTool
371
+ ) -> None:
372
+ self._set_tool_permission_always(
373
+ message.tool_name, save_permanently=message.save_permanently
374
+ )
375
+
376
+ if self._pending_approval and not self._pending_approval.done():
377
+ self._pending_approval.set_result((ApprovalResponse.YES, None))
378
+
379
+ await self._switch_to_input_app()
380
+
381
+ async def on_approval_app_approval_rejected(
382
+ self, message: ApprovalApp.ApprovalRejected
383
+ ) -> None:
384
+ if self._pending_approval and not self._pending_approval.done():
385
+ feedback = str(
386
+ get_user_cancellation_message(CancellationReason.OPERATION_CANCELLED)
387
+ )
388
+ self._pending_approval.set_result((ApprovalResponse.NO, feedback))
389
+
390
+ await self._switch_to_input_app()
391
+
392
+ if self._loading_widget and self._loading_widget.parent:
393
+ await self._remove_loading_widget()
394
+
395
+ async def on_question_app_answered(self, message: QuestionApp.Answered) -> None:
396
+ if self._pending_question and not self._pending_question.done():
397
+ result = AskUserQuestionResult(answers=message.answers, cancelled=False)
398
+ self._pending_question.set_result(result)
399
+
400
+ await self._switch_to_input_app()
401
+
402
+ async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
403
+ if self._pending_question and not self._pending_question.done():
404
+ result = AskUserQuestionResult(answers=[], cancelled=True)
405
+ self._pending_question.set_result(result)
406
+
407
+ await self._switch_to_input_app()
408
+ await self._interrupt_agent_loop()
409
+
410
+ async def _remove_loading_widget(self) -> None:
411
+ if self._loading_widget and self._loading_widget.parent:
412
+ await self._loading_widget.remove()
413
+ self._loading_widget = None
414
+
415
+ async def on_config_app_config_closed(
416
+ self, message: ConfigApp.ConfigClosed
417
+ ) -> None:
418
+ if message.changes:
419
+ VibeConfig.save_updates(message.changes)
420
+ await self._reload_config()
421
+ else:
422
+ await self._mount_and_scroll(
423
+ UserCommandMessage("Configuration closed (no changes saved).")
424
+ )
425
+
426
+ await self._switch_to_input_app()
427
+
428
+ async def on_proxy_setup_app_proxy_setup_closed(
429
+ self, message: ProxySetupApp.ProxySetupClosed
430
+ ) -> None:
431
+ if message.error:
432
+ await self._mount_and_scroll(
433
+ ErrorMessage(f"Failed to save proxy settings: {message.error}")
434
+ )
435
+ elif message.saved:
436
+ await self._mount_and_scroll(
437
+ UserCommandMessage(
438
+ "Proxy settings saved. Restart the CLI for changes to take effect."
439
+ )
440
+ )
441
+ else:
442
+ await self._mount_and_scroll(UserCommandMessage("Proxy setup cancelled."))
443
+
444
+ await self._switch_to_input_app()
445
+
446
+ async def on_compact_message_completed(
447
+ self, message: CompactMessage.Completed
448
+ ) -> None:
449
+ messages_area = self._cached_messages_area or self.query_one("#messages")
450
+ children = list(messages_area.children)
451
+
452
+ try:
453
+ compact_index = children.index(message.compact_widget)
454
+ except ValueError:
455
+ return
456
+
457
+ if compact_index == 0:
458
+ return
459
+
460
+ with self.batch_update():
461
+ for widget in children[:compact_index]:
462
+ await widget.remove()
463
+
464
+ def _set_tool_permission_always(
465
+ self, tool_name: str, save_permanently: bool = False
466
+ ) -> None:
467
+ self.agent_loop.set_tool_permission(
468
+ tool_name, ToolPermission.ALWAYS, save_permanently
469
+ )
470
+
471
+ async def _handle_command(self, user_input: str) -> bool:
472
+ if command := self.commands.find_command(user_input):
473
+ if cmd_name := self.commands.get_command_name(user_input):
474
+ self.agent_loop.telemetry_client.send_slash_command_used(
475
+ cmd_name, "builtin"
476
+ )
477
+ await self._mount_and_scroll(UserMessage(user_input))
478
+ handler = getattr(self, command.handler)
479
+ if asyncio.iscoroutinefunction(handler):
480
+ await handler()
481
+ else:
482
+ handler()
483
+ return True
484
+ return False
485
+
486
+ def _get_skill_entries(self) -> list[tuple[str, str]]:
487
+ if not self.agent_loop:
488
+ return []
489
+ return [
490
+ (f"/{name}", info.description)
491
+ for name, info in self.agent_loop.skill_manager.available_skills.items()
492
+ if info.user_invocable
493
+ ]
494
+
495
+ async def _handle_skill(self, user_input: str) -> bool:
496
+ if not user_input.startswith("/"):
497
+ return False
498
+
499
+ if not self.agent_loop:
500
+ return False
501
+
502
+ skill_name = user_input[1:].strip().lower()
503
+ skill_info = self.agent_loop.skill_manager.get_skill(skill_name)
504
+ if not skill_info:
505
+ return False
506
+
507
+ self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
508
+
509
+ try:
510
+ skill_content = skill_info.skill_path.read_text(encoding="utf-8")
511
+ except OSError as e:
512
+ await self._mount_and_scroll(
513
+ ErrorMessage(
514
+ f"Failed to read skill file: {e}", collapsed=self._tools_collapsed
515
+ )
516
+ )
517
+ return True
518
+
519
+ await self._handle_user_message(skill_content)
520
+ return True
521
+
522
+ async def _handle_bash_command(self, command: str) -> None:
523
+ if not command:
524
+ await self._mount_and_scroll(
525
+ ErrorMessage(
526
+ "No command provided after '!'", collapsed=self._tools_collapsed
527
+ )
528
+ )
529
+ return
530
+
531
+ try:
532
+ result = subprocess.run(
533
+ command, shell=True, capture_output=True, text=False, timeout=30
534
+ )
535
+ stdout = (
536
+ result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
537
+ )
538
+ stderr = (
539
+ result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
540
+ )
541
+ output = stdout or stderr or "(no output)"
542
+ exit_code = result.returncode
543
+ await self._mount_and_scroll(
544
+ BashOutputMessage(command, str(Path.cwd()), output, exit_code)
545
+ )
546
+ except subprocess.TimeoutExpired:
547
+ await self._mount_and_scroll(
548
+ ErrorMessage(
549
+ "Command timed out after 30 seconds",
550
+ collapsed=self._tools_collapsed,
551
+ )
552
+ )
553
+ except Exception as e:
554
+ await self._mount_and_scroll(
555
+ ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
556
+ )
557
+
558
+ async def _handle_user_message(self, message: str) -> None:
559
+ user_message = UserMessage(message)
560
+
561
+ await self._mount_and_scroll(user_message)
562
+
563
+ if not self._agent_running:
564
+ self._agent_task = asyncio.create_task(
565
+ self._handle_agent_loop_turn(message)
566
+ )
567
+
568
+ async def _resume_history_from_messages(self) -> None:
569
+ messages_area = self._cached_messages_area or self.query_one("#messages")
570
+ if not should_resume_history(list(messages_area.children)):
571
+ return
572
+
573
+ self._windowing.reset()
574
+ history_messages = non_system_history_messages(self.agent_loop.messages)
575
+ if (
576
+ plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
577
+ ) is None:
578
+ return
579
+ await self._mount_history_batch(
580
+ plan.tail_messages,
581
+ messages_area,
582
+ plan.tool_call_map,
583
+ start_index=plan.tail_start_index,
584
+ )
585
+ self.call_after_refresh(
586
+ lambda: self._align_chat_after_history_rebuild(plan.has_backfill)
587
+ )
588
+ self._tool_call_map = plan.tool_call_map
589
+ self._windowing.set_backfill(plan.backfill_messages)
590
+ await self._load_more.set_visible(
591
+ messages_area,
592
+ visible=self._windowing.has_backfill,
593
+ remaining=self._windowing.remaining,
594
+ )
595
+
596
+ async def _mount_history_batch(
597
+ self,
598
+ batch: list[LLMMessage],
599
+ messages_area: Widget,
600
+ tool_call_map: dict[str, str],
601
+ *,
602
+ start_index: int,
603
+ before: Widget | int | None = None,
604
+ after: Widget | None = None,
605
+ ) -> None:
606
+ widgets = build_history_widgets(
607
+ batch=batch,
608
+ tool_call_map=tool_call_map,
609
+ start_index=start_index,
610
+ tools_collapsed=self._tools_collapsed,
611
+ history_widget_indices=self._history_widget_indices,
612
+ )
613
+
614
+ with self.batch_update():
615
+ if not widgets:
616
+ return
617
+ if before is not None:
618
+ await messages_area.mount_all(widgets, before=before)
619
+ return
620
+ if after is not None:
621
+ await messages_area.mount_all(widgets, after=after)
622
+ return
623
+ await messages_area.mount_all(widgets)
624
+
625
+ def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
626
+ return tool in self.agent_loop.tool_manager.available_tools
627
+
628
+ async def _approval_callback(
629
+ self, tool: str, args: BaseModel, tool_call_id: str
630
+ ) -> tuple[ApprovalResponse, str | None]:
631
+ # Auto-approve only if parent is in auto-approve mode AND tool is enabled
632
+ # This ensures subagents respect the main agent's tool restrictions
633
+ if self.agent_loop and self.agent_loop.config.auto_approve:
634
+ if self._is_tool_enabled_in_main_agent(tool):
635
+ return (ApprovalResponse.YES, None)
636
+
637
+ self._pending_approval = asyncio.Future()
638
+ with paused_timer(self._loading_widget):
639
+ await self._switch_to_approval_app(tool, args)
640
+ result = await self._pending_approval
641
+
642
+ self._pending_approval = None
643
+ return result
644
+
645
+ async def _user_input_callback(self, args: BaseModel) -> BaseModel:
646
+ question_args = cast(AskUserQuestionArgs, args)
647
+
648
+ self._pending_question = asyncio.Future()
649
+ with paused_timer(self._loading_widget):
650
+ await self._switch_to_question_app(question_args)
651
+ result = await self._pending_question
652
+
653
+ self._pending_question = None
654
+ return result
655
+
656
+ async def _handle_agent_loop_turn(self, prompt: str) -> None:
657
+ self._agent_running = True
658
+
659
+ loading_area = self._cached_loading_area or self.query_one(
660
+ "#loading-area-content"
661
+ )
662
+
663
+ loading = LoadingWidget()
664
+ self._loading_widget = loading
665
+ await loading_area.mount(loading)
666
+
667
+ try:
668
+ rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
669
+ async for event in self.agent_loop.act(rendered_prompt):
670
+ if self.event_handler:
671
+ await self.event_handler.handle_event(
672
+ event,
673
+ loading_active=self._loading_widget is not None,
674
+ loading_widget=self._loading_widget,
675
+ )
676
+
677
+ except asyncio.CancelledError:
678
+ if self._loading_widget and self._loading_widget.parent:
679
+ await self._loading_widget.remove()
680
+ if self.event_handler:
681
+ self.event_handler.stop_current_tool_call(success=False)
682
+ raise
683
+ except Exception as e:
684
+ if self._loading_widget and self._loading_widget.parent:
685
+ await self._loading_widget.remove()
686
+ if self.event_handler:
687
+ self.event_handler.stop_current_tool_call(success=False)
688
+
689
+ message = str(e)
690
+ if isinstance(e, RateLimitError):
691
+ if self.plan_type == PlanType.FREE:
692
+ message = "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
693
+ else:
694
+ message = "Rate limits exceeded. Please wait a moment before trying again."
695
+
696
+ await self._mount_and_scroll(
697
+ ErrorMessage(message, collapsed=self._tools_collapsed)
698
+ )
699
+ finally:
700
+ self._agent_running = False
701
+ self._interrupt_requested = False
702
+ self._agent_task = None
703
+ if self._loading_widget:
704
+ await self._loading_widget.remove()
705
+ self._loading_widget = None
706
+ await self._finalize_current_streaming_message()
707
+ await self._refresh_windowing_from_history()
708
+
709
+ async def _teleport_command(self) -> None:
710
+ await self._handle_teleport_command(show_message=False)
711
+
712
+ async def _handle_teleport_command(
713
+ self, value: str | None = None, show_message: bool = True
714
+ ) -> None:
715
+ has_history = any(msg.role != Role.system for msg in self.agent_loop.messages)
716
+ if not value:
717
+ if show_message:
718
+ await self._mount_and_scroll(UserMessage("/teleport"))
719
+ if not has_history:
720
+ await self._mount_and_scroll(
721
+ ErrorMessage(
722
+ "No conversation history to teleport.",
723
+ collapsed=self._tools_collapsed,
724
+ )
725
+ )
726
+ return
727
+ elif show_message:
728
+ await self._mount_and_scroll(UserMessage(value))
729
+ self.run_worker(self._teleport(value), exclusive=False)
730
+
731
+ async def _teleport(self, prompt: str | None = None) -> None:
732
+ loading_area = self._cached_loading_area or self.query_one(
733
+ "#loading-area-content"
734
+ )
735
+ loading = LoadingWidget()
736
+ await loading_area.mount(loading)
737
+
738
+ teleport_msg = TeleportMessage()
739
+ await self._mount_and_scroll(teleport_msg)
740
+
741
+ try:
742
+ gen = self.agent_loop.teleport_to_vibe_nuage(prompt)
743
+ async for event in gen:
744
+ match event:
745
+ case TeleportCheckingGitEvent():
746
+ teleport_msg.set_status("Checking git status...")
747
+ case TeleportPushRequiredEvent(unpushed_count=count):
748
+ await loading.remove()
749
+ response = await self._ask_push_approval(count)
750
+ await loading_area.mount(loading)
751
+ teleport_msg.set_status("Teleporting...")
752
+ await gen.asend(response)
753
+ case TeleportPushingEvent():
754
+ teleport_msg.set_status("Pushing to remote...")
755
+ case TeleportAuthRequiredEvent(
756
+ user_code=code, verification_uri=uri
757
+ ):
758
+ teleport_msg.set_status(
759
+ f"GitHub auth required. Code: {code} (copied)\nOpen: {uri}"
760
+ )
761
+ case TeleportAuthCompleteEvent():
762
+ teleport_msg.set_status("GitHub authenticated.")
763
+ case TeleportStartingWorkflowEvent():
764
+ teleport_msg.set_status("Starting Nuage workflow...")
765
+ case TeleportSendingGithubTokenEvent():
766
+ teleport_msg.set_status("Sending encrypted GitHub token...")
767
+ case TeleportCompleteEvent(url=url):
768
+ teleport_msg.set_complete(url)
769
+ except TeleportError as e:
770
+ await teleport_msg.remove()
771
+ await self._mount_and_scroll(
772
+ ErrorMessage(str(e), collapsed=self._tools_collapsed)
773
+ )
774
+ finally:
775
+ if loading.parent:
776
+ await loading.remove()
777
+
778
+ async def _ask_push_approval(self, count: int) -> TeleportPushResponseEvent:
779
+ word = f"commit{'s' if count != 1 else ''}"
780
+ push_label = "Push and continue"
781
+ result = await self._user_input_callback(
782
+ AskUserQuestionArgs(
783
+ questions=[
784
+ Question(
785
+ question=f"You have {count} unpushed {word}. Push to continue?",
786
+ header="Push",
787
+ options=[Choice(label=push_label), Choice(label="Cancel")],
788
+ hide_other=True,
789
+ )
790
+ ]
791
+ )
792
+ )
793
+ ok = (
794
+ isinstance(result, AskUserQuestionResult)
795
+ and not result.cancelled
796
+ and bool(result.answers)
797
+ and result.answers[0].answer == push_label
798
+ )
799
+ return TeleportPushResponseEvent(approved=ok)
800
+
801
+ async def _interrupt_agent_loop(self) -> None:
802
+ if not self._agent_running or self._interrupt_requested:
803
+ return
804
+
805
+ self._interrupt_requested = True
806
+
807
+ if self._agent_task and not self._agent_task.done():
808
+ self._agent_task.cancel()
809
+ try:
810
+ await self._agent_task
811
+ except asyncio.CancelledError:
812
+ pass
813
+
814
+ if self.event_handler:
815
+ self.event_handler.stop_current_tool_call(success=False)
816
+ self.event_handler.stop_current_compact()
817
+
818
+ self._agent_running = False
819
+ loading_area = self._cached_loading_area or self.query_one(
820
+ "#loading-area-content"
821
+ )
822
+ await loading_area.remove_children()
823
+ self._loading_widget = None
824
+
825
+ await self._finalize_current_streaming_message()
826
+ await self._mount_and_scroll(InterruptMessage())
827
+
828
+ self._interrupt_requested = False
829
+
830
+ async def _show_help(self) -> None:
831
+ help_text = self.commands.get_help_text()
832
+ await self._mount_and_scroll(UserCommandMessage(help_text))
833
+
834
+ async def _show_status(self) -> None:
835
+ stats = self.agent_loop.stats
836
+ status_text = f"""## Agent Statistics
837
+
838
+ - **Steps**: {stats.steps:,}
839
+ - **Session Prompt Tokens**: {stats.session_prompt_tokens:,}
840
+ - **Session Completion Tokens**: {stats.session_completion_tokens:,}
841
+ - **Session Total LLM Tokens**: {stats.session_total_llm_tokens:,}
842
+ - **Last Turn Tokens**: {stats.last_turn_total_tokens:,}
843
+ - **Cost**: ${stats.session_cost:.4f}
844
+ """
845
+ await self._mount_and_scroll(UserCommandMessage(status_text))
846
+
847
+ async def _show_config(self) -> None:
848
+ """Switch to the configuration app in the bottom panel."""
849
+ if self._current_bottom_app == BottomApp.Config:
850
+ return
851
+ await self._switch_to_config_app()
852
+
853
+ async def _show_proxy_setup(self) -> None:
854
+ if self._current_bottom_app == BottomApp.ProxySetup:
855
+ return
856
+ await self._switch_to_proxy_setup_app()
857
+
858
+ async def _reload_config(self) -> None:
859
+ try:
860
+ self._windowing.reset()
861
+ self._tool_call_map = None
862
+ self._history_widget_indices = WeakKeyDictionary()
863
+ await self._load_more.hide()
864
+ base_config = VibeConfig.load()
865
+
866
+ await self.agent_loop.reload_with_initial_messages(base_config=base_config)
867
+
868
+ if self._banner:
869
+ self._banner.set_state(base_config, self.agent_loop.skill_manager)
870
+ await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
871
+ except Exception as e:
872
+ await self._mount_and_scroll(
873
+ ErrorMessage(
874
+ f"Failed to reload config: {e}", collapsed=self._tools_collapsed
875
+ )
876
+ )
877
+
878
+ async def _clear_history(self) -> None:
879
+ try:
880
+ self._windowing.reset()
881
+ self._tool_call_map = None
882
+ self._history_widget_indices = WeakKeyDictionary()
883
+ await self.agent_loop.clear_history()
884
+ await self._finalize_current_streaming_message()
885
+ messages_area = self._cached_messages_area or self.query_one("#messages")
886
+ await messages_area.remove_children()
887
+
888
+ await messages_area.mount(UserMessage("/clear"))
889
+ await self._mount_and_scroll(
890
+ UserCommandMessage("Conversation history cleared!")
891
+ )
892
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
893
+ chat.scroll_home(animate=False)
894
+
895
+ except Exception as e:
896
+ await self._mount_and_scroll(
897
+ ErrorMessage(
898
+ f"Failed to clear history: {e}", collapsed=self._tools_collapsed
899
+ )
900
+ )
901
+
902
+ async def _show_log_path(self) -> None:
903
+ if not self.agent_loop.session_logger.enabled:
904
+ await self._mount_and_scroll(
905
+ ErrorMessage(
906
+ "Session logging is disabled in configuration.",
907
+ collapsed=self._tools_collapsed,
908
+ )
909
+ )
910
+ return
911
+
912
+ try:
913
+ log_path = str(self.agent_loop.session_logger.session_dir)
914
+ await self._mount_and_scroll(
915
+ UserCommandMessage(
916
+ f"## Current Log Directory\n\n`{log_path}`\n\nYou can send this directory to share your interaction."
917
+ )
918
+ )
919
+ except Exception as e:
920
+ await self._mount_and_scroll(
921
+ ErrorMessage(
922
+ f"Failed to get log path: {e}", collapsed=self._tools_collapsed
923
+ )
924
+ )
925
+
926
+ async def _compact_history(self) -> None:
927
+ if self._agent_running:
928
+ await self._mount_and_scroll(
929
+ ErrorMessage(
930
+ "Cannot compact while agent loop is processing. Please wait.",
931
+ collapsed=self._tools_collapsed,
932
+ )
933
+ )
934
+ return
935
+
936
+ if len(self.agent_loop.messages) <= 1:
937
+ await self._mount_and_scroll(
938
+ ErrorMessage(
939
+ "No conversation history to compact yet.",
940
+ collapsed=self._tools_collapsed,
941
+ )
942
+ )
943
+ return
944
+
945
+ if not self.event_handler:
946
+ return
947
+
948
+ old_tokens = self.agent_loop.stats.context_tokens
949
+ compact_msg = CompactMessage()
950
+ self.event_handler.current_compact = compact_msg
951
+ await self._mount_and_scroll(compact_msg)
952
+
953
+ self._agent_task = asyncio.create_task(
954
+ self._run_compact(compact_msg, old_tokens)
955
+ )
956
+
957
+ async def _run_compact(self, compact_msg: CompactMessage, old_tokens: int) -> None:
958
+ self._agent_running = True
959
+ try:
960
+ await self.agent_loop.compact()
961
+ new_tokens = self.agent_loop.stats.context_tokens
962
+ compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
963
+
964
+ except asyncio.CancelledError:
965
+ compact_msg.set_error("Compaction interrupted")
966
+ raise
967
+ except Exception as e:
968
+ compact_msg.set_error(str(e))
969
+ finally:
970
+ self._agent_running = False
971
+ self._agent_task = None
972
+ if self.event_handler:
973
+ self.event_handler.current_compact = None
974
+
975
+ def _get_session_resume_info(self) -> str | None:
976
+ if not self.agent_loop.session_logger.enabled:
977
+ return None
978
+ if not self.agent_loop.session_logger.session_id:
979
+ return None
980
+ session_config = self.agent_loop.session_logger.session_config
981
+ session_path = SessionLoader.does_session_exist(
982
+ self.agent_loop.session_logger.session_id, session_config
983
+ )
984
+ if session_path is None:
985
+ return None
986
+ return self.agent_loop.session_logger.session_id[:8]
987
+
988
+ async def _exit_app(self) -> None:
989
+ self.exit(result=self._get_session_resume_info())
990
+
991
+ async def _setup_terminal(self) -> None:
992
+ result = setup_terminal()
993
+
994
+ if result.success:
995
+ if result.requires_restart:
996
+ message = f"{result.message or 'Set up Shift+Enter keybind'} (You may need to restart your terminal.)"
997
+ await self._mount_and_scroll(
998
+ UserCommandMessage(f"{result.terminal.value}: {message}")
999
+ )
1000
+ else:
1001
+ message = result.message or "Shift+Enter keybind already set up"
1002
+ await self._mount_and_scroll(
1003
+ WarningMessage(f"{result.terminal.value}: {message}")
1004
+ )
1005
+ else:
1006
+ await self._mount_and_scroll(
1007
+ ErrorMessage(result.message, collapsed=self._tools_collapsed)
1008
+ )
1009
+
1010
+ async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
1011
+ bottom_container = self.query_one("#bottom-app-container")
1012
+
1013
+ if self._chat_input_container:
1014
+ self._chat_input_container.display = False
1015
+ self._chat_input_container.disabled = True
1016
+
1017
+ self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
1018
+ await bottom_container.mount(widget)
1019
+
1020
+ self.call_after_refresh(widget.focus)
1021
+ if scroll:
1022
+ self.call_after_refresh(self._scroll_to_bottom)
1023
+
1024
+ async def _switch_to_config_app(self) -> None:
1025
+ if self._current_bottom_app == BottomApp.Config:
1026
+ return
1027
+
1028
+ await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
1029
+ await self._switch_from_input(ConfigApp(self.config))
1030
+
1031
+ async def _switch_to_proxy_setup_app(self) -> None:
1032
+ if self._current_bottom_app == BottomApp.ProxySetup:
1033
+ return
1034
+
1035
+ await self._mount_and_scroll(UserCommandMessage("Proxy setup opened..."))
1036
+ await self._switch_from_input(ProxySetupApp())
1037
+
1038
+ async def _switch_to_approval_app(
1039
+ self, tool_name: str, tool_args: BaseModel
1040
+ ) -> None:
1041
+ approval_app = ApprovalApp(
1042
+ tool_name=tool_name, tool_args=tool_args, config=self.config
1043
+ )
1044
+ await self._switch_from_input(approval_app, scroll=True)
1045
+
1046
+ async def _switch_to_question_app(self, args: AskUserQuestionArgs) -> None:
1047
+ await self._switch_from_input(QuestionApp(args=args), scroll=True)
1048
+
1049
+ async def _switch_to_input_app(self) -> None:
1050
+ for app in BottomApp:
1051
+ if app != BottomApp.Input:
1052
+ try:
1053
+ await self.query_one(f"#{app.value}-app").remove()
1054
+ except Exception:
1055
+ pass
1056
+
1057
+ if self._chat_input_container:
1058
+ self._chat_input_container.disabled = False
1059
+ self._chat_input_container.display = True
1060
+ self._current_bottom_app = BottomApp.Input
1061
+ self.call_after_refresh(self._chat_input_container.focus_input)
1062
+ self.call_after_refresh(self._scroll_to_bottom)
1063
+
1064
+ def _focus_current_bottom_app(self) -> None:
1065
+ try:
1066
+ match self._current_bottom_app:
1067
+ case BottomApp.Input:
1068
+ self.query_one(ChatInputContainer).focus_input()
1069
+ case BottomApp.Config:
1070
+ self.query_one(ConfigApp).focus()
1071
+ case BottomApp.ProxySetup:
1072
+ self.query_one(ProxySetupApp).focus()
1073
+ case BottomApp.Approval:
1074
+ self.query_one(ApprovalApp).focus()
1075
+ case BottomApp.Question:
1076
+ self.query_one(QuestionApp).focus()
1077
+ case app:
1078
+ assert_never(app)
1079
+ except Exception:
1080
+ pass
1081
+
1082
+ def _handle_config_app_escape(self) -> None:
1083
+ try:
1084
+ config_app = self.query_one(ConfigApp)
1085
+ config_app.action_close()
1086
+ except Exception:
1087
+ pass
1088
+ self._last_escape_time = None
1089
+
1090
+ def _handle_approval_app_escape(self) -> None:
1091
+ try:
1092
+ approval_app = self.query_one(ApprovalApp)
1093
+ approval_app.action_reject()
1094
+ except Exception:
1095
+ pass
1096
+ self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
1097
+ self._last_escape_time = None
1098
+
1099
+ def _handle_question_app_escape(self) -> None:
1100
+ try:
1101
+ question_app = self.query_one(QuestionApp)
1102
+ question_app.action_cancel()
1103
+ except Exception:
1104
+ pass
1105
+ self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
1106
+ self._last_escape_time = None
1107
+
1108
+ def _handle_input_app_escape(self) -> None:
1109
+ try:
1110
+ input_widget = self.query_one(ChatInputContainer)
1111
+ input_widget.value = ""
1112
+ except Exception:
1113
+ pass
1114
+ self._last_escape_time = None
1115
+
1116
+ def _handle_agent_running_escape(self) -> None:
1117
+ self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
1118
+ self.run_worker(self._interrupt_agent_loop(), exclusive=False)
1119
+
1120
+ def action_interrupt(self) -> None:
1121
+ current_time = time.monotonic()
1122
+
1123
+ if self._current_bottom_app == BottomApp.Config:
1124
+ self._handle_config_app_escape()
1125
+ return
1126
+
1127
+ if self._current_bottom_app == BottomApp.ProxySetup:
1128
+ try:
1129
+ proxy_setup_app = self.query_one(ProxySetupApp)
1130
+ proxy_setup_app.action_close()
1131
+ except Exception:
1132
+ pass
1133
+ self._last_escape_time = None
1134
+ return
1135
+
1136
+ if self._current_bottom_app == BottomApp.Approval:
1137
+ self._handle_approval_app_escape()
1138
+ return
1139
+
1140
+ if self._current_bottom_app == BottomApp.Question:
1141
+ self._handle_question_app_escape()
1142
+ return
1143
+
1144
+ if (
1145
+ self._current_bottom_app == BottomApp.Input
1146
+ and self._last_escape_time is not None
1147
+ and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
1148
+ ):
1149
+ self._handle_input_app_escape()
1150
+ return
1151
+
1152
+ if self._agent_running:
1153
+ self._handle_agent_running_escape()
1154
+
1155
+ self._last_escape_time = current_time
1156
+ self._scroll_to_bottom()
1157
+ self._focus_current_bottom_app()
1158
+
1159
+ async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
1160
+ self._load_more.set_enabled(False)
1161
+ try:
1162
+ if not self._windowing.has_backfill:
1163
+ await self._load_more.hide()
1164
+ return
1165
+ if (batch := self._windowing.next_load_more_batch()) is None:
1166
+ await self._load_more.hide()
1167
+ return
1168
+ messages_area = self._cached_messages_area or self.query_one("#messages")
1169
+ if self._tool_call_map is None:
1170
+ self._tool_call_map = {}
1171
+ if self._load_more.widget:
1172
+ before: Widget | int | None = None
1173
+ after: Widget | None = self._load_more.widget
1174
+ else:
1175
+ before = 0
1176
+ after = None
1177
+ await self._mount_history_batch(
1178
+ batch.messages,
1179
+ messages_area,
1180
+ self._tool_call_map,
1181
+ start_index=batch.start_index,
1182
+ before=before,
1183
+ after=after,
1184
+ )
1185
+ if not self._windowing.has_backfill:
1186
+ await self._load_more.hide()
1187
+ else:
1188
+ await self._load_more.show(messages_area, self._windowing.remaining)
1189
+ finally:
1190
+ self._load_more.set_enabled(True)
1191
+
1192
+ async def action_toggle_tool(self) -> None:
1193
+ self._tools_collapsed = not self._tools_collapsed
1194
+
1195
+ for result in self.query(ToolResultMessage):
1196
+ await result.set_collapsed(self._tools_collapsed)
1197
+
1198
+ try:
1199
+ for error_msg in self.query(ErrorMessage):
1200
+ error_msg.set_collapsed(self._tools_collapsed)
1201
+ except Exception:
1202
+ pass
1203
+
1204
+ def action_cycle_mode(self) -> None:
1205
+ if self._current_bottom_app != BottomApp.Input:
1206
+ return
1207
+ self._refresh_profile_widgets()
1208
+ self._focus_current_bottom_app()
1209
+ self.run_worker(self._cycle_agent(), group="mode_switch", exclusive=True)
1210
+
1211
+ def _refresh_profile_widgets(self) -> None:
1212
+ self._update_profile_widgets(self.agent_loop.agent_profile)
1213
+
1214
+ def _update_profile_widgets(self, profile: AgentProfile) -> None:
1215
+ if self._chat_input_container:
1216
+ self._chat_input_container.set_safety(profile.safety)
1217
+ self._chat_input_container.set_agent_name(profile.display_name.lower())
1218
+
1219
+ async def _cycle_agent(self) -> None:
1220
+ new_profile = self.agent_loop.agent_manager.next_agent(
1221
+ self.agent_loop.agent_profile
1222
+ )
1223
+ self._update_profile_widgets(new_profile)
1224
+ await self.agent_loop.switch_agent(new_profile.name)
1225
+ self.agent_loop.set_approval_callback(self._approval_callback)
1226
+ self.agent_loop.set_user_input_callback(self._user_input_callback)
1227
+
1228
+ def action_clear_quit(self) -> None:
1229
+ input_widgets = self.query(ChatInputContainer)
1230
+ if input_widgets:
1231
+ input_widget = input_widgets.first()
1232
+ if input_widget.value:
1233
+ input_widget.value = ""
1234
+ return
1235
+
1236
+ self.action_force_quit()
1237
+
1238
+ def action_force_quit(self) -> None:
1239
+ if self._agent_task and not self._agent_task.done():
1240
+ self._agent_task.cancel()
1241
+
1242
+ self.exit(result=self._get_session_resume_info())
1243
+
1244
+ def action_scroll_chat_up(self) -> None:
1245
+ try:
1246
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
1247
+ chat.scroll_relative(y=-5, animate=False)
1248
+ self._auto_scroll = False
1249
+ except Exception:
1250
+ pass
1251
+
1252
+ def action_scroll_chat_down(self) -> None:
1253
+ try:
1254
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
1255
+ chat.scroll_relative(y=5, animate=False)
1256
+ if self._is_scrolled_to_bottom(chat):
1257
+ self._auto_scroll = True
1258
+ except Exception:
1259
+ pass
1260
+
1261
+ async def _show_dangerous_directory_warning(self) -> None:
1262
+ is_dangerous, reason = is_dangerous_directory()
1263
+ if is_dangerous:
1264
+ warning = (
1265
+ f"⚠ WARNING: {reason}\n\nRunning in this location is not recommended."
1266
+ )
1267
+ await self._mount_and_scroll(WarningMessage(warning, show_border=False))
1268
+
1269
+ async def _check_and_show_whats_new(self) -> None:
1270
+ if self._update_cache_repository is None:
1271
+ return
1272
+
1273
+ if not await should_show_whats_new(
1274
+ self._current_version, self._update_cache_repository
1275
+ ):
1276
+ return
1277
+
1278
+ content = load_whats_new_content()
1279
+ if content is not None:
1280
+ whats_new_message = WhatsNewMessage(content)
1281
+ plan_offer = await self._plan_offer_cta()
1282
+ if plan_offer is not None:
1283
+ whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
1284
+ if self._history_widget_indices:
1285
+ whats_new_message.add_class("after-history")
1286
+ await self._mount_and_scroll(whats_new_message)
1287
+ await mark_version_as_seen(self._current_version, self._update_cache_repository)
1288
+
1289
+ async def _plan_offer_cta(self) -> str | None:
1290
+ self.plan_type = PlanType.UNKNOWN
1291
+
1292
+ if self._plan_offer_gateway is None:
1293
+ return
1294
+
1295
+ try:
1296
+ active_model = self.config.get_active_model()
1297
+ provider = self.config.get_provider_for_model(active_model)
1298
+
1299
+ api_key = resolve_api_key_for_plan(provider)
1300
+ action, plan_type = await decide_plan_offer(
1301
+ api_key, self._plan_offer_gateway
1302
+ )
1303
+
1304
+ self.plan_type = plan_type
1305
+ return plan_offer_cta(action)
1306
+ except Exception as exc:
1307
+ logger.warning(
1308
+ "Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
1309
+ )
1310
+ return
1311
+
1312
+ async def _finalize_current_streaming_message(self) -> None:
1313
+ if self._current_streaming_reasoning is not None:
1314
+ self._current_streaming_reasoning.stop_spinning()
1315
+ await self._current_streaming_reasoning.stop_stream()
1316
+ self._current_streaming_reasoning = None
1317
+
1318
+ if self._current_streaming_message is None:
1319
+ return
1320
+
1321
+ await self._current_streaming_message.stop_stream()
1322
+ self._current_streaming_message = None
1323
+
1324
+ async def _handle_streaming_widget[T: StreamingMessageBase](
1325
+ self,
1326
+ widget: T,
1327
+ current_stream: T | None,
1328
+ other_stream: StreamingMessageBase | None,
1329
+ messages_area: Widget,
1330
+ ) -> T | None:
1331
+ if other_stream is not None:
1332
+ await other_stream.stop_stream()
1333
+
1334
+ if current_stream is not None:
1335
+ if widget._content:
1336
+ await current_stream.append_content(widget._content)
1337
+ return None
1338
+
1339
+ await messages_area.mount(widget)
1340
+ await widget.write_initial_content()
1341
+ return widget
1342
+
1343
+ async def _mount_and_scroll(self, widget: Widget) -> None:
1344
+ messages_area = self._cached_messages_area or self.query_one("#messages")
1345
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
1346
+ was_at_bottom = self._is_scrolled_to_bottom(chat)
1347
+ result: Widget | None = None
1348
+
1349
+ if was_at_bottom:
1350
+ self._auto_scroll = True
1351
+
1352
+ if isinstance(widget, ReasoningMessage):
1353
+ result = await self._handle_streaming_widget(
1354
+ widget,
1355
+ self._current_streaming_reasoning,
1356
+ self._current_streaming_message,
1357
+ messages_area,
1358
+ )
1359
+ if result is not None:
1360
+ self._current_streaming_reasoning = result
1361
+ self._current_streaming_message = None
1362
+ elif isinstance(widget, AssistantMessage):
1363
+ if self._current_streaming_reasoning is not None:
1364
+ self._current_streaming_reasoning.stop_spinning()
1365
+ result = await self._handle_streaming_widget(
1366
+ widget,
1367
+ self._current_streaming_message,
1368
+ self._current_streaming_reasoning,
1369
+ messages_area,
1370
+ )
1371
+ if result is not None:
1372
+ self._current_streaming_message = result
1373
+ self._current_streaming_reasoning = None
1374
+ else:
1375
+ await self._finalize_current_streaming_message()
1376
+ await messages_area.mount(widget)
1377
+ result = widget
1378
+
1379
+ is_tool_message = isinstance(widget, (ToolCallMessage, ToolResultMessage))
1380
+
1381
+ if not is_tool_message:
1382
+ self.call_after_refresh(self._scroll_to_bottom)
1383
+
1384
+ if result is not None:
1385
+ self.call_after_refresh(self._try_prune)
1386
+ if was_at_bottom:
1387
+ self.call_after_refresh(self._anchor_if_scrollable)
1388
+
1389
+ def _is_scrolled_to_bottom(self, scroll_view: VerticalScroll) -> bool:
1390
+ try:
1391
+ threshold = 3
1392
+ return scroll_view.scroll_y >= (scroll_view.max_scroll_y - threshold)
1393
+ except Exception:
1394
+ return True
1395
+
1396
+ def _scroll_to_bottom(self) -> None:
1397
+ try:
1398
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
1399
+ chat.scroll_end(animate=False)
1400
+ except Exception:
1401
+ pass
1402
+
1403
+ def _scroll_to_bottom_deferred(self) -> None:
1404
+ self.call_after_refresh(self._scroll_to_bottom)
1405
+
1406
+ async def _try_prune(self) -> None:
1407
+ messages_area = self._cached_messages_area or self.query_one("#messages")
1408
+ await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
1409
+ if self._load_more.widget and not self._load_more.widget.parent:
1410
+ self._load_more.widget = None
1411
+
1412
+ async def _refresh_windowing_from_history(self) -> None:
1413
+ if self._load_more.widget is None:
1414
+ return
1415
+ messages_area = self._cached_messages_area or self.query_one("#messages")
1416
+ has_backfill, tool_call_map = sync_backfill_state(
1417
+ history_messages=non_system_history_messages(self.agent_loop.messages),
1418
+ messages_children=list(messages_area.children),
1419
+ history_widget_indices=self._history_widget_indices,
1420
+ windowing=self._windowing,
1421
+ )
1422
+ self._tool_call_map = tool_call_map
1423
+ await self._load_more.set_visible(
1424
+ messages_area, visible=has_backfill, remaining=self._windowing.remaining
1425
+ )
1426
+
1427
+ def _anchor_if_scrollable(self) -> None:
1428
+ if not self._auto_scroll:
1429
+ return
1430
+ try:
1431
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
1432
+ if chat.max_scroll_y == 0:
1433
+ return
1434
+ chat.anchor()
1435
+ except Exception:
1436
+ pass
1437
+
1438
+ def _align_chat_after_history_rebuild(self, has_backfill: bool) -> None:
1439
+ try:
1440
+ chat = self._cached_chat or self.query_one("#chat", ChatScroll)
1441
+ if has_backfill and chat.max_scroll_y > 0:
1442
+ chat.anchor(True)
1443
+ chat.scroll_end(animate=False)
1444
+ chat.anchor(False)
1445
+ return
1446
+ chat.scroll_end(animate=False)
1447
+ except Exception:
1448
+ pass
1449
+
1450
+ def _schedule_update_notification(self) -> None:
1451
+ if self._update_notifier is None or not self.config.enable_update_checks:
1452
+ return
1453
+
1454
+ asyncio.create_task(self._check_update(), name="version-update-check")
1455
+
1456
+ async def _check_update(self) -> None:
1457
+ try:
1458
+ if self._update_notifier is None or self._update_cache_repository is None:
1459
+ return
1460
+
1461
+ update_availability = await get_update_if_available(
1462
+ update_notifier=self._update_notifier,
1463
+ current_version=self._current_version,
1464
+ update_cache_repository=self._update_cache_repository,
1465
+ )
1466
+ except UpdateError as error:
1467
+ self.notify(
1468
+ error.message,
1469
+ title="Update check failed",
1470
+ severity="warning",
1471
+ timeout=10,
1472
+ )
1473
+ return
1474
+ except Exception as exc:
1475
+ logger.debug("Version update check failed", exc_info=exc)
1476
+ return
1477
+
1478
+ if update_availability is None or not update_availability.should_notify:
1479
+ return
1480
+
1481
+ update_message_prefix = (
1482
+ f"{self._current_version} => {update_availability.latest_version}"
1483
+ )
1484
+
1485
+ if self.config.enable_auto_update and await do_update():
1486
+ self.notify(
1487
+ f"{update_message_prefix}\nVibe was updated successfully. Please restart to use the new version.",
1488
+ title="Update successful",
1489
+ severity="information",
1490
+ timeout=10,
1491
+ )
1492
+ return
1493
+
1494
+ message = f"{update_message_prefix}\nPlease update mistral-vibe with your package manager"
1495
+
1496
+ self.notify(
1497
+ message, title="Update available", severity="information", timeout=10
1498
+ )
1499
+
1500
+ def action_copy_selection(self) -> None:
1501
+ copied_text = copy_selection_to_clipboard(self, show_toast=False)
1502
+ if copied_text is not None:
1503
+ self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
1504
+
1505
+ def on_mouse_up(self, event: MouseUp) -> None:
1506
+ if self.config.autocopy_to_clipboard:
1507
+ copied_text = copy_selection_to_clipboard(self, show_toast=True)
1508
+ if copied_text is not None:
1509
+ self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
1510
+
1511
+ def on_app_blur(self, event: AppBlur) -> None:
1512
+ if self._chat_input_container and self._chat_input_container.input_widget:
1513
+ self._chat_input_container.input_widget.set_app_focus(False)
1514
+
1515
+ def on_app_focus(self, event: AppFocus) -> None:
1516
+ if self._chat_input_container and self._chat_input_container.input_widget:
1517
+ self._chat_input_container.input_widget.set_app_focus(True)
1518
+
1519
+
1520
+ def _print_session_resume_message(session_id: str | None) -> None:
1521
+ if not session_id:
1522
+ return
1523
+
1524
+ print()
1525
+ print("To continue this session, run: vibe --continue")
1526
+ print(f"Or: vibe --resume {session_id}")
1527
+
1528
+
1529
+ def run_textual_ui(
1530
+ agent_loop: AgentLoop,
1531
+ initial_prompt: str | None = None,
1532
+ teleport_on_start: bool = False,
1533
+ ) -> None:
1534
+ update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
1535
+ update_cache_repository = FileSystemUpdateCacheRepository()
1536
+ plan_offer_gateway = HttpWhoAmIGateway()
1537
+ app = VibeApp(
1538
+ agent_loop=agent_loop,
1539
+ initial_prompt=initial_prompt,
1540
+ teleport_on_start=teleport_on_start,
1541
+ update_notifier=update_notifier,
1542
+ update_cache_repository=update_cache_repository,
1543
+ plan_offer_gateway=plan_offer_gateway,
1544
+ )
1545
+ session_id = app.run()
1546
+ _print_session_resume_message(session_id)