Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-02-19 12:06:03 +01:00 committed by GitHub
parent ec7f3b25ea
commit a560a47ce8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1026 additions and 562 deletions

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.2.0"
__version__ = "2.2.1"

View file

@ -1,8 +1,12 @@
from __future__ import annotations
import base64
from collections.abc import Callable
import os
import shutil
import subprocess
import pyperclip
from textual.app import App
_PREVIEW_MAX_LENGTH = 40
@ -19,6 +23,100 @@ def _copy_osc52(text: str) -> None:
tty.flush()
def _copy_pyperclip(text: str) -> None:
pyperclip.copy(text)
def _has_cmd(cmd: str) -> bool:
return shutil.which(cmd) is not None
def _copy_pbcopy(text: str) -> None:
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
def _copy_xclip(text: str) -> None:
subprocess.run(
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
)
def _copy_wl_copy(text: str) -> None:
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
_CMD_STRATEGIES: list[tuple[str, Callable[[str], None]]] = [
("pbcopy", _copy_pbcopy),
("xclip", _copy_xclip),
("wl-copy", _copy_wl_copy),
]
_COPY_METHODS: list[Callable[[str], None]] = [
_copy_osc52,
_copy_pyperclip,
*[fn for cmd, fn in _CMD_STRATEGIES if _has_cmd(cmd)],
]
def _paste_pyperclip() -> str:
return pyperclip.paste()
def _paste_pbpaste() -> str:
return subprocess.run(["pbpaste"], capture_output=True, check=True).stdout.decode(
"utf-8"
)
def _paste_xclip() -> str:
return subprocess.run(
["xclip", "-selection", "clipboard", "-o"], capture_output=True, check=True
).stdout.decode("utf-8")
def _paste_wl_paste() -> str:
return subprocess.run(["wl-paste"], capture_output=True, check=True).stdout.decode(
"utf-8"
)
_PASTE_CMD_STRATEGIES: list[tuple[str, Callable[[], str]]] = [
("pbpaste", _paste_pbpaste),
("xclip", _paste_xclip),
("wl-paste", _paste_wl_paste),
]
_READ_CLIPBOARD_METHODS: list[Callable[[], str]] = [
_paste_pyperclip,
*[fn for cmd, fn in _PASTE_CMD_STRATEGIES if _has_cmd(cmd)],
]
def _read_clipboard() -> str | None:
for reader in _READ_CLIPBOARD_METHODS:
try:
return reader()
except Exception:
pass
return None
def _copy_to_clipboard(text: str) -> None:
all_strategies_failed = True
for to_clipboard in _COPY_METHODS:
try:
to_clipboard(text)
except Exception:
pass
else:
all_strategies_failed = False
if _read_clipboard() == text:
return
if all_strategies_failed:
raise RuntimeError("All clipboard strategies failed")
def _shorten_preview(texts: list[str]) -> str:
dense_text = "".join(texts).replace("\n", "")
if len(dense_text) > _PREVIEW_MAX_LENGTH:
@ -26,7 +124,7 @@ def _shorten_preview(texts: list[str]) -> str:
return dense_text
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
def _get_selected_texts(app: App) -> list[str]:
selected_texts = []
for widget in app.query("*"):
@ -47,13 +145,17 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None
if selected_text.strip():
selected_texts.append(selected_text)
return selected_texts
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
selected_texts = _get_selected_texts(app)
if not selected_texts:
return None
combined_text = "\n".join(selected_texts)
try:
_copy_osc52(combined_text)
_copy_to_clipboard(combined_text)
if show_toast:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',

View file

@ -2,16 +2,20 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
import os
from pathlib import Path
import signal
import subprocess
import time
from typing import Any, ClassVar, assert_never, cast
from weakref import WeakKeyDictionary
from pydantic import BaseModel
from textual.app import App, ComposeResult
from rich import print as rprint
from textual.app import WINDOWS, App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Horizontal, VerticalGroup, VerticalScroll
from textual.driver import Driver
from textual.events import AppBlur, AppFocus, MouseUp
from textual.widget import Widget
from textual.widgets import Static
@ -38,11 +42,9 @@ from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenS
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
BashOutputMessage,
ErrorMessage,
InterruptMessage,
ReasoningMessage,
StreamingMessageBase,
UserCommandMessage,
UserMessage,
@ -54,7 +56,7 @@ from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.windowing import (
HISTORY_RESUME_TAIL_MESSAGES,
LOAD_MORE_BATCH_SIZE,
@ -180,6 +182,7 @@ class VibeApp(App): # noqa: PLR0904
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "clear_quit", "Quit", show=False),
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
Binding("ctrl+z", "suspend_with_message", "Suspend", show=False, priority=True),
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
Binding("ctrl+y", "copy_selection", "Copy", show=False, priority=True),
@ -225,8 +228,6 @@ class VibeApp(App): # noqa: PLR0904
self.history_file = HISTORY_FILE.path
self._tools_collapsed = True
self._current_streaming_message: AssistantMessage | None = None
self._current_streaming_reasoning: ReasoningMessage | None = None
self._windowing = SessionWindowing(load_more_batch_size=LOAD_MORE_BATCH_SIZE)
self._load_more = HistoryLoadMoreManager()
self._tool_call_map: dict[str, str] | None = None
@ -239,9 +240,9 @@ class VibeApp(App): # noqa: PLR0904
self._plan_offer_gateway = plan_offer_gateway
self._initial_prompt = initial_prompt
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
self._auto_scroll = True
self._last_escape_time: float | None = None
self._banner: Banner | None = None
self._whats_new_message: WhatsNewMessage | None = None
self._cached_messages_area: Widget | None = None
self._cached_chat: ChatScroll | None = None
self._cached_loading_area: Widget | None = None
@ -267,6 +268,7 @@ class VibeApp(App): # noqa: PLR0904
safety=self.agent_loop.agent_profile.safety,
agent_name=self.agent_loop.agent_profile.display_name.lower(),
skill_entries_getter=self._get_skill_entries,
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
nuage_enabled=self.config.nuage_enabled,
)
@ -284,7 +286,6 @@ class VibeApp(App): # noqa: PLR0904
self.event_handler = EventHandler(
mount_callback=self._mount_and_scroll,
scroll_callback=self._scroll_to_bottom_deferred,
get_tools_collapsed=lambda: self._tools_collapsed,
)
@ -312,6 +313,8 @@ class VibeApp(App): # noqa: PLR0904
self._schedule_update_notification()
self.agent_loop.emit_new_session_telemetry("cli")
self.call_after_refresh(self._refresh_banner)
if self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
@ -325,12 +328,19 @@ class VibeApp(App): # noqa: PLR0904
self._handle_user_message(self._initial_prompt), exclusive=False
)
def _is_file_watcher_enabled(self) -> bool:
return self.config.file_watcher_for_autocomplete
async def on_chat_input_container_submitted(
self, event: ChatInputContainer.Submitted
) -> None:
if self._banner:
self._banner.freeze_animation()
if self._whats_new_message:
await self._whats_new_message.remove()
self._whats_new_message = None
value = event.value.strip()
if not value:
return
@ -582,9 +592,7 @@ class VibeApp(App): # noqa: PLR0904
plan.tool_call_map,
start_index=plan.tail_start_index,
)
self.call_after_refresh(
lambda: self._align_chat_after_history_rebuild(plan.has_backfill)
)
self.call_after_refresh(self._scroll_chat_to_end)
self._tool_call_map = plan.tool_call_map
self._windowing.set_backfill(plan.backfill_messages)
await self._load_more.set_visible(
@ -703,7 +711,8 @@ class VibeApp(App): # noqa: PLR0904
if self._loading_widget:
await self._loading_widget.remove()
self._loading_widget = None
await self._finalize_current_streaming_message()
if self.event_handler:
await self.event_handler.finalize_streaming()
await self._refresh_windowing_from_history()
async def _teleport_command(self) -> None:
@ -814,6 +823,7 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
self.event_handler.stop_current_compact()
await self.event_handler.finalize_streaming()
self._agent_running = False
loading_area = self._cached_loading_area or self.query_one(
@ -822,7 +832,6 @@ class VibeApp(App): # noqa: PLR0904
await loading_area.remove_children()
self._loading_widget = None
await self._finalize_current_streaming_message()
await self._mount_and_scroll(InterruptMessage())
self._interrupt_requested = False
@ -881,7 +890,8 @@ class VibeApp(App): # noqa: PLR0904
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
await self.agent_loop.clear_history()
await self._finalize_current_streaming_message()
if self.event_handler:
await self.event_handler.finalize_streaming()
messages_area = self._cached_messages_area or self.query_one("#messages")
await messages_area.remove_children()
@ -1019,7 +1029,7 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(widget.focus)
if scroll:
self.call_after_refresh(self._scroll_to_bottom)
self.call_after_refresh(self._scroll_chat_to_end)
async def _switch_to_config_app(self) -> None:
if self._current_bottom_app == BottomApp.Config:
@ -1059,7 +1069,7 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
self.call_after_refresh(self._chat_input_container.focus_input)
self.call_after_refresh(self._scroll_to_bottom)
self.call_after_refresh(self._scroll_chat_to_end)
def _focus_current_bottom_app(self) -> None:
try:
@ -1153,7 +1163,7 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_running_escape()
self._last_escape_time = current_time
self._scroll_to_bottom()
self.call_after_refresh(self._scroll_chat_to_end)
self._focus_current_bottom_app()
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
@ -1211,6 +1221,10 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_profile_widgets(self) -> None:
self._update_profile_widgets(self.agent_loop.agent_profile)
def _refresh_banner(self) -> None:
if self._banner:
self._banner.set_state(self.config, self.agent_loop.skill_manager)
def _update_profile_widgets(self, profile: AgentProfile) -> None:
if self._chat_input_container:
self._chat_input_container.set_safety(profile.safety)
@ -1245,7 +1259,6 @@ class VibeApp(App): # noqa: PLR0904
try:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_relative(y=-5, animate=False)
self._auto_scroll = False
except Exception:
pass
@ -1253,8 +1266,6 @@ class VibeApp(App): # noqa: PLR0904
try:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_relative(y=5, animate=False)
if self._is_scrolled_to_bottom(chat):
self._auto_scroll = True
except Exception:
pass
@ -1283,7 +1294,11 @@ class VibeApp(App): # noqa: PLR0904
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
if self._history_widget_indices:
whats_new_message.add_class("after-history")
await self._mount_and_scroll(whats_new_message)
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
await chat.mount(whats_new_message, after=messages_area)
self._whats_new_message = whats_new_message
chat.anchor()
await mark_version_as_seen(self._current_version, self._update_cache_repository)
async def _plan_offer_cta(self) -> str | None:
@ -1309,105 +1324,29 @@ class VibeApp(App): # noqa: PLR0904
)
return
async def _finalize_current_streaming_message(self) -> None:
if self._current_streaming_reasoning is not None:
self._current_streaming_reasoning.stop_spinning()
await self._current_streaming_reasoning.stop_stream()
self._current_streaming_reasoning = None
if self._current_streaming_message is None:
return
await self._current_streaming_message.stop_stream()
self._current_streaming_message = None
async def _handle_streaming_widget[T: StreamingMessageBase](
self,
widget: T,
current_stream: T | None,
other_stream: StreamingMessageBase | None,
messages_area: Widget,
) -> T | None:
if other_stream is not None:
await other_stream.stop_stream()
if current_stream is not None:
if widget._content:
await current_stream.append_content(widget._content)
return None
await messages_area.mount(widget)
await widget.write_initial_content()
return widget
def _scroll_chat_to_end(self) -> None:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_end(animate=False)
async def _mount_and_scroll(self, widget: Widget) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
was_at_bottom = self._is_scrolled_to_bottom(chat)
result: Widget | None = None
if was_at_bottom:
self._auto_scroll = True
await messages_area.mount(widget)
if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content()
if isinstance(widget, ReasoningMessage):
result = await self._handle_streaming_widget(
widget,
self._current_streaming_reasoning,
self._current_streaming_message,
messages_area,
)
if result is not None:
self._current_streaming_reasoning = result
self._current_streaming_message = None
elif isinstance(widget, AssistantMessage):
if self._current_streaming_reasoning is not None:
self._current_streaming_reasoning.stop_spinning()
result = await self._handle_streaming_widget(
widget,
self._current_streaming_message,
self._current_streaming_reasoning,
messages_area,
)
if result is not None:
self._current_streaming_message = result
self._current_streaming_reasoning = None
else:
await self._finalize_current_streaming_message()
await messages_area.mount(widget)
result = widget
is_tool_message = isinstance(widget, (ToolCallMessage, ToolResultMessage))
if not is_tool_message:
self.call_after_refresh(self._scroll_to_bottom)
if result is not None:
self.call_after_refresh(self._try_prune)
if was_at_bottom:
self.call_after_refresh(self._anchor_if_scrollable)
def _is_scrolled_to_bottom(self, scroll_view: VerticalScroll) -> bool:
try:
threshold = 3
return scroll_view.scroll_y >= (scroll_view.max_scroll_y - threshold)
except Exception:
return True
def _scroll_to_bottom(self) -> None:
try:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_end(animate=False)
except Exception:
pass
def _scroll_to_bottom_deferred(self) -> None:
self.call_after_refresh(self._scroll_to_bottom)
self.call_after_refresh(self._try_prune)
chat.anchor()
async def _try_prune(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
pruned = await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
if self._load_more.widget and not self._load_more.widget.parent:
self._load_more.widget = None
if pruned:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
self.call_later(chat.anchor)
async def _refresh_windowing_from_history(self) -> None:
if self._load_more.widget is None:
@ -1424,29 +1363,6 @@ class VibeApp(App): # noqa: PLR0904
messages_area, visible=has_backfill, remaining=self._windowing.remaining
)
def _anchor_if_scrollable(self) -> None:
if not self._auto_scroll:
return
try:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if chat.max_scroll_y == 0:
return
chat.anchor()
except Exception:
pass
def _align_chat_after_history_rebuild(self, has_backfill: bool) -> None:
try:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if has_backfill and chat.max_scroll_y > 0:
chat.anchor(True)
chat.scroll_end(animate=False)
chat.anchor(False)
return
chat.scroll_end(animate=False)
except Exception:
pass
def _schedule_update_notification(self) -> None:
if self._update_notifier is None or not self.config.enable_update_checks:
return
@ -1516,6 +1432,20 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True)
def action_suspend_with_message(self) -> None:
if WINDOWS or self._driver is None or not self._driver.can_suspend:
return
with self.suspend():
rprint(
"Mistral Vibe has been suspended. Run [bold cyan]fg[/bold cyan] to bring Mistral Vibe back."
)
os.kill(os.getpid(), signal.SIGTSTP)
def _on_driver_signal_resume(self, event: Driver.SignalResume) -> None:
# Textual doesn't repaint after resuming from Ctrl+Z (SIGTSTP);
# force a full layout refresh so the UI isn't garbled.
self.refresh(layout=True)
def _print_session_resume_message(session_id: str | None) -> None:
if not session_id:

View file

@ -29,6 +29,7 @@ TextArea > .text-area--cursor {
width: 100%;
background: transparent;
padding: 0;
align-vertical: bottom;
}
#loading-area {
@ -147,8 +148,9 @@ Markdown {
color: ansi_default;
.code_inline {
color: ansi_default;
background: ansi_bright_black;
color: ansi_yellow;
background: transparent;
text-style: bold;
}
MarkdownFence {

View file

@ -27,16 +27,14 @@ if TYPE_CHECKING:
class EventHandler:
def __init__(
self,
mount_callback: Callable,
scroll_callback: Callable,
get_tools_collapsed: Callable[[], bool],
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
) -> None:
self.mount_callback = mount_callback
self.scroll_callback = scroll_callback
self.get_tools_collapsed = get_tools_collapsed
self.current_tool_call: ToolCallMessage | None = None
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
self.current_streaming_reasoning: ReasoningMessage | None = None
async def handle_event(
self,
@ -45,24 +43,29 @@ class EventHandler:
loading_widget: LoadingWidget | None = None,
) -> ToolCallMessage | None:
match event:
case ToolCallEvent():
return await self._handle_tool_call(event, loading_widget)
case ToolResultEvent():
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
case ToolStreamEvent():
await self._handle_tool_stream(event)
case ReasoningEvent():
await self._handle_reasoning_message(event)
case AssistantEvent():
await self._handle_assistant_message(event)
case ToolCallEvent():
await self.finalize_streaming()
return await self._handle_tool_call(event, loading_widget)
case ToolResultEvent():
await self.finalize_streaming()
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
case ToolStreamEvent():
await self._handle_tool_stream(event)
case CompactStartEvent():
await self.finalize_streaming()
await self._handle_compact_start()
case CompactEndEvent():
await self.finalize_streaming()
await self._handle_compact_end(event)
case UserMessageEvent():
pass
await self.finalize_streaming()
case _:
await self.finalize_streaming()
await self._handle_unknown_event(event)
return None
@ -113,13 +116,30 @@ class EventHandler:
self.current_tool_call.set_stream_message(event.message)
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
await self.mount_callback(AssistantMessage(event.content))
if self.current_streaming_reasoning is not None:
self.current_streaming_reasoning.stop_spinning()
await self.current_streaming_reasoning.stop_stream()
self.current_streaming_reasoning = None
if self.current_streaming_message is None:
msg = AssistantMessage(event.content)
self.current_streaming_message = msg
await self.mount_callback(msg)
else:
await self.current_streaming_message.append_content(event.content)
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
tools_collapsed = self.get_tools_collapsed()
await self.mount_callback(
ReasoningMessage(event.content, collapsed=tools_collapsed)
)
if self.current_streaming_message is not None:
await self.current_streaming_message.stop_stream()
self.current_streaming_message = None
if self.current_streaming_reasoning is None:
tools_collapsed = self.get_tools_collapsed()
msg = ReasoningMessage(event.content, collapsed=tools_collapsed)
self.current_streaming_reasoning = msg
await self.mount_callback(msg)
else:
await self.current_streaming_reasoning.append_content(event.content)
async def _handle_compact_start(self) -> None:
compact_msg = CompactMessage()
@ -136,6 +156,15 @@ class EventHandler:
async def _handle_unknown_event(self, event: BaseEvent) -> None:
await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event"))
async def finalize_streaming(self) -> None:
if self.current_streaming_reasoning is not None:
self.current_streaming_reasoning.stop_spinning()
await self.current_streaming_reasoning.stop_stream()
self.current_streaming_reasoning = None
if self.current_streaming_message is not None:
await self.current_streaming_message.stop_stream()
self.current_streaming_message = None
def stop_current_tool_call(self, success: bool = True) -> None:
if self.current_tool_call:
self.current_tool_call.stop_spinning(success=success)

View file

@ -42,6 +42,7 @@ class ChatInputContainer(Vertical):
safety: AgentSafety = AgentSafety.NEUTRAL,
agent_name: str = "",
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
nuage_enabled: bool = False,
**kwargs: Any,
) -> None:
@ -51,11 +52,19 @@ class ChatInputContainer(Vertical):
self._safety = safety
self._agent_name = agent_name
self._skill_entries_getter = skill_entries_getter
self._file_watcher_for_autocomplete_getter = (
file_watcher_for_autocomplete_getter
)
self._nuage_enabled = nuage_enabled
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
PathCompletionController(PathCompleter(), self),
PathCompletionController(
PathCompleter(
watcher_enabled_getter=self._file_watcher_for_autocomplete_getter
),
self,
),
])
self._completion_popup: CompletionPopup | None = None
self._body: ChatInputBody | None = None

View file

@ -63,6 +63,12 @@ class ConfigApp(Container):
"type": "cycle",
"options": ["On", "Off"],
},
{
"key": "file_watcher_for_autocomplete",
"label": "Autocomplete watcher (may delay first autocompletion)",
"type": "cycle",
"options": ["On", "Off"],
},
]
self.title_widget: Static | None = None

View file

@ -25,9 +25,9 @@ from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
"""Truncate content to max_lines, returning (content, truncation_info)."""
lines = content.split("\n")
lines = content.strip("\n").split("\n")
if len(lines) <= max_lines:
return content, None
return "\n".join(lines), None
remaining = len(lines) - max_lines
return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)"

View file

@ -5,7 +5,6 @@ from weakref import WeakKeyDictionary
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage
from vibe.cli.textual_ui.windowing.history import (
build_tool_call_map,
split_history_tail,
@ -29,10 +28,7 @@ class HistoryResumePlan:
def should_resume_history(messages_children: list[Widget]) -> bool:
allowed_pre_existing_types = (WhatsNewMessage,)
return all(
isinstance(child, allowed_pre_existing_types) for child in messages_children
)
return len(messages_children) == 0
def create_resume_plan(

View file

@ -474,54 +474,21 @@ class AgentLoop:
async def _stream_assistant_events(
self,
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
content_buffer = ""
reasoning_buffer = ""
chunks_with_content = 0
chunks_with_reasoning = 0
message_id: str | None = None
BATCH_SIZE = 5
async for chunk in self._chat_streaming():
if message_id is None:
message_id = chunk.message.message_id
if chunk.message.reasoning_content:
if content_buffer:
yield AssistantEvent(content=content_buffer, message_id=message_id)
content_buffer = ""
chunks_with_content = 0
reasoning_buffer += chunk.message.reasoning_content
chunks_with_reasoning += 1
if chunks_with_reasoning >= BATCH_SIZE:
yield ReasoningEvent(
content=reasoning_buffer, message_id=message_id
)
reasoning_buffer = ""
chunks_with_reasoning = 0
yield ReasoningEvent(
content=chunk.message.reasoning_content, message_id=message_id
)
if chunk.message.content:
if reasoning_buffer:
yield ReasoningEvent(
content=reasoning_buffer, message_id=message_id
)
reasoning_buffer = ""
chunks_with_reasoning = 0
content_buffer += chunk.message.content
chunks_with_content += 1
if chunks_with_content >= BATCH_SIZE:
yield AssistantEvent(content=content_buffer, message_id=message_id)
content_buffer = ""
chunks_with_content = 0
if reasoning_buffer:
yield ReasoningEvent(content=reasoning_buffer, message_id=message_id)
if content_buffer:
yield AssistantEvent(content=content_buffer, message_id=message_id)
yield AssistantEvent(
content=chunk.message.content, message_id=message_id
)
async def _get_assistant_event(self) -> AssistantEvent:
llm_result = await self._chat()
@ -933,7 +900,7 @@ class AgentLoop:
)
self.messages = self.messages[:1]
self.stats = AgentStats()
self.stats = AgentStats.create_fresh(self.stats)
self.stats.trigger_listeners()
try:

View file

@ -71,8 +71,9 @@ class PathCompleter(Completer):
self,
max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS,
target_matches: int = DEFAULT_TARGET_MATCHES,
watcher_enabled_getter: Callable[[], bool] | None = None,
) -> None:
self._indexer = FileIndexer()
self._indexer = FileIndexer(should_enable_watcher=watcher_enabled_getter)
self._max_entries_to_process = max_entries_to_process
self._target_matches = target_matches

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import atexit
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
@ -23,7 +23,11 @@ class _RebuildTask:
class FileIndexer:
def __init__(self, mass_change_threshold: int = 200) -> None:
def __init__(
self,
mass_change_threshold: int = 200,
should_enable_watcher: Callable[[], bool] | None = None,
) -> None:
self._lock = RLock() # guards _store snapshot access and watcher callbacks.
self._stats = FileIndexStats()
self._ignore_rules = IgnoreRules()
@ -40,6 +44,7 @@ class FileIndexer:
) # coordinates updates to _active_rebuilds and _target_root.
self._target_root: Path | None = None
self._shutdown = False
self._should_enable_watcher = should_enable_watcher or (lambda: False)
atexit.register(self.shutdown)
@ -74,7 +79,10 @@ class FileIndexer:
self._start_background_rebuild(resolved_root)
self._wait_for_rebuild(resolved_root)
self._watcher.start(resolved_root)
if self._should_enable_watcher():
self._watcher.start(resolved_root)
else:
self._watcher.stop()
with self._lock: # ensure root reference is fresh before snapshotting
return self._store.snapshot()

View file

@ -311,6 +311,7 @@ class VibeConfig(BaseSettings):
vim_keybindings: bool = False
disable_welcome_banner_animation: bool = False
autocopy_to_clipboard: bool = True
file_watcher_for_autocomplete: bool = False
displayed_workdir: str = ""
auto_compact_threshold: int = 200_000
context_warnings: bool = False

View file

@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, NamedTuple, cast
import httpx
import mistralai
from mistralai.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
@ -178,13 +179,22 @@ class MistralBackend:
)
self._server_url = match.group(1)
self._timeout = timeout
self._retry_config = self._build_retry_config()
def _build_retry_config(self) -> RetryConfig:
return RetryConfig(
strategy="backoff",
backoff=BackoffStrategy(
initial_interval=500,
max_interval=30000,
exponent=1.5,
max_elapsed_time=300000,
),
retry_connection_errors=True,
)
async def __aenter__(self) -> MistralBackend:
self._client = mistralai.Mistral(
api_key=self._api_key,
server_url=self._server_url,
timeout_ms=int(self._timeout * 1000),
)
self._client = self._create_mistral_client()
await self._client.__aenter__()
return self
@ -199,11 +209,17 @@ class MistralBackend:
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
)
def _create_mistral_client(self) -> mistralai.Mistral:
return mistralai.Mistral(
api_key=self._api_key,
server_url=self._server_url,
timeout_ms=int(self._timeout * 1000),
retry_config=self._retry_config,
)
def _get_client(self) -> mistralai.Mistral:
if self._client is None:
self._client = mistralai.Mistral(
api_key=self._api_key, server_url=self._server_url
)
self._client = self._create_mistral_client()
return self._client
async def complete(

View file

@ -1,9 +1,11 @@
from __future__ import annotations
import json
import threading
from typing import Any, ClassVar
import google.auth
import google.auth.credentials
from google.auth.transport.requests import Request
from vibe.core.config import ProviderConfig
@ -12,15 +14,6 @@ from vibe.core.llm.backend.base import PreparedRequest
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
def get_vertex_access_token() -> str:
credentials, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
credentials.refresh(Request())
return credentials.token
def build_vertex_base_url(region: str) -> str:
if region == "global":
return "https://aiplatform.googleapis.com"
@ -37,13 +30,39 @@ def build_vertex_endpoint(
)
class VertexCredentials:
def __init__(self) -> None:
self._credentials: google.auth.credentials.Credentials | None = None
self._lock = threading.Lock()
@property
def access_token(self) -> str:
with self._lock:
creds = self._credentials
if creds is None:
creds, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
self._credentials = creds
if not creds.valid:
creds.refresh(Request())
if creds.token is None:
raise RuntimeError(
"Vertex AI credential refresh did not produce a token"
)
return creds.token
class VertexAnthropicAdapter(AnthropicAdapter):
"""Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter."""
endpoint: ClassVar[str] = ""
# Vertex AI doesn't support beta features
BETA_FEATURES: ClassVar[str] = ""
def __init__(self) -> None:
super().__init__()
self.credentials = VertexCredentials()
def prepare_request( # noqa: PLR0913
self,
*,
@ -70,7 +89,6 @@ class VertexAnthropicAdapter(AnthropicAdapter):
converted_tools = self._mapper.prepare_tools(tools)
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
# Build vertex-specific payload (no "model" key, uses anthropic_version)
payload: dict[str, Any] = {
"anthropic_version": "vertex-2023-10-16",
"messages": converted_messages,
@ -98,11 +116,9 @@ class VertexAnthropicAdapter(AnthropicAdapter):
self._add_cache_control_to_last_user_message(converted_messages)
access_token = get_vertex_access_token()
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"Authorization": f"Bearer {self.credentials.access_token}",
"anthropic-beta": self.BETA_FEATURES,
}

View file

@ -72,13 +72,7 @@ def _get_shell_executable() -> str | None:
def _get_base_env() -> dict[str, str]:
base_env = {
**os.environ,
"CI": "true",
"NONINTERACTIVE": "1",
"NO_TTY": "1",
"NO_COLOR": "1",
}
base_env = {**os.environ, "CI": "true", "NONINTERACTIVE": "1", "NO_TTY": "1"}
if is_windows():
base_env["GIT_PAGER"] = "more"

View file

@ -61,6 +61,12 @@ class AgentStats(BaseModel):
) -> None:
self._listeners[attr_name] = listener
@staticmethod
def create_fresh(previous: AgentStats) -> AgentStats:
fresh = AgentStats()
fresh._listeners = previous._listeners.copy()
return fresh
@computed_field
@property
def session_total_llm_tokens(self) -> int:

View file

@ -1,5 +1,4 @@
# What's New in 2.2.0
# What's new
- **Agent Skills standard** — Vibe now discovers skills from `.agents/skills/` (agentskills.io) as well as `.vibe/skills/`.
Optional: usage and tool events are sent to our datalake to improve the product if you have a valid Mistral API key; set `disable_telemetry = true` in config to opt out.
- **Performance**: general improvements (streaming, scrolling).
- **Fix**: autocopying now behaves correctly in cases where it previously failed.