Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
This commit is contained in:
Mathias Gesbert 2025-12-12 17:47:20 +01:00 committed by Mathias Gesbert
parent a340a721ea
commit 661588de0c
48 changed files with 1679 additions and 467 deletions

View file

@ -57,7 +57,9 @@ from vibe.core import __version__
from vibe.core.agent import Agent as VibeAgent
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
ToolCallEvent,
@ -211,10 +213,32 @@ class VibeAcpAgent(AcpAgent):
return disabled
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
session = self._get_session(session_id)
def _handle_permission_selection(
option_id: str, tool_name: str
) -> tuple[ApprovalResponse, str | None]:
match option_id:
case ToolOption.ALLOW_ONCE:
return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS:
if tool_name not in session.agent.config.tools:
session.agent.config.tools[tool_name] = BaseToolConfig()
session.agent.config.tools[
tool_name
].permission = ToolPermission.ALWAYS
return (ApprovalResponse.YES, None)
case ToolOption.REJECT_ONCE:
return (
ApprovalResponse.NO,
"User rejected the tool call, provide an alternative plan",
)
case _:
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
async def approval_callback(
tool_name: str, args: dict[str, Any], tool_call_id: str
) -> tuple[str, str | None]:
) -> tuple[ApprovalResponse, str | None]:
# Create the tool call update
tool_call = ToolCall(toolCallId=tool_call_id)
@ -228,10 +252,10 @@ class VibeAcpAgent(AcpAgent):
# Parse the response using isinstance for proper type narrowing
if response.outcome.outcome == "selected":
outcome = cast(AllowedOutcome, response.outcome)
return self._handle_permission_selection(outcome.optionId)
return _handle_permission_selection(outcome.optionId, tool_name)
else:
return (
"n",
ApprovalResponse.NO,
str(
get_user_cancellation_message(
CancellationReason.OPERATION_CANCELLED
@ -241,18 +265,6 @@ class VibeAcpAgent(AcpAgent):
return approval_callback
@staticmethod
def _handle_permission_selection(option_id: str) -> tuple[str, str | None]:
match option_id:
case ToolOption.ALLOW_ONCE:
return ("y", None)
case ToolOption.ALLOW_ALWAYS:
return ("a", None)
case ToolOption.REJECT_ONCE:
return ("n", "User rejected the tool call, provide an alternative plan")
case _:
return ("n", f"Unknown option: {option_id}")
def _get_session(self, session_id: str) -> AcpSession:
if session_id not in self.sessions:
raise RequestError.invalid_params({"session": "Not found"})

View file

@ -1,8 +1,31 @@
from __future__ import annotations
import base64
import os
import pyperclip
from textual.app import App
_PREVIEW_MAX_LENGTH = 40
def _copy_osc52(text: str) -> None:
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
osc52_seq = f"\033]52;c;{encoded}\a"
if os.environ.get("TMUX"):
osc52_seq = f"\033Ptmux;\033{osc52_seq}\033\\"
with open("/dev/tty", "w") as tty:
tty.write(osc52_seq)
tty.flush()
def _shorten_preview(texts: list[str]) -> str:
dense_text = "".join(texts).replace("\n", "")
if len(dense_text) > _PREVIEW_MAX_LENGTH:
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}"
return dense_text
def copy_selection_to_clipboard(app: App) -> None:
selected_texts = []
@ -30,10 +53,21 @@ def copy_selection_to_clipboard(app: App) -> None:
combined_text = "\n".join(selected_texts)
try:
pyperclip.copy(combined_text)
app.notify("Selection added to clipboard", severity="information", timeout=2)
except Exception:
for copy_fn in [_copy_osc52, pyperclip.copy, app.copy_to_clipboard]:
try:
copy_fn(combined_text)
except:
pass
else:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
)
break
else:
app.notify(
"Use Ctrl+c to copy selections in Vibe", severity="warning", timeout=3
"Failed to copy - no clipboard method available",
severity="warning",
timeout=3,
)

View file

@ -7,14 +7,12 @@ from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.config import (
CONFIG_FILE,
HISTORY_FILE,
INSTRUCTIONS_FILE,
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_api_keys_from_env,
)
from vibe.core.config_path import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
from vibe.core.interaction_logger import InteractionLogger
from vibe.core.programmatic import run_programmatic
from vibe.core.types import OutputFormat, ResumeSessionInfo
@ -138,23 +136,23 @@ def main() -> None: # noqa: PLR0912, PLR0915
run_onboarding()
sys.exit(0)
try:
if not CONFIG_FILE.exists():
if not CONFIG_FILE.path.exists():
try:
VibeConfig.save_updates(VibeConfig.create_default())
except Exception as e:
rprint(f"[yellow]Could not create default config file: {e}[/]")
if not INSTRUCTIONS_FILE.exists():
if not INSTRUCTIONS_FILE.path.exists():
try:
INSTRUCTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
INSTRUCTIONS_FILE.touch()
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
INSTRUCTIONS_FILE.path.touch()
except Exception as e:
rprint(f"[yellow]Could not create instructions file: {e}[/]")
if not HISTORY_FILE.exists():
if not HISTORY_FILE.path.exists():
try:
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.write_text("Hello Vibe!\n", "utf-8")
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
except Exception as e:
rprint(f"[yellow]Could not create history file: {e}[/]")

View file

@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
import os
import subprocess
from typing import Any, ClassVar, assert_never
@ -35,20 +34,22 @@ from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
from vibe.cli.update_notifier import (
GitHubVersionUpdateGateway,
VersionUpdate,
FileSystemUpdateCacheRepository,
PyPIVersionUpdateGateway,
UpdateCacheRepository,
VersionUpdateAvailability,
VersionUpdateError,
is_version_update_available,
VersionUpdateGateway,
get_update_if_available,
)
from vibe.cli.update_notifier.version_update_gateway import VersionUpdateGateway
from vibe.core import __version__ as CORE_VERSION
from vibe.core.agent import Agent
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import HISTORY_FILE, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.config_path import HISTORY_FILE
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import LLMMessage, ResumeSessionInfo, Role
from vibe.core.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role
from vibe.core.utils import (
ApprovalResponse,
CancellationReason,
get_user_cancellation_message,
is_dangerous_directory,
@ -72,6 +73,10 @@ class VibeApp(App):
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
Binding("shift+up", "scroll_chat_up", "Scroll Up", show=False, priority=True),
Binding(
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
),
]
def __init__(
@ -83,6 +88,7 @@ class VibeApp(App):
loaded_messages: list[LLMMessage] | None = None,
session_info: ResumeSessionInfo | None = None,
version_update_notifier: VersionUpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
**kwargs: Any,
) -> None:
@ -108,12 +114,13 @@ class VibeApp(App):
self._current_bottom_app: BottomApp = BottomApp.Input
self.theme = config.textual_theme
self.history_file = HISTORY_FILE
self.history_file = HISTORY_FILE.path
self._tools_collapsed = True
self._todos_collapsed = False
self._current_streaming_message: AssistantMessage | None = None
self._version_update_notifier = version_update_notifier
self._update_cache_repository = update_cache_repository
self._is_update_check_enabled = config.enable_update_checks
self._current_version = current_version
self._update_notification_task: asyncio.Task | None = None
@ -126,6 +133,7 @@ class VibeApp(App):
# prevent a race condition where the agent initialization
# completes exactly at the moment the user interrupts
self._agent_init_interrupted = False
self._auto_scroll = True
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
@ -460,7 +468,7 @@ class VibeApp(App):
async def _approval_callback(
self, tool: str, args: dict, tool_call_id: str
) -> tuple[str, str | None]:
) -> tuple[ApprovalResponse, str | None]:
self._pending_approval = asyncio.Future()
await self._switch_to_approval_app(tool, args)
result = await self._pending_approval
@ -955,6 +963,23 @@ class VibeApp(App):
self.exit()
def action_scroll_chat_up(self) -> None:
try:
chat = self.query_one("#chat", VerticalScroll)
chat.scroll_relative(y=-5, animate=False)
self._auto_scroll = False
except Exception:
pass
def action_scroll_chat_down(self) -> None:
try:
chat = self.query_one("#chat", VerticalScroll)
chat.scroll_relative(y=5, animate=False)
if self._is_scrolled_to_bottom(chat):
self._auto_scroll = True
except Exception:
pass
async def _show_dangerous_directory_warning(self) -> None:
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:
@ -975,6 +1000,9 @@ class VibeApp(App):
chat = self.query_one("#chat", VerticalScroll)
was_at_bottom = self._is_scrolled_to_bottom(chat)
if was_at_bottom:
self._auto_scroll = True
if isinstance(widget, AssistantMessage):
if self._current_streaming_message is not None:
content = widget._content or ""
@ -1014,6 +1042,8 @@ class VibeApp(App):
self.call_after_refresh(self._scroll_to_bottom)
def _anchor_if_scrollable(self) -> None:
if not self._auto_scroll:
return
try:
chat = self.query_one("#chat", VerticalScroll)
if chat.max_scroll_y == 0:
@ -1036,11 +1066,16 @@ class VibeApp(App):
async def _check_version_update(self) -> None:
try:
if self._version_update_notifier is None:
if (
self._version_update_notifier is None
or self._update_cache_repository is None
):
return
update = await is_version_update_available(
self._version_update_notifier, current_version=self._current_version
update = await get_update_if_available(
version_update_notifier=self._version_update_notifier,
current_version=self._current_version,
update_cache_repository=self._update_cache_repository,
)
except VersionUpdateError as error:
self.notify(
@ -1056,12 +1091,12 @@ class VibeApp(App):
finally:
self._update_notification_task = None
if update is None:
if update is None or not update.should_notify:
return
self._display_update_notification(update)
def _display_update_notification(self, update: VersionUpdate) -> None:
def _display_update_notification(self, update: VersionUpdateAvailability) -> None:
if self._update_notification_shown:
return
@ -1084,9 +1119,8 @@ def run_textual_ui(
loaded_messages: list[LLMMessage] | None = None,
session_info: ResumeSessionInfo | None = None,
) -> None:
update_notifier = GitHubVersionUpdateGateway(
owner="mistralai", repository="mistral-vibe", token=os.getenv("GITHUB_TOKEN")
)
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
app = VibeApp(
config=config,
auto_approve=auto_approve,
@ -1095,5 +1129,6 @@ def run_textual_ui(
loaded_messages=loaded_messages,
session_info=session_info,
version_update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
)
app.run()

View file

@ -1,31 +1,43 @@
from __future__ import annotations
from vibe.cli.update_notifier.fake_version_update_gateway import (
FakeVersionUpdateGateway,
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
FileSystemUpdateCacheRepository,
)
from vibe.cli.update_notifier.github_version_update_gateway import (
from vibe.cli.update_notifier.adapters.github_version_update_gateway import (
GitHubVersionUpdateGateway,
)
from vibe.cli.update_notifier.version_update import (
VersionUpdateError,
is_version_update_available,
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
PyPIVersionUpdateGateway,
)
from vibe.cli.update_notifier.version_update_gateway import (
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.cli.update_notifier.ports.version_update_gateway import (
DEFAULT_GATEWAY_MESSAGES,
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
from vibe.cli.update_notifier.version_update import (
VersionUpdateAvailability,
VersionUpdateError,
get_update_if_available,
)
__all__ = [
"DEFAULT_GATEWAY_MESSAGES",
"FakeVersionUpdateGateway",
"FileSystemUpdateCacheRepository",
"GitHubVersionUpdateGateway",
"PyPIVersionUpdateGateway",
"UpdateCache",
"UpdateCacheRepository",
"VersionUpdate",
"VersionUpdateAvailability",
"VersionUpdateError",
"VersionUpdateGateway",
"VersionUpdateGatewayCause",
"VersionUpdateGatewayError",
"is_version_update_available",
"get_update_if_available",
]

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.core.config_path import VIBE_HOME
class FileSystemUpdateCacheRepository(UpdateCacheRepository):
def __init__(self, base_path: Path | str | None = None) -> None:
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
self._cache_file = self._base_path / "update_cache.json"
async def get(self) -> UpdateCache | None:
try:
content = await asyncio.to_thread(self._cache_file.read_text)
except OSError:
return None
try:
data = json.loads(content)
latest_version = data.get("latest_version")
stored_at_timestamp = data.get("stored_at_timestamp")
except (TypeError, json.JSONDecodeError):
return None
if not isinstance(latest_version, str) or not isinstance(
stored_at_timestamp, int
):
return None
return UpdateCache(
latest_version=latest_version, stored_at_timestamp=stored_at_timestamp
)
async def set(self, update_cache: UpdateCache) -> None:
try:
payload = json.dumps({
"latest_version": update_cache.latest_version,
"stored_at_timestamp": update_cache.stored_at_timestamp,
})
await asyncio.to_thread(self._cache_file.write_text, payload)
except OSError:
return None

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import httpx
from vibe.cli.update_notifier.version_update_gateway import (
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,

View file

@ -0,0 +1,113 @@
from __future__ import annotations
import httpx
from packaging.utils import parse_sdist_filename, parse_wheel_filename
from packaging.version import InvalidVersion, Version
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
_STATUS_CAUSES: dict[int, VersionUpdateGatewayCause] = {
httpx.codes.NOT_FOUND: VersionUpdateGatewayCause.NOT_FOUND,
httpx.codes.FORBIDDEN: VersionUpdateGatewayCause.FORBIDDEN,
httpx.codes.TOO_MANY_REQUESTS: VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
}
class PyPIVersionUpdateGateway(VersionUpdateGateway):
def __init__(
self,
project_name: str,
*,
client: httpx.AsyncClient | None = None,
timeout: float = 5.0,
base_url: str = "https://pypi.org",
) -> None:
self._project_name = project_name
self._client = client
self._timeout = timeout
self._base_url = base_url.rstrip("/")
async def fetch_update(self) -> VersionUpdate | None:
response = await self._fetch()
self._raise_gateway_error_if_any(response)
try:
data = response.json()
except ValueError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
) from exc
versions = data.get("versions") or []
files = data.get("files") or []
non_yanked_versions: set[Version] = set()
for file in files:
if not isinstance(file, dict) or file.get("yanked") is True:
continue
filename = file.get("filename")
if not isinstance(filename, str):
continue
parsed_version = _parse_filename_version(filename)
if parsed_version is not None:
non_yanked_versions.add(parsed_version)
valid_versions: list[Version] = []
for raw_version in versions:
try:
valid_versions.append(Version(str(raw_version)))
except InvalidVersion:
continue
for version in sorted(valid_versions, reverse=True):
if version in non_yanked_versions:
return VersionUpdate(latest_version=str(version))
return None
async def _fetch(self) -> httpx.Response:
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
request_path = f"/simple/{self._project_name}/"
try:
if self._client is not None:
return await self._client.get(
f"{self._base_url}{request_path}",
headers=headers,
timeout=self._timeout,
)
async with httpx.AsyncClient(
base_url=self._base_url, timeout=self._timeout
) as client:
return await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.REQUEST_FAILED
) from exc
def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
if response.status_code in _STATUS_CAUSES:
raise VersionUpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
if response.is_error:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.ERROR_RESPONSE
)
def _parse_filename_version(filename: str) -> Version | None:
try:
_, version, *_ = parse_wheel_filename(filename)
return Version(str(version))
except Exception:
try:
_, sdist_version = parse_sdist_filename(filename)
return Version(str(sdist_version))
except Exception:
return None

View file

@ -1,24 +0,0 @@
from __future__ import annotations
from vibe.cli.update_notifier.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayError,
)
class FakeVersionUpdateGateway(VersionUpdateGateway):
def __init__(
self,
update: VersionUpdate | None = None,
error: VersionUpdateGatewayError | None = None,
) -> None:
self._update: VersionUpdate | None = update
self._error = error
self.fetch_update_calls = 0
async def fetch_update(self) -> VersionUpdate | None:
self.fetch_update_calls += 1
if self._error is not None:
raise self._error
return self._update

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True, slots=True)
class UpdateCache:
latest_version: str
stored_at_timestamp: int
class UpdateCacheRepository(Protocol):
async def get(self) -> UpdateCache | None: ...
async def set(self, update_cache: UpdateCache) -> None: ...

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol, runtime_checkable
from typing import Protocol
@dataclass(frozen=True, slots=True)
@ -49,6 +49,5 @@ class VersionUpdateGatewayError(Exception):
super().__init__(detail)
@runtime_checkable
class VersionUpdateGateway(Protocol):
async def fetch_update(self) -> VersionUpdate | None: ...

View file

@ -1,15 +1,28 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import time
from packaging.version import InvalidVersion, Version
from vibe.cli.update_notifier.version_update_gateway import (
from vibe.cli.update_notifier import (
DEFAULT_GATEWAY_MESSAGES,
VersionUpdate,
UpdateCache,
UpdateCacheRepository,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
UPDATE_CACHE_TTL_SECONDS = 24 * 60 * 60
@dataclass(frozen=True, slots=True)
class VersionUpdateAvailability:
latest_version: str
should_notify: bool
class VersionUpdateError(Exception):
def __init__(self, message: str) -> None:
@ -37,21 +50,76 @@ def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
async def is_version_update_available(
version_update_notifier: VersionUpdateGateway, current_version: str
) -> VersionUpdate | None:
def _is_cache_fresh(
cache: UpdateCache, get_current_timestamp: Callable[[], int]
) -> bool:
return (
cache.stored_at_timestamp > get_current_timestamp() - UPDATE_CACHE_TTL_SECONDS
)
def _get_cached_update_if_any(
cache: UpdateCache, current: Version
) -> VersionUpdateAvailability | None:
latest_version_in_cache = _parse_version(cache.latest_version)
if latest_version_in_cache is None or latest_version_in_cache <= current:
return None
return VersionUpdateAvailability(
latest_version=cache.latest_version, should_notify=False
)
async def _write_update_cache(
repository: UpdateCacheRepository,
version: str,
get_current_timestamp: Callable[[], int],
) -> None:
await repository.set(
UpdateCache(latest_version=version, stored_at_timestamp=get_current_timestamp())
)
async def get_update_if_available(
version_update_notifier: VersionUpdateGateway,
current_version: str,
update_cache_repository: UpdateCacheRepository,
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
) -> VersionUpdateAvailability | None:
if not (current := _parse_version(current_version)):
return None
if update_cache := await update_cache_repository.get():
if _is_cache_fresh(update_cache, get_current_timestamp):
return _get_cached_update_if_any(update_cache, current)
try:
update = await version_update_notifier.fetch_update()
except VersionUpdateGatewayError as error:
await _write_update_cache(
update_cache_repository, current_version, get_current_timestamp
)
raise VersionUpdateError(_describe_gateway_error(error)) from error
if not update:
await _write_update_cache(
update_cache_repository, current_version, get_current_timestamp
)
return None
latest_version = _parse_version(update.latest_version)
current = _parse_version(current_version)
if latest_version is None or current is None:
if not (latest_version := _parse_version(update.latest_version)):
return None
return update if latest_version > current else None
if latest_version <= current:
await _write_update_cache(
update_cache_repository, current_version, get_current_timestamp
)
return None
await _write_update_cache(
update_cache_repository, update.latest_version, get_current_timestamp
)
return VersionUpdateAvailability(
latest_version=update.latest_version, should_notify=True
)

View file

@ -1,6 +1,6 @@
from __future__ import annotations
__all__ = ["__version__", "run_programmatic"]
__version__ = "1.1.2"
__version__ = "1.1.3"
from vibe.core.programmatic import run_programmatic

View file

@ -38,7 +38,9 @@ from vibe.core.tools.manager import ToolManager
from vibe.core.types import (
AgentStats,
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
BaseEvent,
CompactEndEvent,
CompactStartEvent,
@ -53,7 +55,6 @@ from vibe.core.types import (
from vibe.core.utils import (
TOOL_ERROR_TAG,
VIBE_STOP_EVENT_TAG,
ApprovalResponse,
CancellationReason,
get_user_agent,
get_user_cancellation_message,
@ -188,11 +189,6 @@ class Agent:
case MiddlewareAction.STOP:
yield AssistantEvent(
content=f"<{VIBE_STOP_EVENT_TAG}>{result.reason}</{VIBE_STOP_EVENT_TAG}>",
prompt_tokens=0,
completion_tokens=0,
session_total_tokens=self.stats.session_total_llm_tokens,
last_turn_duration=0,
tokens_per_second=0,
stopped_by_middleware=True,
)
await self.interaction_logger.save_interaction(
@ -337,16 +333,7 @@ class Agent:
def _create_assistant_event(
self, content: str, chunk: LLMChunk | None
) -> AssistantEvent:
return AssistantEvent(
content=content,
prompt_tokens=chunk.usage.prompt_tokens if chunk and chunk.usage else 0,
completion_tokens=chunk.usage.completion_tokens
if chunk and chunk.usage
else 0,
session_total_tokens=self.stats.session_total_llm_tokens,
last_turn_duration=self.stats.last_turn_duration,
tokens_per_second=self.stats.tokens_per_second,
)
return AssistantEvent(content=content)
async def _stream_assistant_events(self) -> AsyncGenerator[AssistantEvent]:
chunks: list[LLMChunk] = []
@ -421,14 +408,7 @@ class Agent:
assistant_msg = llm_result.message
self.messages.append(assistant_msg)
return AssistantEvent(
content=assistant_msg.content or "",
prompt_tokens=llm_result.usage.prompt_tokens,
completion_tokens=llm_result.usage.completion_tokens,
session_total_tokens=self.stats.session_total_llm_tokens,
last_turn_duration=self.stats.last_turn_duration,
tokens_per_second=self.stats.tokens_per_second,
)
return AssistantEvent(content=assistant_msg.content or "")
async def _handle_tool_calls( # noqa: PLR0915
self, resolved: ResolvedMessage
@ -761,24 +741,18 @@ class Agent:
feedback="Tool execution not permitted.",
)
if asyncio.iscoroutinefunction(self.approval_callback):
response, feedback = await self.approval_callback(
tool_name, args, tool_call_id
)
async_callback = cast(AsyncApprovalCallback, self.approval_callback)
response, feedback = await async_callback(tool_name, args, tool_call_id)
else:
sync_callback = cast(SyncApprovalCallback, self.approval_callback)
response, feedback = sync_callback(tool_name, args, tool_call_id)
match response:
case ApprovalResponse.ALWAYS:
self.auto_approve = True
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
)
case ApprovalResponse.YES:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
)
case _:
case ApprovalResponse.NO:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP, feedback=feedback
)

View file

@ -19,45 +19,28 @@ from pydantic_settings import (
)
import tomli_w
from vibe.core.config_path import (
AGENT_DIR,
CONFIG_DIR,
CONFIG_FILE,
GLOBAL_ENV_FILE,
PROMPT_DIR,
SESSION_LOG_DIR,
)
from vibe.core.prompts import SystemPrompt
from vibe.core.tools.base import BaseToolConfig
def get_vibe_home() -> Path:
if vibe_home := os.getenv("VIBE_HOME"):
return Path(vibe_home).expanduser().resolve()
return Path.home() / ".vibe"
GLOBAL_CONFIG_DIR = get_vibe_home()
GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.toml"
GLOBAL_ENV_FILE = GLOBAL_CONFIG_DIR / ".env"
def resolve_config_file() -> Path:
for directory in (cwd := Path.cwd(), *cwd.parents):
if (candidate := directory / ".vibe" / "config.toml").is_file():
return candidate
return GLOBAL_CONFIG_FILE
PROJECT_DOC_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
def load_api_keys_from_env() -> None:
if GLOBAL_ENV_FILE.is_file():
env_vars = dotenv_values(GLOBAL_ENV_FILE)
if GLOBAL_ENV_FILE.path.is_file():
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
for key, value in env_vars.items():
if value:
os.environ.setdefault(key, value)
CONFIG_FILE = resolve_config_file()
CONFIG_DIR = CONFIG_FILE.parent
AGENT_DIR = CONFIG_DIR / "agents"
PROMPT_DIR = CONFIG_DIR / "prompts"
INSTRUCTIONS_FILE = CONFIG_DIR / "instructions.md"
HISTORY_FILE = CONFIG_DIR / "vibehistory"
PROJECT_DOC_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
class MissingAPIKeyError(RuntimeError):
def __init__(self, env_key: str, provider_name: str) -> None:
super().__init__(
@ -94,7 +77,7 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource):
self.toml_data = self._load_toml()
def _load_toml(self) -> dict[str, Any]:
file = CONFIG_FILE
file = CONFIG_FILE.path
try:
with file.open("rb") as f:
return tomllib.load(f)
@ -134,7 +117,7 @@ class SessionLoggingConfig(BaseSettings):
@classmethod
def set_default_save_dir(cls, v: str) -> str:
if not v:
return str(get_vibe_home() / "logs" / "session")
return str(SESSION_LOG_DIR.path)
return v
@field_validator("save_dir", mode="after")
@ -350,7 +333,7 @@ class VibeConfig(BaseSettings):
)
model_config = SettingsConfigDict(
env_prefix="VIBE_", case_sensitive=False, extra="forbid"
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
@property
@ -364,9 +347,9 @@ class VibeConfig(BaseSettings):
except KeyError:
pass
custom_sp_path = (PROMPT_DIR / self.system_prompt_id).with_suffix(".md")
custom_sp_path = (PROMPT_DIR.path / self.system_prompt_id).with_suffix(".md")
if not custom_sp_path.is_file():
raise MissingPromptFileError(self.system_prompt_id, str(PROMPT_DIR))
raise MissingPromptFileError(self.system_prompt_id, str(PROMPT_DIR.path))
return custom_sp_path.read_text()
def get_active_model(self) -> ModelConfig:
@ -492,7 +475,7 @@ class VibeConfig(BaseSettings):
@classmethod
def save_updates(cls, updates: dict[str, Any]) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_DIR.path.mkdir(parents=True, exist_ok=True)
current_config = TomlFileSettingsSource(cls).toml_data
def deep_merge(target: dict, source: dict) -> None:
@ -522,7 +505,7 @@ class VibeConfig(BaseSettings):
@classmethod
def dump_config(cls, config: dict[str, Any]) -> None:
with CONFIG_FILE.open("wb") as f:
with CONFIG_FILE.path.open("wb") as f:
tomli_w.dump(config, f)
@classmethod
@ -530,21 +513,21 @@ class VibeConfig(BaseSettings):
if agent is None:
return None
agent_config_path = (AGENT_DIR / agent).with_suffix(".toml")
agent_config_path = (AGENT_DIR.path / agent).with_suffix(".toml")
try:
return tomllib.load(agent_config_path.open("rb"))
except FileNotFoundError:
raise ValueError(
f"Config '{agent}.toml' for agent not found in {AGENT_DIR}"
f"Config '{agent}.toml' for agent not found in {AGENT_DIR.path}"
)
@classmethod
def _migrate(cls) -> None:
if not CONFIG_FILE.exists():
if not CONFIG_FILE.path.is_file():
return
try:
with CONFIG_FILE.open("rb") as f:
with CONFIG_FILE.path.open("rb") as f:
config = tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return

51
vibe/core/config_path.py Normal file
View file

@ -0,0 +1,51 @@
from __future__ import annotations
from collections.abc import Callable
import os
from pathlib import Path
class ConfigPath:
def __init__(self, path_resolver: Callable[[], Path]) -> None:
self._path_resolver = path_resolver
@property
def path(self) -> Path:
return self._path_resolver()
_DEFAULT_VIBE_HOME = Path.home() / ".vibe"
def _get_vibe_home() -> Path:
if vibe_home := os.getenv("VIBE_HOME"):
return Path(vibe_home).expanduser().resolve()
return _DEFAULT_VIBE_HOME
def _resolve_config_file() -> Path:
if (candidate := Path.cwd() / ".vibe" / "config.toml").is_file():
return candidate
return _get_vibe_home() / "config.toml"
def resolve_local_tools_dir(dir: Path) -> Path | None:
if (candidate := dir / ".vibe" / "tools").is_dir():
return candidate
return None
VIBE_HOME = ConfigPath(_get_vibe_home)
GLOBAL_CONFIG_FILE = ConfigPath(lambda: VIBE_HOME.path / "config.toml")
GLOBAL_ENV_FILE = ConfigPath(lambda: VIBE_HOME.path / ".env")
GLOBAL_TOOLS_DIR = ConfigPath(lambda: VIBE_HOME.path / "tools")
SESSION_LOG_DIR = ConfigPath(lambda: VIBE_HOME.path / "logs" / "session")
CONFIG_FILE = ConfigPath(_resolve_config_file)
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
LOG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "logs")
AGENT_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "agents")
PROMPT_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "prompts")
INSTRUCTIONS_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "instructions.md")
HISTORY_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "vibehistory")
LOG_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "vibe.log")

View file

@ -124,8 +124,10 @@ class OpenAIAdapter(APIAdapter):
if enable_streaming:
payload["stream"] = True
stream_options = {"include_usage": True}
if provider.name == "mistral":
payload["stream_options"] = {"stream_tool_calls": True}
stream_options["stream_tool_calls"] = True
payload["stream_options"] = stream_options
headers = self.build_headers(api_key)
@ -141,11 +143,11 @@ class OpenAIAdapter(APIAdapter):
message = LLMMessage.model_validate(data["choices"][0]["delta"])
else:
raise ValueError("Invalid response data")
finish_reason = data["choices"][0]["finish_reason"]
finish_reason = data["choices"][0].get("finish_reason", None)
elif "message" in data:
message = LLMMessage.model_validate(data["message"])
finish_reason = data["finish_reason"]
finish_reason = data["choices"][0].get("finish_reason", None)
elif "delta" in data:
message = LLMMessage.model_validate(data["delta"])
finish_reason = None

View file

@ -9,7 +9,8 @@ import sys
import time
from typing import TYPE_CHECKING
from vibe.core.config import INSTRUCTIONS_FILE, PROJECT_DOC_FILENAMES
from vibe.core.config import PROJECT_DOC_FILENAMES
from vibe.core.config_path import INSTRUCTIONS_FILE
from vibe.core.llm.format import get_active_tool_classes
from vibe.core.prompts import UtilityPrompt
from vibe.core.utils import is_dangerous_directory, is_windows
@ -21,7 +22,7 @@ if TYPE_CHECKING:
def _load_user_instructions() -> str:
try:
return INSTRUCTIONS_FILE.read_text("utf-8", errors="ignore")
return INSTRUCTIONS_FILE.path.read_text("utf-8", errors="ignore")
except (FileNotFoundError, OSError):
return ""

View file

@ -10,7 +10,7 @@ import sys
from typing import TYPE_CHECKING, Any
from vibe import VIBE_ROOT
from vibe.core.config import get_vibe_home
from vibe.core.config_path import GLOBAL_TOOLS_DIR, resolve_local_tools_dir
from vibe.core.tools.base import BaseTool, BaseToolConfig
from vibe.core.tools.mcp import (
RemoteTool,
@ -60,16 +60,11 @@ class ToolManager:
if path.is_dir():
paths.append(path)
cwd = config.effective_workdir
for directory in (cwd, *cwd.parents):
tools_dir = directory / ".vibe" / "tools"
if tools_dir.is_dir():
paths.append(tools_dir)
break
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
paths.append(tools_dir)
global_tools = get_vibe_home() / "tools"
if global_tools.is_dir():
paths.append(global_tools)
if GLOBAL_TOOLS_DIR.path.is_dir():
paths.append(GLOBAL_TOOLS_DIR.path)
unique: list[Path] = []
seen: set[Path] = set()

View file

@ -173,6 +173,11 @@ class Role(StrEnum):
tool = auto()
class ApprovalResponse(StrEnum):
YES = "y"
NO = "n"
class LLMMessage(BaseModel):
model_config = ConfigDict(extra="ignore")
@ -219,11 +224,6 @@ class BaseEvent(BaseModel, ABC):
class AssistantEvent(BaseEvent):
content: str
prompt_tokens: int = 0
completion_tokens: int = 0
session_total_tokens: int = 0
last_turn_duration: float = 0.0
tokens_per_second: float = 0.0
stopped_by_middleware: bool = False
@ -263,9 +263,11 @@ class OutputFormat(StrEnum):
type AsyncApprovalCallback = Callable[
[str, dict[str, Any], str], Awaitable[tuple[str, str | None]]
[str, dict[str, Any], str], Awaitable[tuple[ApprovalResponse, str | None]]
]
type SyncApprovalCallback = Callable[[str, dict[str, Any], str], tuple[str, str | None]]
type SyncApprovalCallback = Callable[
[str, dict[str, Any], str], tuple[ApprovalResponse, str | None]
]
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
import concurrent.futures
from enum import Enum, StrEnum, auto
from enum import Enum, auto
import functools
import logging
from pathlib import Path
@ -14,16 +14,10 @@ from typing import Any
import httpx
from vibe.core import __version__
from vibe.core.config import CONFIG_DIR, CONFIG_FILE, GLOBAL_CONFIG_FILE, Backend
from vibe.core.config import Backend
from vibe.core.config_path import CONFIG_FILE, GLOBAL_CONFIG_FILE, LOG_DIR, LOG_FILE
from vibe.core.types import BaseEvent, ToolResultEvent
class ApprovalResponse(StrEnum):
YES = "y"
NO = "n"
ALWAYS = "a"
CANCELLATION_TAG = "user_cancellation"
TOOL_ERROR_TAG = "tool_error"
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
@ -141,22 +135,21 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
return False, ""
LOG_DIR = CONFIG_DIR
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / "vibe.log"
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.FileHandler(LOG_FILE, "a", "utf-8")],
handlers=[logging.FileHandler(LOG_FILE.path, "a", "utf-8")],
)
logger = logging.getLogger("vibe")
logger.info("Using config: %s", CONFIG_FILE)
if CONFIG_FILE != GLOBAL_CONFIG_FILE and GLOBAL_CONFIG_FILE.is_file():
logger.info("Using config: %s", CONFIG_FILE.path)
if CONFIG_FILE.path != GLOBAL_CONFIG_FILE.path and GLOBAL_CONFIG_FILE.path.is_file():
logger.warning(
"Project config active (%s); ignoring global config (%s)",
CONFIG_FILE,
GLOBAL_CONFIG_FILE,
CONFIG_FILE.path,
GLOBAL_CONFIG_FILE.path,
)

View file

@ -5,7 +5,7 @@ import sys
from rich import print as rprint
from textual.app import App
from vibe.core.config import GLOBAL_ENV_FILE
from vibe.core.config_path import GLOBAL_ENV_FILE
from vibe.setup.onboarding.screens import (
ApiKeyScreen,
ThemeSelectionScreen,
@ -34,7 +34,7 @@ def run_onboarding(app: App | None = None) -> None:
rprint(
f"\n[yellow]Warning: Could not save API key to .env file: {err}[/]"
"\n[dim]The API key is set for this session only. "
f"You may need to set it manually in {GLOBAL_ENV_FILE}[/]\n"
f"You may need to set it manually in {GLOBAL_ENV_FILE.path}[/]\n"
)
case "completed":
pass

View file

@ -12,7 +12,8 @@ from textual.validation import Length
from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.core.config import GLOBAL_ENV_FILE, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.config_path import GLOBAL_ENV_FILE
from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = {
@ -24,8 +25,8 @@ CONFIG_DOCS_URL = (
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
GLOBAL_ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE, env_key, api_key)
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
class ApiKeyScreen(OnboardingScreen):