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

@ -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
)