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