v2.14.1 (#756)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Stanislas <stanislas.lange@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
3f8487f761
commit
702d0f412e
51 changed files with 2446 additions and 502 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.14.0"
|
||||
__version__ = "2.14.1"
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
|
|||
|
||||
from vibe import VIBE_ROOT, __version__
|
||||
from vibe.acp.acp_logger import acp_message_observer
|
||||
from vibe.acp.commands import AcpCommandRegistry
|
||||
from vibe.acp.commands import AcpCommandAvailabilityContext, AcpCommandRegistry
|
||||
from vibe.acp.exceptions import (
|
||||
ConfigurationError,
|
||||
ContextTooLongError,
|
||||
|
|
@ -86,6 +86,7 @@ from vibe.acp.exceptions import (
|
|||
UnauthenticatedError,
|
||||
)
|
||||
from vibe.acp.session import AcpSessionLoop
|
||||
from vibe.acp.teleport import handle_teleport_command
|
||||
from vibe.acp.title import acp_blocks_to_title_segments
|
||||
from vibe.acp.tools.base import BaseAcpTool
|
||||
from vibe.acp.tools.events import ToolTerminalOpenedEvent
|
||||
|
|
@ -109,6 +110,7 @@ from vibe.acp.utils import (
|
|||
create_tool_result_replay,
|
||||
create_user_message_replay,
|
||||
get_proxy_help_text,
|
||||
is_jetbrains_client,
|
||||
is_valid_acp_mode,
|
||||
make_thinking_response,
|
||||
)
|
||||
|
|
@ -186,6 +188,7 @@ from vibe.setup.onboarding.context import OnboardingContext
|
|||
logger = logging.getLogger("vibe")
|
||||
|
||||
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
|
||||
INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1
|
||||
|
||||
|
||||
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
|
||||
|
|
@ -354,6 +357,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
id="vibe-setup",
|
||||
name="Register your API Key",
|
||||
description="Register your API Key inside Mistral Vibe",
|
||||
args=args,
|
||||
field_meta={
|
||||
"terminal-auth": {
|
||||
"command": command,
|
||||
|
|
@ -448,6 +452,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
if supports_terminal_auth:
|
||||
auth_methods.append(self._build_terminal_auth_method(command, args))
|
||||
|
||||
# JetBrains preemptively shows the auth UI as soon as `authMethods` is
|
||||
# non-empty; suppress methods for already-authenticated JetBrains clients.
|
||||
_, auth_state = self._assess_current_auth_state()
|
||||
if is_jetbrains_client(self.client_info) and auth_state.can_use_active_provider:
|
||||
auth_methods = []
|
||||
|
||||
response = InitializeResponse(
|
||||
agent_capabilities=AgentCapabilities(
|
||||
load_session=True,
|
||||
|
|
@ -606,7 +616,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
async def _create_acp_session(
|
||||
self, session_id: str, agent_loop: AgentLoop
|
||||
) -> AcpSessionLoop:
|
||||
command_registry = AcpCommandRegistry()
|
||||
command_registry = AcpCommandRegistry(
|
||||
availability_context=AcpCommandAvailabilityContext(
|
||||
vibe_code_enabled=agent_loop.base_config.vibe_code_enabled
|
||||
)
|
||||
)
|
||||
session = AcpSessionLoop(
|
||||
id=session_id, agent_loop=agent_loop, command_registry=command_registry
|
||||
)
|
||||
|
|
@ -620,11 +634,17 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
if not agent_loop.bypass_tool_permissions:
|
||||
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
|
||||
|
||||
session.spawn(self._send_available_commands(session))
|
||||
session.spawn(self._send_initial_available_commands(session))
|
||||
session.spawn(self._warm_up_agent_loop(agent_loop))
|
||||
|
||||
return session
|
||||
|
||||
async def _send_initial_available_commands(self, session: AcpSessionLoop) -> None:
|
||||
# Zed can drop session/update notifications sent before it registers
|
||||
# the session returned by session/new, so delay initial command discovery.
|
||||
await asyncio.sleep(INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS)
|
||||
await self._send_available_commands(session)
|
||||
|
||||
async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None:
|
||||
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
|
||||
without waiting for the user's first prompt. Errors are swallowed here
|
||||
|
|
@ -1052,6 +1072,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
success = await self._apply_thinking_change(
|
||||
session, cast(ThinkingLevel, value)
|
||||
)
|
||||
case "max_turns" if isinstance(value, str):
|
||||
try:
|
||||
max_turns = int(value)
|
||||
except ValueError:
|
||||
success = False
|
||||
else:
|
||||
session.agent_loop.set_max_turns(max_turns)
|
||||
success = True
|
||||
case _:
|
||||
success = False
|
||||
|
||||
|
|
@ -1701,6 +1729,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
)
|
||||
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
|
||||
|
||||
async def _handle_teleport(
|
||||
self, session: AcpSessionLoop, text_prompt: str, message_id: str
|
||||
) -> PromptResponse:
|
||||
return await handle_teleport_command(self.client, session, message_id)
|
||||
|
||||
async def _handle_help(
|
||||
self, session: AcpSessionLoop, text_prompt: str, message_id: str
|
||||
) -> PromptResponse:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.acp.commands.registry import AcpCommandRegistry
|
||||
from vibe.acp.commands.registry import AcpCommandAvailabilityContext, AcpCommandRegistry
|
||||
|
||||
__all__ = ["AcpCommandRegistry"]
|
||||
__all__ = ["AcpCommandAvailabilityContext", "AcpCommandRegistry"]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@ from dataclasses import dataclass, field
|
|||
type OnCommandsChanged = Callable[[], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcpCommandAvailabilityContext:
|
||||
"""Context used to decide whether a command should be advertised."""
|
||||
|
||||
vibe_code_enabled: bool = False
|
||||
|
||||
def is_teleport_available(self) -> bool:
|
||||
return self.vibe_code_enabled
|
||||
|
||||
|
||||
type CommandAvailability = Callable[[AcpCommandAvailabilityContext], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcpCommand:
|
||||
"""Command advertised to ACP clients via available_commands_update."""
|
||||
|
|
@ -14,18 +27,31 @@ class AcpCommand:
|
|||
description: str
|
||||
handler: str
|
||||
input_hint: str | None = None
|
||||
is_available: CommandAvailability | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AcpCommandRegistry:
|
||||
"""Registry of ACP commands. Notifies listeners when commands change."""
|
||||
|
||||
availability_context: AcpCommandAvailabilityContext = field(
|
||||
default_factory=AcpCommandAvailabilityContext
|
||||
)
|
||||
_commands: dict[str, AcpCommand] = field(default_factory=dict)
|
||||
_on_changed: OnCommandsChanged | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self._commands:
|
||||
self._commands = _build_commands()
|
||||
self._commands = {
|
||||
name: command
|
||||
for name, command in _build_commands().items()
|
||||
if self._is_available(command)
|
||||
}
|
||||
|
||||
def _is_available(self, command: AcpCommand) -> bool:
|
||||
if command.is_available is None:
|
||||
return True
|
||||
return command.is_available(self.availability_context)
|
||||
|
||||
def set_on_changed(self, callback: OnCommandsChanged) -> None:
|
||||
self._on_changed = callback
|
||||
|
|
@ -65,6 +91,12 @@ def _build_commands() -> dict[str, AcpCommand]:
|
|||
description="Show path to current session log directory",
|
||||
handler="_handle_log",
|
||||
),
|
||||
"teleport": AcpCommand(
|
||||
name="teleport",
|
||||
description="Teleport session to Vibe Code Web",
|
||||
handler="_handle_teleport",
|
||||
is_available=AcpCommandAvailabilityContext.is_teleport_available,
|
||||
),
|
||||
"proxy-setup": AcpCommand(
|
||||
name="proxy-setup",
|
||||
description="Configure proxy and SSL certificate settings",
|
||||
|
|
|
|||
334
vibe/acp/teleport.py
Normal file
334
vibe/acp/teleport.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from contextlib import aclosing
|
||||
from typing import Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from acp import Client, PromptResponse
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AllowedOutcome,
|
||||
ContentToolCallContent,
|
||||
PermissionOption,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
ToolCallStatus,
|
||||
ToolCallUpdate,
|
||||
)
|
||||
|
||||
from vibe.acp.session import AcpSessionLoop
|
||||
from vibe.core.agent_loop import TeleportError
|
||||
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportCheckingGitEvent,
|
||||
TeleportCompleteEvent,
|
||||
TeleportPushingEvent,
|
||||
TeleportPushRequiredEvent,
|
||||
TeleportPushResponseEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
)
|
||||
from vibe.core.types import Role
|
||||
|
||||
TELEPORT_PUSH_OPTION_ID = "teleport_push_and_continue"
|
||||
TELEPORT_CANCEL_OPTION_ID = "teleport_cancel"
|
||||
TELEPORT_FIELD_META_KEY = "teleport"
|
||||
type TeleportAcpStatus = Literal[
|
||||
"starting",
|
||||
"preparing_workspace",
|
||||
"push_required",
|
||||
"syncing_remote",
|
||||
"starting_workflow",
|
||||
"completed",
|
||||
"failed",
|
||||
"no_history",
|
||||
"unavailable",
|
||||
]
|
||||
|
||||
|
||||
def _teleport_field_meta(
|
||||
status: TeleportAcpStatus,
|
||||
*,
|
||||
url: str | None = None,
|
||||
unpushed_count: int | None = None,
|
||||
branch_not_pushed: bool | None = None,
|
||||
) -> dict[str, object]:
|
||||
teleport_meta: dict[str, object] = {"status": status}
|
||||
if url is not None:
|
||||
teleport_meta["url"] = url
|
||||
if unpushed_count is not None:
|
||||
teleport_meta["unpushedCount"] = unpushed_count
|
||||
if branch_not_pushed is not None:
|
||||
teleport_meta["branchNotPushed"] = branch_not_pushed
|
||||
return {"tool_name": "teleport", TELEPORT_FIELD_META_KEY: teleport_meta}
|
||||
|
||||
|
||||
def _teleport_progress_update(
|
||||
tool_call_id: str,
|
||||
*,
|
||||
title: str,
|
||||
status: ToolCallStatus = "in_progress",
|
||||
text: str | None = None,
|
||||
raw_output: str | None = None,
|
||||
url: str | None = None,
|
||||
teleport_status: TeleportAcpStatus,
|
||||
unpushed_count: int | None = None,
|
||||
branch_not_pushed: bool | None = None,
|
||||
) -> ToolCallProgress:
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=tool_call_id,
|
||||
title=title,
|
||||
kind="other",
|
||||
status=status,
|
||||
raw_output=raw_output,
|
||||
content=(
|
||||
[
|
||||
ContentToolCallContent(
|
||||
type="content", content=TextContentBlock(type="text", text=text)
|
||||
)
|
||||
]
|
||||
if text
|
||||
else None
|
||||
),
|
||||
field_meta=_teleport_field_meta(
|
||||
teleport_status,
|
||||
url=url,
|
||||
unpushed_count=unpushed_count,
|
||||
branch_not_pushed=branch_not_pushed,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _teleport_command_reply(
|
||||
client: Client,
|
||||
session: AcpSessionLoop,
|
||||
text: str,
|
||||
message_id: str,
|
||||
*,
|
||||
field_meta: dict[str, object],
|
||||
) -> PromptResponse:
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=text),
|
||||
message_id=str(uuid4()),
|
||||
field_meta=field_meta,
|
||||
),
|
||||
)
|
||||
return PromptResponse(
|
||||
stop_reason="end_turn", user_message_id=message_id, field_meta=field_meta
|
||||
)
|
||||
|
||||
|
||||
async def _request_teleport_push_approval(
|
||||
client: Client,
|
||||
session: AcpSessionLoop,
|
||||
tool_call_id: str,
|
||||
*,
|
||||
count: int,
|
||||
branch_not_pushed: bool,
|
||||
) -> TeleportPushResponseEvent:
|
||||
if branch_not_pushed:
|
||||
question = "Your branch doesn't exist on remote. Push to continue?"
|
||||
else:
|
||||
word = f"commit{'s' if count != 1 else ''}"
|
||||
question = f"You have {count} unpushed {word}. Push to continue?"
|
||||
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=_teleport_progress_update(
|
||||
tool_call_id,
|
||||
title="Push required",
|
||||
text=question,
|
||||
teleport_status="push_required",
|
||||
unpushed_count=count,
|
||||
branch_not_pushed=branch_not_pushed,
|
||||
),
|
||||
)
|
||||
|
||||
response = await client.request_permission(
|
||||
session_id=session.id,
|
||||
tool_call=ToolCallUpdate(
|
||||
tool_call_id=tool_call_id,
|
||||
title=question,
|
||||
kind="execute",
|
||||
status="pending",
|
||||
field_meta=_teleport_field_meta(
|
||||
"push_required",
|
||||
unpushed_count=count,
|
||||
branch_not_pushed=branch_not_pushed,
|
||||
),
|
||||
),
|
||||
options=[
|
||||
PermissionOption(
|
||||
option_id=TELEPORT_PUSH_OPTION_ID,
|
||||
name="Push and continue",
|
||||
kind="allow_once",
|
||||
),
|
||||
PermissionOption(
|
||||
option_id=TELEPORT_CANCEL_OPTION_ID, name="Cancel", kind="reject_once"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
if response.outcome.outcome != "selected":
|
||||
return TeleportPushResponseEvent(approved=False)
|
||||
|
||||
outcome = cast(AllowedOutcome, response.outcome)
|
||||
return TeleportPushResponseEvent(
|
||||
approved=outcome.option_id == TELEPORT_PUSH_OPTION_ID
|
||||
)
|
||||
|
||||
|
||||
async def handle_teleport_command(
|
||||
client: Client, session: AcpSessionLoop, message_id: str
|
||||
) -> PromptResponse:
|
||||
if not session.agent_loop.base_config.vibe_code_enabled:
|
||||
return await _teleport_command_reply(
|
||||
client,
|
||||
session,
|
||||
"Teleport is not available because Vibe Code is disabled.",
|
||||
message_id,
|
||||
field_meta=_teleport_field_meta("unavailable"),
|
||||
)
|
||||
|
||||
last_user_message = next(
|
||||
(
|
||||
msg
|
||||
for msg in reversed(session.agent_loop.messages)
|
||||
if msg.role == Role.user and not msg.injected
|
||||
),
|
||||
None,
|
||||
)
|
||||
has_resolvable_prompt = (
|
||||
last_user_message is not None
|
||||
and isinstance(last_user_message.content, str)
|
||||
and bool(last_user_message.content)
|
||||
)
|
||||
if not has_resolvable_prompt:
|
||||
send_teleport_early_failure_telemetry(
|
||||
session.agent_loop.telemetry_client,
|
||||
stage="no_history",
|
||||
error_class="TeleportNoHistoryError",
|
||||
nb_session_messages=len(session.agent_loop.messages[1:]),
|
||||
)
|
||||
return await _teleport_command_reply(
|
||||
client,
|
||||
session,
|
||||
"No conversation history to teleport.",
|
||||
message_id,
|
||||
field_meta=_teleport_field_meta("no_history"),
|
||||
)
|
||||
|
||||
tool_call_id = str(uuid4())
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=ToolCallStart(
|
||||
session_update="tool_call",
|
||||
tool_call_id=tool_call_id,
|
||||
title="Teleporting session to Vibe Code Web...",
|
||||
kind="other",
|
||||
status="in_progress",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text="Preparing workspace..."
|
||||
),
|
||||
)
|
||||
],
|
||||
field_meta=_teleport_field_meta("starting"),
|
||||
),
|
||||
)
|
||||
|
||||
final_url: str | None = None
|
||||
try:
|
||||
async with aclosing(session.agent_loop.teleport_to_vibe_code(None)) as events:
|
||||
response: TeleportPushResponseEvent | None = None
|
||||
while True:
|
||||
try:
|
||||
event = await events.asend(response)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
response = None
|
||||
|
||||
match event:
|
||||
case TeleportCheckingGitEvent():
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=_teleport_progress_update(
|
||||
tool_call_id,
|
||||
title="Preparing workspace...",
|
||||
text="Preparing workspace...",
|
||||
teleport_status="preparing_workspace",
|
||||
),
|
||||
)
|
||||
case TeleportPushRequiredEvent(
|
||||
unpushed_count=count, branch_not_pushed=branch_not_pushed
|
||||
):
|
||||
response = await _request_teleport_push_approval(
|
||||
client,
|
||||
session,
|
||||
tool_call_id,
|
||||
count=count,
|
||||
branch_not_pushed=branch_not_pushed,
|
||||
)
|
||||
case TeleportPushingEvent():
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=_teleport_progress_update(
|
||||
tool_call_id,
|
||||
title="Syncing with remote...",
|
||||
text="Syncing with remote...",
|
||||
teleport_status="syncing_remote",
|
||||
),
|
||||
)
|
||||
case TeleportStartingWorkflowEvent():
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=_teleport_progress_update(
|
||||
tool_call_id,
|
||||
title="Starting Vibe Code Web session...",
|
||||
text="Starting Vibe Code Web session...",
|
||||
teleport_status="starting_workflow",
|
||||
),
|
||||
)
|
||||
case TeleportCompleteEvent(url=url):
|
||||
final_url = url
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=_teleport_progress_update(
|
||||
tool_call_id,
|
||||
title="Teleported to Vibe Code Web",
|
||||
status="completed",
|
||||
text=f"Teleported to Vibe Code Web: {url}",
|
||||
raw_output=url,
|
||||
url=url,
|
||||
teleport_status="completed",
|
||||
),
|
||||
)
|
||||
except TeleportError as e:
|
||||
await client.session_update(
|
||||
session_id=session.id,
|
||||
update=_teleport_progress_update(
|
||||
tool_call_id,
|
||||
title="Teleport failed",
|
||||
status="failed",
|
||||
text=str(e),
|
||||
raw_output=str(e),
|
||||
teleport_status="failed",
|
||||
),
|
||||
)
|
||||
return PromptResponse(
|
||||
stop_reason="end_turn",
|
||||
user_message_id=message_id,
|
||||
field_meta=_teleport_field_meta("failed"),
|
||||
)
|
||||
|
||||
return PromptResponse(
|
||||
stop_reason="end_turn",
|
||||
user_message_id=message_id,
|
||||
field_meta=_teleport_field_meta("completed", url=final_url),
|
||||
)
|
||||
|
|
@ -7,6 +7,7 @@ from acp.schema import (
|
|||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ContentToolCallContent,
|
||||
Implementation,
|
||||
ModelInfo,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
|
|
@ -109,6 +110,10 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def is_jetbrains_client(client_info: Implementation | None) -> bool:
|
||||
return bool(client_info and client_info.name.startswith("JetBrains."))
|
||||
|
||||
|
||||
def build_mode_state(
|
||||
profiles: list[AgentProfile], current_mode_id: str
|
||||
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
|
||||
|
|
|
|||
158
vibe/cli/cli.py
158
vibe/cli/cli.py
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
|
@ -10,10 +11,16 @@ import tomli_w
|
|||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
|
||||
from vibe.cli.update_notifier import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
UpdateCacheRepository,
|
||||
get_pending_update_from_cache,
|
||||
mark_update_as_dismissed,
|
||||
)
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.hooks.config import load_hooks_from_fs
|
||||
from vibe.core.hooks.config import HookConfigResult, load_hooks_from_fs
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
|
|
@ -26,6 +33,7 @@ from vibe.core.trusted_folders import find_trustable_files, trusted_folders_mana
|
|||
from vibe.core.types import LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
from vibe.setup.update_prompt import UpdatePromptResult, ask_update_prompt
|
||||
|
||||
|
||||
def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
|
||||
|
|
@ -183,6 +191,94 @@ def _resume_previous_session(
|
|||
)
|
||||
|
||||
|
||||
def _run_programmatic_mode(
|
||||
args: argparse.Namespace,
|
||||
config: VibeConfig,
|
||||
initial_agent_name: str,
|
||||
hook_config_result: HookConfigResult,
|
||||
loaded_session: tuple[list[LLMMessage], Path] | None,
|
||||
stdin_prompt: str | None,
|
||||
) -> None:
|
||||
warn_if_workdir_trust_is_unset()
|
||||
config.disabled_tools = [
|
||||
*config.disabled_tools,
|
||||
"ask_user_question",
|
||||
"exit_plan_mode",
|
||||
]
|
||||
programmatic_prompt = args.prompt or stdin_prompt
|
||||
if not programmatic_prompt:
|
||||
print("Error: No prompt provided for programmatic mode", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
output_format = OutputFormat(args.output if hasattr(args, "output") else "text")
|
||||
|
||||
try:
|
||||
final_response = run_programmatic(
|
||||
config=config,
|
||||
prompt=programmatic_prompt or "",
|
||||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
max_session_tokens=args.max_tokens,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_session[0] if loaded_session else None,
|
||||
agent_name=initial_agent_name,
|
||||
teleport=args.teleport and config.vibe_code_enabled,
|
||||
headless=True,
|
||||
hook_config_result=hook_config_result,
|
||||
)
|
||||
if final_response:
|
||||
print(final_response)
|
||||
sys.exit(0)
|
||||
except ConversationLimitException as e:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except TeleportError as e:
|
||||
print(f"Teleport error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (RuntimeError, ValueError) as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _maybe_run_startup_update_prompt(
|
||||
config: VibeConfig, repository: UpdateCacheRepository
|
||||
) -> None:
|
||||
if not config.enable_update_checks:
|
||||
return
|
||||
|
||||
try:
|
||||
latest_version = asyncio.run(
|
||||
get_pending_update_from_cache(repository, __version__)
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.debug("Failed to read pending update from cache", exc_info=exc)
|
||||
return
|
||||
|
||||
if latest_version is None:
|
||||
return
|
||||
|
||||
result = ask_update_prompt(__version__, latest_version, theme=config.theme)
|
||||
|
||||
match result:
|
||||
case UpdatePromptResult.CONTINUE:
|
||||
try:
|
||||
asyncio.run(mark_update_as_dismissed(repository, latest_version))
|
||||
except OSError as exc:
|
||||
logger.debug("Failed to persist dismissed update", exc_info=exc)
|
||||
return
|
||||
case UpdatePromptResult.QUIT:
|
||||
sys.exit(0)
|
||||
case UpdatePromptResult.UPDATED:
|
||||
rprint(
|
||||
f"[green]✔ Vibe was updated from {__version__} to "
|
||||
f"{latest_version}.[/]\n Run [bold]vibe[/] to start using the "
|
||||
"new version."
|
||||
)
|
||||
sys.exit(0)
|
||||
case UpdatePromptResult.UPDATE_FAILED:
|
||||
rprint("[red]✗ Vibe could not be updated automatically.[/]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_cli(args: argparse.Namespace) -> None:
|
||||
load_dotenv_values()
|
||||
bootstrap_config_files()
|
||||
|
|
@ -194,6 +290,11 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
try:
|
||||
is_interactive = args.prompt is None
|
||||
config = load_config_or_exit(interactive=is_interactive)
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
|
||||
if is_interactive:
|
||||
_maybe_run_startup_update_prompt(config, update_cache_repository)
|
||||
|
||||
initial_agent_name = get_initial_agent_name(args, config)
|
||||
hook_config_result = load_hooks_from_fs(config)
|
||||
setup_tracing(config)
|
||||
|
|
@ -204,50 +305,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
loaded_session = load_session(args, config)
|
||||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
warn_if_workdir_trust_is_unset()
|
||||
config.disabled_tools = [
|
||||
*config.disabled_tools,
|
||||
"ask_user_question",
|
||||
"exit_plan_mode",
|
||||
]
|
||||
programmatic_prompt = args.prompt or stdin_prompt
|
||||
if not programmatic_prompt:
|
||||
print(
|
||||
"Error: No prompt provided for programmatic mode", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
output_format = OutputFormat(
|
||||
args.output if hasattr(args, "output") else "text"
|
||||
)
|
||||
|
||||
try:
|
||||
final_response = run_programmatic(
|
||||
config=config,
|
||||
prompt=programmatic_prompt or "",
|
||||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
max_session_tokens=args.max_tokens,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_session[0] if loaded_session else None,
|
||||
agent_name=initial_agent_name,
|
||||
teleport=args.teleport and config.vibe_code_enabled,
|
||||
headless=True,
|
||||
hook_config_result=hook_config_result,
|
||||
)
|
||||
if final_response:
|
||||
print(final_response)
|
||||
sys.exit(0)
|
||||
except ConversationLimitException as e:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except TeleportError as e:
|
||||
print(f"Teleport error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (RuntimeError, ValueError) as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
if is_interactive:
|
||||
try:
|
||||
agent_loop = AgentLoop(
|
||||
config,
|
||||
|
|
@ -266,6 +324,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
update_cache_repository=update_cache_repository,
|
||||
startup=StartupOptions(
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
|
|
@ -273,6 +332,15 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
is_resuming_session=loaded_session is not None,
|
||||
),
|
||||
)
|
||||
else:
|
||||
_run_programmatic_mode(
|
||||
args=args,
|
||||
config=config,
|
||||
initial_agent_name=initial_agent_name,
|
||||
hook_config_result=hook_config_result,
|
||||
loaded_session=loaded_session,
|
||||
stdin_prompt=stdin_prompt,
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
rprint("\n[dim]Bye![/]")
|
||||
|
|
|
|||
|
|
@ -112,7 +112,6 @@ from vibe.cli.textual_ui.windowing import (
|
|||
sync_backfill_state,
|
||||
)
|
||||
from vibe.cli.update_notifier import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
PyPIUpdateGateway,
|
||||
UpdateCacheRepository,
|
||||
UpdateError,
|
||||
|
|
@ -122,7 +121,6 @@ from vibe.cli.update_notifier import (
|
|||
mark_version_as_seen,
|
||||
should_show_whats_new,
|
||||
)
|
||||
from vibe.cli.update_notifier.update import do_update
|
||||
from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
|
||||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||
from vibe.cli.vscode_extension_promo import (
|
||||
|
|
@ -3270,48 +3268,19 @@ class VibeApp(App): # noqa: PLR0904
|
|||
asyncio.create_task(self._check_update(), name="version-update-check")
|
||||
|
||||
async def _check_update(self) -> None:
|
||||
try:
|
||||
if self._update_notifier is None or self._update_cache_repository is None:
|
||||
return
|
||||
if self._update_notifier is None or self._update_cache_repository is None:
|
||||
return
|
||||
|
||||
update_availability = await get_update_if_available(
|
||||
try:
|
||||
await get_update_if_available(
|
||||
update_notifier=self._update_notifier,
|
||||
current_version=self._current_version,
|
||||
update_cache_repository=self._update_cache_repository,
|
||||
)
|
||||
except UpdateError as error:
|
||||
self.notify(
|
||||
error.message,
|
||||
title="Update check failed",
|
||||
severity="warning",
|
||||
timeout=10,
|
||||
)
|
||||
return
|
||||
except UpdateError as exc:
|
||||
logger.warning("Update check failed", exc_info=exc)
|
||||
except Exception as exc:
|
||||
logger.debug("Version update check failed", exc_info=exc)
|
||||
return
|
||||
|
||||
if update_availability is None or not update_availability.should_notify:
|
||||
return
|
||||
|
||||
update_message_prefix = (
|
||||
f"{self._current_version} => {update_availability.latest_version}"
|
||||
)
|
||||
|
||||
if self.config.enable_auto_update and await do_update():
|
||||
self.notify(
|
||||
f"{update_message_prefix}\nVibe was updated successfully. Please restart to use the new version.",
|
||||
title="Update successful",
|
||||
severity="information",
|
||||
timeout=float("inf"),
|
||||
)
|
||||
return
|
||||
|
||||
message = f"{update_message_prefix}\nPlease update mistral-vibe with your package manager"
|
||||
|
||||
self.notify(
|
||||
message, title="Update available", severity="information", timeout=10
|
||||
)
|
||||
logger.debug("Update check failed", exc_info=exc)
|
||||
|
||||
def action_copy_selection(self) -> None:
|
||||
copied_text = copy_selection_to_clipboard(self, show_toast=False)
|
||||
|
|
@ -3364,12 +3333,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
|
||||
def run_textual_ui(
|
||||
agent_loop: AgentLoop, startup: StartupOptions | None = None
|
||||
agent_loop: AgentLoop,
|
||||
update_cache_repository: UpdateCacheRepository,
|
||||
startup: StartupOptions | None = None,
|
||||
) -> None:
|
||||
from vibe.cli.stderr_guard import stderr_guard
|
||||
|
||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
|
||||
vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository()
|
||||
vscode_extension_promo = VscodeExtensionPromo(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ from vibe.cli.update_notifier.ports.update_gateway import (
|
|||
from vibe.cli.update_notifier.update import (
|
||||
UpdateAvailability,
|
||||
UpdateError,
|
||||
get_pending_update_from_cache,
|
||||
get_update_if_available,
|
||||
mark_update_as_dismissed,
|
||||
)
|
||||
from vibe.cli.update_notifier.whats_new import (
|
||||
load_whats_new_content,
|
||||
|
|
@ -40,8 +42,10 @@ __all__ = [
|
|||
"UpdateGateway",
|
||||
"UpdateGatewayCause",
|
||||
"UpdateGatewayError",
|
||||
"get_pending_update_from_cache",
|
||||
"get_update_if_available",
|
||||
"load_whats_new_content",
|
||||
"mark_update_as_dismissed",
|
||||
"mark_version_as_seen",
|
||||
"should_show_whats_new",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -19,12 +19,16 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
|||
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
|
||||
self._cache_file = self._base_path / "cache.toml"
|
||||
self._legacy_json = self._base_path / "update_cache.json"
|
||||
self._cached: UpdateCache | None = None
|
||||
self._loaded = False
|
||||
|
||||
async def get(self) -> UpdateCache | None:
|
||||
if self._loaded:
|
||||
return self._cached
|
||||
data = await asyncio.to_thread(self._read_section)
|
||||
if data is None:
|
||||
return None
|
||||
return self._parse(data)
|
||||
self._cached = self._parse(data) if data is not None else None
|
||||
self._loaded = True
|
||||
return self._cached
|
||||
|
||||
async def set(self, update_cache: UpdateCache) -> None:
|
||||
payload: dict[str, str | int] = {
|
||||
|
|
@ -33,7 +37,11 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
|||
}
|
||||
if update_cache.seen_whats_new_version is not None:
|
||||
payload["seen_whats_new_version"] = update_cache.seen_whats_new_version
|
||||
if update_cache.dismissed_version is not None:
|
||||
payload["dismissed_version"] = update_cache.dismissed_version
|
||||
await asyncio.to_thread(write_cache, self._cache_file, _CACHE_SECTION, payload)
|
||||
self._cached = update_cache
|
||||
self._loaded = True
|
||||
|
||||
def _read_section(self) -> dict | None:
|
||||
cache = read_cache(self._cache_file)
|
||||
|
|
@ -58,20 +66,22 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
|||
latest_version = data.get("latest_version")
|
||||
stored_at_timestamp = data.get("stored_at_timestamp")
|
||||
seen_whats_new_version = data.get("seen_whats_new_version")
|
||||
dismissed_version = data.get("dismissed_version")
|
||||
|
||||
if not isinstance(latest_version, str) or not isinstance(
|
||||
stored_at_timestamp, int
|
||||
):
|
||||
return None
|
||||
|
||||
if (
|
||||
not isinstance(seen_whats_new_version, str)
|
||||
and seen_whats_new_version is not None
|
||||
):
|
||||
if not isinstance(seen_whats_new_version, str):
|
||||
seen_whats_new_version = None
|
||||
|
||||
if not isinstance(dismissed_version, str):
|
||||
dismissed_version = None
|
||||
|
||||
return UpdateCache(
|
||||
latest_version=latest_version,
|
||||
stored_at_timestamp=stored_at_timestamp,
|
||||
seen_whats_new_version=seen_whats_new_version,
|
||||
dismissed_version=dismissed_version,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class UpdateCache:
|
|||
latest_version: str
|
||||
stored_at_timestamp: int
|
||||
seen_whats_new_version: str | None = None
|
||||
dismissed_version: str | None = None
|
||||
|
||||
|
||||
class UpdateCacheRepository(Protocol):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, replace
|
||||
import time
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
|
@ -74,18 +75,56 @@ async def _write_update_cache(
|
|||
version: str,
|
||||
get_current_timestamp: Callable[[], int],
|
||||
) -> None:
|
||||
previous = await repository.get()
|
||||
timestamp = get_current_timestamp()
|
||||
if previous is None:
|
||||
await repository.set(
|
||||
UpdateCache(latest_version=version, stored_at_timestamp=timestamp)
|
||||
)
|
||||
return
|
||||
await repository.set(
|
||||
UpdateCache(latest_version=version, stored_at_timestamp=get_current_timestamp())
|
||||
replace(previous, latest_version=version, stored_at_timestamp=timestamp)
|
||||
)
|
||||
|
||||
|
||||
async def get_pending_update_from_cache(
|
||||
repository: UpdateCacheRepository, current_version: str
|
||||
) -> str | None:
|
||||
current = _parse_version(current_version)
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
cache = await repository.get()
|
||||
if cache is None:
|
||||
return None
|
||||
|
||||
latest = _parse_version(cache.latest_version)
|
||||
if latest is None or latest <= current:
|
||||
return None
|
||||
|
||||
if cache.dismissed_version == cache.latest_version:
|
||||
return None
|
||||
|
||||
return cache.latest_version
|
||||
|
||||
|
||||
async def mark_update_as_dismissed(
|
||||
repository: UpdateCacheRepository, version: str
|
||||
) -> None:
|
||||
cache = await repository.get()
|
||||
if cache is None:
|
||||
return
|
||||
await repository.set(replace(cache, dismissed_version=version))
|
||||
|
||||
|
||||
async def get_update_if_available(
|
||||
update_notifier: UpdateGateway,
|
||||
current_version: str,
|
||||
update_cache_repository: UpdateCacheRepository,
|
||||
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
|
||||
) -> UpdateAvailability | None:
|
||||
if not (current := _parse_version(current_version)):
|
||||
current = _parse_version(current_version)
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
if update_cache := await update_cache_repository.get():
|
||||
|
|
@ -133,7 +172,24 @@ async def do_update() -> bool:
|
|||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await process.wait()
|
||||
try:
|
||||
await process.wait()
|
||||
except asyncio.CancelledError:
|
||||
await _terminate(process)
|
||||
raise
|
||||
if process.returncode == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _terminate(process: asyncio.subprocess.Process) -> None:
|
||||
if process.returncode is not None:
|
||||
return
|
||||
with suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
with suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import time
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
|
|
@ -40,11 +41,5 @@ async def mark_version_as_seen(version: str, repository: UpdateCacheRepository)
|
|||
seen_whats_new_version=version,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await repository.set(
|
||||
UpdateCache(
|
||||
latest_version=cache.latest_version,
|
||||
stored_at_timestamp=cache.stored_at_timestamp,
|
||||
seen_whats_new_version=version,
|
||||
)
|
||||
)
|
||||
return
|
||||
await repository.set(replace(cache, seen_whats_new_version=version))
|
||||
|
|
|
|||
|
|
@ -67,7 +67,10 @@ from vibe.core.session.session_logger import SessionLogger
|
|||
from vibe.core.session.session_migration import migrate_sessions_entrypoint
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.system_prompt import get_universal_system_prompt
|
||||
from vibe.core.telemetry.build_metadata import build_request_metadata
|
||||
from vibe.core.telemetry.build_metadata import (
|
||||
build_attachment_counts,
|
||||
build_request_metadata,
|
||||
)
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.telemetry.types import (
|
||||
EntrypointMetadata,
|
||||
|
|
@ -748,6 +751,10 @@ class AgentLoop: # noqa: PLR0904
|
|||
None,
|
||||
)
|
||||
|
||||
def set_max_turns(self, max_turns: int) -> None:
|
||||
self._max_turns = max_turns
|
||||
self._setup_middleware()
|
||||
|
||||
def _setup_middleware(self) -> None:
|
||||
"""Configure middleware pipeline for this conversation."""
|
||||
self.middleware_pipeline.clear()
|
||||
|
|
@ -1373,6 +1380,9 @@ class AgentLoop: # noqa: PLR0904
|
|||
else 0,
|
||||
call_type=backend_metadata.call_type,
|
||||
message_id=backend_metadata.message_id,
|
||||
attachment_counts=build_attachment_counts(
|
||||
last_user_message, supports_images=active_model.supports_images
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -1436,6 +1446,9 @@ class AgentLoop: # noqa: PLR0904
|
|||
else 0,
|
||||
call_type=backend_metadata.call_type,
|
||||
message_id=backend_metadata.message_id,
|
||||
attachment_counts=build_attachment_counts(
|
||||
last_user_message, supports_images=active_model.supports_images
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -531,7 +531,6 @@ class VibeConfig(BaseSettings):
|
|||
include_project_context: bool = True
|
||||
include_prompt_detail: bool = True
|
||||
enable_update_checks: bool = True
|
||||
enable_auto_update: bool = True
|
||||
enable_notifications: bool = True
|
||||
enable_system_trust_store: bool = False
|
||||
api_timeout: float = DEFAULT_API_TIMEOUT
|
||||
|
|
|
|||
|
|
@ -76,8 +76,7 @@ bypass_tool_permissions = false # Skip tool approval prompts
|
|||
system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename
|
||||
compaction_prompt_id = "compact" # Compaction prompt: built-in "compact" or custom .md filename
|
||||
enable_telemetry = true
|
||||
enable_update_checks = true
|
||||
enable_auto_update = true
|
||||
enable_update_checks = true # Daily PyPI check; prompts on next launch when a newer release exists
|
||||
enable_notifications = true
|
||||
enable_system_trust_store = false # Use OS trust store for outbound HTTPS
|
||||
api_timeout = 720.0 # API request timeout in seconds
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ from typing import Any, cast
|
|||
|
||||
from vibe.core.telemetry.types import (
|
||||
AgentEntrypoint,
|
||||
AttachmentKind,
|
||||
EntrypointMetadata,
|
||||
TelemetryBaseMetadata,
|
||||
TelemetryCallType,
|
||||
TelemetryRequestMetadata,
|
||||
)
|
||||
from vibe.core.types import LLMMessage
|
||||
|
||||
|
||||
def build_base_metadata(
|
||||
|
|
@ -52,6 +54,17 @@ def build_request_metadata(
|
|||
)
|
||||
|
||||
|
||||
def build_attachment_counts(
|
||||
message: LLMMessage | None, *, supports_images: bool
|
||||
) -> dict[AttachmentKind, int]:
|
||||
if message is None:
|
||||
return {}
|
||||
counts: dict[AttachmentKind, int] = {}
|
||||
if supports_images and message.images:
|
||||
counts[AttachmentKind.IMAGE] = len(message.images)
|
||||
return counts
|
||||
|
||||
|
||||
def build_entrypoint_metadata(
|
||||
*,
|
||||
agent_entrypoint: AgentEntrypoint,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from vibe.core.logger import logger
|
|||
from vibe.core.telemetry.build_metadata import build_base_metadata
|
||||
from vibe.core.telemetry.types import (
|
||||
AgentEntrypoint,
|
||||
AttachmentKind,
|
||||
EntrypointMetadata,
|
||||
TelemetryCallType,
|
||||
TeleportCompletedPayload,
|
||||
|
|
@ -303,6 +304,7 @@ class TelemetryClient:
|
|||
nb_prompt_chars: int,
|
||||
call_type: TelemetryCallType,
|
||||
message_id: str | None = None,
|
||||
attachment_counts: dict[AttachmentKind, int] | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"model": model,
|
||||
|
|
@ -312,6 +314,11 @@ class TelemetryClient:
|
|||
"call_source": "vibe_code",
|
||||
"call_type": call_type,
|
||||
"message_id": message_id,
|
||||
"attachment_counts": {
|
||||
kind.value: count
|
||||
for kind, count in (attachment_counts or {}).items()
|
||||
if count > 0
|
||||
},
|
||||
}
|
||||
self.send_telemetry_event("vibe.request_sent", payload)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AttachmentKind(StrEnum):
|
||||
IMAGE = "image"
|
||||
|
||||
|
||||
class ClientMetadata(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Use the `bash` tool to run one-off shell commands.
|
|||
- `sed -n '100,200p' filename` → Use `read(file_path="filename", offset=100, limit=101)`
|
||||
- `less`, `more`, `vim`, `nano` → Use `read` with offset/limit for navigation
|
||||
- `echo "content" > file` → Use `write_file(path="file", content="content")`
|
||||
- `echo "content" >> existing_file` → Read first, then use `search_replace` to append (write_file refuses to overwrite)
|
||||
- `echo "content" >> existing_file` → Read first, then use `edit` to append (write_file refuses to overwrite)
|
||||
|
||||
**Search Operations - DO NOT USE:**
|
||||
- `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")`
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Use `write_file` to create a new file.
|
|||
**BEHAVIOR:**
|
||||
|
||||
- `write_file` can ONLY create new files.
|
||||
- If the file already exists, the tool returns an error. Use `search_replace` to edit existing files.
|
||||
- If the file already exists, the tool returns an error. Use `edit` to modify existing files.
|
||||
- Parent directories are created automatically if they don't exist.
|
||||
|
||||
**BEST PRACTICES:**
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class WriteFile(
|
|||
ToolUIData[WriteFileArgs, WriteFileResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Create a UTF-8 file. Fails if the file already exists; use search_replace to edit."
|
||||
"Create a UTF-8 file. Fails if the file already exists; use edit to modify."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -115,7 +115,7 @@ class WriteFile(
|
|||
|
||||
if file_path.exists():
|
||||
raise ToolError(
|
||||
f"File '{file_path}' already exists. Use search_replace to edit it."
|
||||
f"File '{file_path}' already exists. Use edit to modify it."
|
||||
)
|
||||
|
||||
if self.config.create_parent_dirs:
|
||||
|
|
@ -133,7 +133,7 @@ class WriteFile(
|
|||
await f.write(args.content)
|
||||
except FileExistsError as e:
|
||||
raise ToolError(
|
||||
f"File '{file_path}' already exists. Use search_replace to edit it."
|
||||
f"File '{file_path}' already exists. Use edit to modify it."
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error writing {file_path}: {e}") from e
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ class TrustFolderApp(App):
|
|||
self.exit()
|
||||
|
||||
def run_trust_dialog(self) -> TrustDecision | None:
|
||||
self.run()
|
||||
self.run(inline=True)
|
||||
if self._quit_without_saving:
|
||||
raise TrustDialogQuitException()
|
||||
return self._result
|
||||
|
|
|
|||
8
vibe/setup/update_prompt/__init__.py
Normal file
8
vibe/setup/update_prompt/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.setup.update_prompt.update_prompt_dialog import (
|
||||
UpdatePromptResult,
|
||||
ask_update_prompt,
|
||||
)
|
||||
|
||||
__all__ = ["UpdatePromptResult", "ask_update_prompt"]
|
||||
221
vibe/setup/update_prompt/update_prompt_dialog.py
Normal file
221
vibe/setup/update_prompt/update_prompt_dialog.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from enum import StrEnum, auto
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import CenterMiddle, Horizontal
|
||||
from textual.message import Message
|
||||
|
||||
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.update_notifier.update import do_update
|
||||
from vibe.core.logger import logger
|
||||
|
||||
|
||||
class UpdatePromptResult(StrEnum):
|
||||
CONTINUE = auto()
|
||||
UPDATED = auto()
|
||||
UPDATE_FAILED = auto()
|
||||
QUIT = auto()
|
||||
|
||||
|
||||
class UpdateChoice(StrEnum):
|
||||
UPDATE = auto()
|
||||
CONTINUE = auto()
|
||||
|
||||
|
||||
_CHOICE_LABELS: dict[UpdateChoice, str] = {
|
||||
UpdateChoice.UPDATE: "Update now",
|
||||
UpdateChoice.CONTINUE: "Continue with current version",
|
||||
}
|
||||
|
||||
|
||||
class UpdatePromptDialog(CenterMiddle):
|
||||
can_focus = True
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("left", "move_left", "Left", show=False),
|
||||
Binding("right", "move_right", "Right", show=False),
|
||||
Binding("enter", "select", "Select", show=False),
|
||||
]
|
||||
|
||||
class Selected(Message):
|
||||
def __init__(self, choice: UpdateChoice) -> None:
|
||||
super().__init__()
|
||||
self.choice = choice
|
||||
|
||||
class UpdateFinished(Message):
|
||||
def __init__(self, succeeded: bool) -> None:
|
||||
super().__init__()
|
||||
self.succeeded = succeeded
|
||||
|
||||
def __init__(
|
||||
self, current_version: str, latest_version: str, **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.current_version = current_version
|
||||
self.latest_version = latest_version
|
||||
self.selected: UpdateChoice = UpdateChoice.UPDATE
|
||||
self._option_widgets: dict[UpdateChoice, NoMarkupStatic] = {}
|
||||
self._is_updating = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with CenterMiddle(id="update-dialog"):
|
||||
yield NoMarkupStatic(
|
||||
"A new Vibe release is available", id="update-dialog-title"
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
f"{self.current_version} → {self.latest_version}",
|
||||
id="update-dialog-version",
|
||||
)
|
||||
|
||||
with Horizontal(id="update-options-container"):
|
||||
for choice in UpdateChoice:
|
||||
widget = NoMarkupStatic(
|
||||
f" {_CHOICE_LABELS[choice]}", classes="update-option"
|
||||
)
|
||||
self._option_widgets[choice] = widget
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"← → navigate Enter select", classes="update-dialog-help"
|
||||
)
|
||||
|
||||
yield PetitChat(id="update-dialog-spinner")
|
||||
yield NoMarkupStatic("Updating mistral-vibe…", id="update-dialog-status")
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
spinner = self.query_one("#update-dialog-spinner", PetitChat)
|
||||
spinner.display = False
|
||||
status = self.query_one("#update-dialog-status", NoMarkupStatic)
|
||||
status.display = False
|
||||
self._refresh_options()
|
||||
self.focus()
|
||||
|
||||
def _refresh_options(self) -> None:
|
||||
for choice in UpdateChoice:
|
||||
widget = self._option_widgets[choice]
|
||||
cursor = "› " if choice == self.selected else " "
|
||||
widget.update(f"{cursor}{_CHOICE_LABELS[choice]}")
|
||||
widget.remove_class("update-option--active")
|
||||
widget.remove_class("update-option--inactive")
|
||||
widget.add_class(
|
||||
"update-option--active"
|
||||
if choice == self.selected
|
||||
else "update-option--inactive"
|
||||
)
|
||||
|
||||
def _move(self, delta: int) -> None:
|
||||
if self._is_updating:
|
||||
return
|
||||
choices = list(UpdateChoice)
|
||||
idx = (choices.index(self.selected) + delta) % len(choices)
|
||||
self.selected = choices[idx]
|
||||
self._refresh_options()
|
||||
|
||||
def action_move_left(self) -> None:
|
||||
self._move(-1)
|
||||
|
||||
def action_move_right(self) -> None:
|
||||
self._move(1)
|
||||
|
||||
def action_select(self) -> None:
|
||||
if self._is_updating:
|
||||
return
|
||||
self.post_message(self.Selected(self.selected))
|
||||
|
||||
async def enter_updating_state(self) -> None:
|
||||
self._is_updating = True
|
||||
|
||||
for widget in self._option_widgets.values():
|
||||
widget.display = False
|
||||
self.query_one("#update-options-container", Horizontal).display = False
|
||||
self.query_one(".update-dialog-help", NoMarkupStatic).display = False
|
||||
|
||||
self.query_one("#update-dialog-spinner", PetitChat).display = True
|
||||
self.query_one("#update-dialog-status", NoMarkupStatic).display = True
|
||||
|
||||
try:
|
||||
succeeded = await do_update()
|
||||
except Exception as exc:
|
||||
logger.warning("do_update raised unexpectedly", exc_info=exc)
|
||||
succeeded = False
|
||||
self.post_message(self.UpdateFinished(succeeded=succeeded))
|
||||
|
||||
def on_blur(self, _: events.Blur) -> None:
|
||||
if self._is_updating:
|
||||
return
|
||||
self.call_after_refresh(self.focus)
|
||||
|
||||
|
||||
class UpdatePromptApp(App[UpdatePromptResult]):
|
||||
CSS_PATH = "update_prompt_dialog.tcss"
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+q", "quit_prompt", "Quit", show=False, priority=True),
|
||||
Binding("ctrl+c", "quit_prompt", "Quit", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_version: str,
|
||||
latest_version: str,
|
||||
theme: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.current_version = current_version
|
||||
self.latest_version = latest_version
|
||||
self._theme_name = theme
|
||||
self._dialog: UpdatePromptDialog | None = None
|
||||
self._update_task: asyncio.Task[None] | None = None
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._theme_name is not None:
|
||||
self.theme = self._theme_name
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._dialog = UpdatePromptDialog(self.current_version, self.latest_version)
|
||||
yield self._dialog
|
||||
|
||||
async def action_quit_prompt(self) -> None:
|
||||
if self._update_task is not None and not self._update_task.done():
|
||||
self._update_task.cancel()
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await self._update_task
|
||||
self.exit(UpdatePromptResult.QUIT)
|
||||
|
||||
def on_update_prompt_dialog_selected(
|
||||
self, message: UpdatePromptDialog.Selected
|
||||
) -> None:
|
||||
match message.choice:
|
||||
case UpdateChoice.UPDATE:
|
||||
if self._dialog is None or self._update_task is not None:
|
||||
return
|
||||
self._update_task = asyncio.create_task(
|
||||
self._dialog.enter_updating_state()
|
||||
)
|
||||
case UpdateChoice.CONTINUE:
|
||||
self.exit(UpdatePromptResult.CONTINUE)
|
||||
|
||||
def on_update_prompt_dialog_update_finished(
|
||||
self, message: UpdatePromptDialog.UpdateFinished
|
||||
) -> None:
|
||||
self.exit(
|
||||
UpdatePromptResult.UPDATED
|
||||
if message.succeeded
|
||||
else UpdatePromptResult.UPDATE_FAILED
|
||||
)
|
||||
|
||||
|
||||
def ask_update_prompt(
|
||||
current_version: str, latest_version: str, theme: str | None = None
|
||||
) -> UpdatePromptResult:
|
||||
app = UpdatePromptApp(current_version, latest_version, theme=theme)
|
||||
return app.run(inline=True) or UpdatePromptResult.CONTINUE
|
||||
85
vibe/setup/update_prompt/update_prompt_dialog.tcss
Normal file
85
vibe/setup/update_prompt/update_prompt_dialog.tcss
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
Screen {
|
||||
align: center middle;
|
||||
background: transparent 80%;
|
||||
}
|
||||
|
||||
#update-dialog {
|
||||
max-width: 70;
|
||||
border: round $border-blurred;
|
||||
background: transparent;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
padding-left: 5;
|
||||
padding-right: 5;
|
||||
}
|
||||
|
||||
#update-dialog-title {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#update-dialog-version {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#update-options-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align: center middle;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.update-option {
|
||||
height: auto;
|
||||
width: auto;
|
||||
color: $foreground;
|
||||
margin: 0 3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
.update-option--active {
|
||||
color: $foreground;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.update-option--inactive {
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.update-dialog-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
#update-dialog-spinner {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
.petit-chat {
|
||||
color: $foreground;
|
||||
width: 12;
|
||||
}
|
||||
|
||||
#update-dialog-status {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
# What's new in v2.14.0
|
||||
# What's new in v2.14.1
|
||||
|
||||
- **Image attachments**: Drop an image into the chat input — or `@`-mention it — and send it to vision-capable models.
|
||||
- **New read and edit tools**: `read` and `edit` replace `read_file` and `search_replace`. Your config has been migrated to these new tool names.
|
||||
- **Update prompt at startup**: Vibe now asks you to install a pending update before starting the session, instead of just notifying you.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue