Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-26 10:11:34 +01:00 committed by GitHub
parent 5ea69b547b
commit 6a50d1d521
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3726 additions and 99 deletions

View file

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

View file

@ -1,6 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
import sys
ALT_KEY = "" if sys.platform == "darwin" else "Alt"
@dataclass
@ -97,6 +100,11 @@ class CommandRegistry:
description="Uninstall the Lean 4 agent",
handler="_uninstall_lean",
),
"rewind": Command(
aliases=frozenset(["/rewind"]),
description="Rewind to a previous message",
handler="_start_rewind_mode",
),
}
for command in excluded_commands:
@ -125,6 +133,7 @@ class CommandRegistry:
"- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view",
"- `Shift+Tab` Toggle auto-approve mode",
f"- `{ALT_KEY}+↑↓` / `Ctrl+P/N` Rewind to previous/next message",
"",
"### Special Features",
"",

View file

@ -69,6 +69,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
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.rewind_app import RewindApp
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
@ -114,6 +115,7 @@ from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
from vibe.core.session.session_loader import SessionLoader
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
@ -166,6 +168,7 @@ class BottomApp(StrEnum):
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
Rewind = auto()
SessionPicker = auto()
Voice = auto()
@ -272,6 +275,10 @@ class VibeApp(App): # noqa: PLR0904
Binding(
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
),
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("alt+down", "rewind_next", "Rewind Next", show=False, priority=True),
Binding("ctrl+n", "rewind_next", "Rewind Next", show=False, priority=True),
]
def __init__(
@ -347,6 +354,9 @@ class VibeApp(App): # noqa: PLR0904
self._speak_task: asyncio.Task[None] | None = None
self._cancel_summary: Callable[[], bool] | None = None
self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None
@property
def config(self) -> VibeConfig:
return self.agent_loop.config
@ -750,7 +760,10 @@ class VibeApp(App): # noqa: PLR0904
)
async def _handle_user_message(self, message: str) -> None:
user_message = UserMessage(message)
# message_index is where the user message will land in agent_loop.messages
# (checkpoint is created in agent_loop.act())
message_index = len(self.agent_loop.messages)
user_message = UserMessage(message, message_index=message_index)
await self._mount_and_scroll(user_message)
if self.agent_loop.telemetry_client.is_active():
@ -1471,6 +1484,12 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_from_input(QuestionApp(args=args), scroll=True)
async def _switch_to_input_app(self) -> None:
if self._chat_input_container:
self._chat_input_container.disabled = False
self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
self._refresh_profile_widgets()
for app in BottomApp:
if app != BottomApp.Input:
try:
@ -1479,10 +1498,6 @@ class VibeApp(App): # noqa: PLR0904
pass
if self._chat_input_container:
self._chat_input_container.disabled = False
self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
self._refresh_profile_widgets()
self.call_after_refresh(self._chat_input_container.focus_input)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if chat.is_at_bottom:
@ -1505,6 +1520,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case BottomApp.Rewind:
self.query_one(RewindApp).focus()
case BottomApp.Voice:
self.query_one(VoiceApp).focus()
case app:
@ -1562,6 +1579,196 @@ class VibeApp(App): # noqa: PLR0904
pass
self._last_escape_time = None
# --- Rewind mode ---
def _get_user_message_widgets(self) -> list[UserMessage]:
"""Return all UserMessage widgets currently visible in #messages."""
messages_area = self._cached_messages_area or self.query_one("#messages")
return [
child for child in messages_area.children if isinstance(child, UserMessage)
]
def _start_rewind_mode(self) -> None:
self.action_rewind_prev()
def action_rewind_prev(self) -> None:
if self._agent_running:
return
user_widgets = self._get_user_message_widgets()
if not user_widgets:
return
if not self._rewind_mode:
self._rewind_mode = True
target = user_widgets[-1]
elif self._rewind_highlighted_widget is not None:
try:
idx = user_widgets.index(self._rewind_highlighted_widget)
except ValueError:
idx = len(user_widgets)
if idx <= 0:
self.run_worker(self._rewind_prev_at_top(), exclusive=False)
return
target = user_widgets[idx - 1]
else:
target = user_widgets[-1]
self.run_worker(self._select_rewind_widget(target), exclusive=False)
async def _rewind_prev_at_top(self) -> None:
"""Handle alt+up when already at the topmost visible user message."""
if self._load_more.widget is not None and self._windowing.has_backfill:
await self.on_history_load_more_requested(HistoryLoadMoreRequested())
user_widgets = self._get_user_message_widgets()
if user_widgets and self._rewind_highlighted_widget is not None:
# Find the current highlighted widget in the refreshed list
# and select the one above it
try:
idx = user_widgets.index(self._rewind_highlighted_widget)
except ValueError:
idx = 0
if idx > 0:
await self._select_rewind_widget(user_widgets[idx - 1])
return
# No load more or already first message: scroll to top
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
self.call_after_refresh(chat.scroll_home, animate=False)
def action_rewind_next(self) -> None:
if not self._rewind_mode:
return
if self._rewind_highlighted_widget is None:
return
user_widgets = self._get_user_message_widgets()
try:
idx = user_widgets.index(self._rewind_highlighted_widget)
except ValueError:
return
if idx >= len(user_widgets) - 1:
return
self.run_worker(
self._select_rewind_widget(user_widgets[idx + 1]), exclusive=False
)
async def _select_rewind_widget(self, widget: UserMessage) -> None:
"""Highlight the given user message widget and show the rewind panel."""
if self._rewind_highlighted_widget is not None:
self._rewind_highlighted_widget.remove_class("rewind-selected")
widget.add_class("rewind-selected")
self._rewind_highlighted_widget = widget
msg_index = widget.message_index
has_file_changes = (
msg_index is not None
and self.agent_loop.rewind_manager.has_file_changes_at(msg_index)
)
await self._switch_to_rewind_app(
widget.get_content(), has_file_changes=has_file_changes
)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
self.call_after_refresh(chat.scroll_to_widget, widget, animate=False, top=True)
async def _switch_to_rewind_app(
self, message_preview: str, *, has_file_changes: bool
) -> None:
"""Show the rewind action panel at the bottom."""
if self._current_bottom_app == BottomApp.Rewind:
# Reuse existing widget if the option set hasn't changed
try:
existing = self.query_one(RewindApp)
if existing.has_file_changes == has_file_changes:
existing.update_preview(message_preview)
return
await existing.remove()
except Exception:
pass
rewind_app = RewindApp(
message_preview=message_preview, has_file_changes=has_file_changes
)
bottom_container = self.query_one("#bottom-app-container")
self._current_bottom_app = BottomApp.Rewind
await bottom_container.mount(rewind_app)
self.call_after_refresh(rewind_app.focus)
else:
rewind_app = RewindApp(
message_preview=message_preview, has_file_changes=has_file_changes
)
await self._switch_from_input(rewind_app)
def _clear_rewind_state(self) -> None:
if self._rewind_highlighted_widget is not None:
self._rewind_highlighted_widget.remove_class("rewind-selected")
self._rewind_highlighted_widget = None
self._rewind_mode = False
async def _exit_rewind_mode(self) -> None:
"""Exit rewind mode and restore the input panel."""
self._clear_rewind_state()
await self._switch_to_input_app()
async def on_rewind_app_rewind_with_restore(
self, message: RewindApp.RewindWithRestore
) -> None:
await self._execute_rewind(restore_files=True)
async def on_rewind_app_rewind_without_restore(
self, message: RewindApp.RewindWithoutRestore
) -> None:
await self._execute_rewind(restore_files=False)
async def _execute_rewind(self, *, restore_files: bool) -> None:
"""Fork the session at the selected user message."""
if not self._rewind_mode or self._rewind_highlighted_widget is None:
return
target_widget = self._rewind_highlighted_widget
msg_index = target_widget.message_index
if msg_index is None:
return
try:
(
message_content,
restore_errors,
) = await self.agent_loop.rewind_manager.rewind_to_message(
msg_index, restore_files=restore_files
)
except RewindError as exc:
self.notify(str(exc), severity="error")
return
for error in restore_errors:
self.notify(error, severity="warning")
# Remove UI widgets from the selected message onward
messages_area = self._cached_messages_area or self.query_one("#messages")
children = list(messages_area.children)
try:
target_idx = children.index(target_widget)
except ValueError:
target_idx = len(children)
to_remove = children[target_idx:]
if to_remove:
await messages_area.remove_children(to_remove)
self._clear_rewind_state()
# Switch back to input and pre-fill with the original message
await self._switch_to_input_app()
if self._chat_input_container:
self._chat_input_container.value = message_content
# --- End rewind mode ---
def _handle_input_app_escape(self) -> None:
try:
input_widget = self.query_one(ChatInputContainer)
@ -1614,6 +1821,11 @@ class VibeApp(App): # noqa: PLR0904
self._handle_session_picker_app_escape()
return
if self._current_bottom_app == BottomApp.Rewind:
self.run_worker(self._exit_rewind_mode(), exclusive=False)
self._last_escape_time = None
return
if (
self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None

View file

@ -529,6 +529,24 @@ StatusMessage {
height: auto;
}
.tool-result-widget Markdown,
.tool-approval-widget Markdown {
padding: 0;
margin: 0;
}
.tool-result-widget Markdown > *,
.tool-approval-widget Markdown > * {
margin-top: 0;
margin-bottom: 0;
}
.tool-result-widget Markdown MarkdownFence,
.tool-approval-widget Markdown MarkdownFence {
margin: 0;
padding: 0;
}
.tool-call-detail,
.tool-result-detail {
height: auto;
@ -1111,6 +1129,51 @@ FeedbackBar {
margin-left: 1;
}
.user-message.rewind-selected {
.user-message-content {
text-style: bold reverse;
}
}
#rewind-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#rewind-content {
width: 100%;
height: auto;
}
.rewind-title {
height: auto;
text-style: bold;
color: ansi_blue;
}
.rewind-option {
height: auto;
color: ansi_default;
}
.rewind-cursor-selected {
color: ansi_blue;
text-style: bold;
}
.rewind-option-unselected {
color: ansi_default;
}
.rewind-help {
height: auto;
color: ansi_bright_black;
}
#feedback-text {
width: auto;
height: auto;

View file

@ -38,11 +38,17 @@ class ExpandingBorder(NonSelectableStatic):
class UserMessage(Static):
def __init__(self, content: str, pending: bool = False) -> None:
def __init__(
self, content: str, pending: bool = False, message_index: int | None = None
) -> None:
super().__init__()
self.add_class("user-message")
self._content = content
self._pending = pending
self.message_index: int | None = message_index
def get_content(self) -> str:
return self._content
def compose(self) -> ComposeResult:
with Horizontal(classes="user-message-container"):

View file

@ -0,0 +1,147 @@
from __future__ import annotations
from enum import StrEnum, auto
from typing import ClassVar
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.widgets import Static
from vibe.cli.commands import ALT_KEY
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
class _RewindAction(StrEnum):
EDIT_AND_RESTORE = auto()
EDIT_ONLY = auto()
class RewindApp(Container):
"""Bottom panel widget for rewind mode actions."""
can_focus = True
can_focus_children = False
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("enter", "select", "Select", show=False),
Binding("1", "select_1", "Option 1", show=False),
Binding("2", "select_2", "Option 2", show=False),
]
class RewindWithRestore(Message):
"""User chose to edit the message and restore files."""
class RewindWithoutRestore(Message):
"""User chose to edit the message without restoring files."""
def __init__(self, message_preview: str, *, has_file_changes: bool) -> None:
super().__init__(id="rewind-app")
self._message_preview = message_preview
self._has_file_changes = has_file_changes
self.selected_option = 0
self.option_widgets: list[Static] = []
self._title_widget: NoMarkupStatic | None = None
self._options = self._build_options()
def _build_options(self) -> list[tuple[str, _RewindAction]]:
options: list[tuple[str, _RewindAction]] = []
if self._has_file_changes:
options.append((
"Edit & restore files to this point",
_RewindAction.EDIT_AND_RESTORE,
))
edit_only_label = (
"Edit without restoring files"
if self._has_file_changes
else "Edit message from here"
)
options.append((edit_only_label, _RewindAction.EDIT_ONLY))
return options
@property
def has_file_changes(self) -> bool:
return self._has_file_changes
def update_preview(self, message_preview: str) -> None:
self._message_preview = message_preview
if self._title_widget is not None:
self._title_widget.update(f"Rewind to: {message_preview[:80]}")
def compose(self) -> ComposeResult:
with Vertical(id="rewind-content"):
self._title_widget = NoMarkupStatic(
f"Rewind to: {self._message_preview[:80]}", classes="rewind-title"
)
yield self._title_widget
yield NoMarkupStatic("")
for _ in range(len(self._options)):
widget = NoMarkupStatic("", classes="rewind-option")
self.option_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
yield NoMarkupStatic(
f"{ALT_KEY}+↑↓ or Ctrl+P/N browse messages ↑↓ pick option Enter confirm ESC cancel",
classes="rewind-help",
)
async def on_mount(self) -> None:
self._update_options()
self.focus()
def _update_options(self) -> None:
for idx, ((text, _action), widget) in enumerate(
zip(self._options, self.option_widgets, strict=True)
):
is_selected = idx == self.selected_option
cursor = " " if is_selected else " "
option_text = f"{cursor}{idx + 1}. {text}"
widget.update(option_text)
widget.remove_class("rewind-cursor-selected")
widget.remove_class("rewind-option-unselected")
if is_selected:
widget.add_class("rewind-cursor-selected")
else:
widget.add_class("rewind-option-unselected")
def _option_count(self) -> int:
return len(self._options)
def action_move_up(self) -> None:
self.selected_option = (self.selected_option - 1) % self._option_count()
self._update_options()
def action_move_down(self) -> None:
self.selected_option = (self.selected_option + 1) % self._option_count()
self._update_options()
def action_select(self) -> None:
self._handle_selection(self.selected_option)
def action_select_1(self) -> None:
if self._option_count() >= 1:
self.selected_option = 0
self._handle_selection(0)
def action_select_2(self) -> None:
if self._option_count() >= 2: # noqa: PLR2004
self.selected_option = 1
self._handle_selection(1)
def _handle_selection(self, option: int) -> None:
_, action = self._options[option]
match action:
case _RewindAction.EDIT_AND_RESTORE:
self.post_message(self.RewindWithRestore())
case _RewindAction.EDIT_ONLY:
self.post_message(self.RewindWithoutRestore())
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -98,8 +98,7 @@ class SessionPickerApp(Container):
with Vertical(id="sessionpicker-content"):
yield OptionList(*options, id="sessionpicker-options")
yield NoMarkupStatic(
"Up/Down Navigate Enter Select Esc Cancel",
classes="sessionpicker-help",
"↑↓ Navigate Enter Select Esc Cancel", classes="sessionpicker-help"
)
def on_mount(self) -> None:

View file

@ -39,12 +39,17 @@ def build_history_widgets(
) -> list[Widget]:
widgets: list[Widget] = []
for offset, msg in enumerate(batch):
history_index = start_index + offset
for history_index, msg in zip(
range(start_index, start_index + len(batch)), batch, strict=True
):
if msg.injected:
continue
match msg.role:
case Role.user:
if msg.content:
widget = UserMessage(msg.content)
# history_index is 0-based in non-system messages;
# agent_loop.messages index = history_index + 1 (system msg at 0)
widget = UserMessage(msg.content, message_index=history_index + 1)
widgets.append(widget)
history_widget_indices[widget] = history_index

View file

@ -60,9 +60,23 @@ class SessionWindowing:
self._backfill_cursor = 0
return False
if visible_indices:
backfill_end = min(visible_indices)
oldest_widget = min(visible_indices)
# _backfill_cursor is the first history index in the loaded window (tail + prepends).
# Oldest widgets can start *after* that when the tail begins with injected-only slots
# (no widgets). Using min(visible_indices) alone would shrink the backfill prefix into
# the already-mounted tail and break load-more batch start_index alignment.
if oldest_widget > self._backfill_cursor:
prefix = history_messages[self._backfill_cursor : oldest_widget]
backfill_end = (
self._backfill_cursor
if prefix and all(m.injected for m in prefix)
else oldest_widget
)
else:
backfill_end = self._backfill_cursor
else:
backfill_end = max(len(history_messages) - visible_history_widgets_count, 0)
backfill_end = min(backfill_end, len(history_messages))
self._backfill_messages = history_messages[:backfill_end]
self._backfill_cursor = len(self._backfill_messages)
return self._backfill_cursor > 0

View file

@ -45,6 +45,7 @@ from vibe.core.middleware import (
)
from vibe.core.plan_session import PlanSession
from vibe.core.prompts import UtilityPrompt
from vibe.core.rewind import RewindManager
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
@ -213,6 +214,11 @@ class AgentLoop:
config_getter=lambda: self.config, session_id_getter=lambda: self.session_id
)
self.session_logger = SessionLogger(config.session_logging, self.session_id)
self.rewind_manager = RewindManager(
messages=self.messages,
save_messages=self._save_messages,
reset_session=self._reset_session,
)
self._teleport_service: TeleportService | None = None
thread = Thread(
@ -340,6 +346,7 @@ class AgentLoop:
async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
self._clean_message_history()
self.rewind_manager.create_checkpoint()
try:
model_name = self.config.get_active_model().name
except ValueError:
@ -447,7 +454,7 @@ class AgentLoop:
case MiddlewareAction.INJECT_MESSAGE:
if result.message:
injected_message = LLMMessage(
role=Role.user, content=result.message
role=Role.user, content=result.message, injected=True
)
self.messages.append(injected_message)
@ -693,6 +700,10 @@ class AgentLoop:
self.stats.tool_calls_agreed += 1
snapshot = tool_instance.get_file_snapshot(tool_call.validated_args)
if snapshot is not None:
self.rewind_manager.add_snapshot(snapshot)
start_time = time.perf_counter()
result_model = None
async for item in tool_instance.invoke(
@ -928,7 +939,7 @@ class AgentLoop:
try:
start_time = time.perf_counter()
usage = LLMUsage()
chunk_agg = LLMChunk(message=LLMMessage(role=Role.assistant))
chunk_agg: LLMChunk | None = None
async for chunk in self.backend.complete_streaming(
model=active_model,
messages=self.messages,
@ -945,12 +956,16 @@ class AgentLoop:
chunk.message
)
processed_chunk = LLMChunk(message=processed_message, usage=chunk.usage)
chunk_agg += processed_chunk
chunk_agg = (
processed_chunk
if chunk_agg is None
else chunk_agg + processed_chunk
)
usage += chunk.usage or LLMUsage()
yield processed_chunk
end_time = time.perf_counter()
if chunk_agg.usage is None:
if chunk_agg is None or chunk_agg.usage is None:
raise AgentLoopLLMResponseError(
"Usage data missing in final chunk of streamed completion"
)
@ -1259,10 +1274,7 @@ class AgentLoop:
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
self.messages.reset([
LLMMessage(role=Role.system, content=new_system_prompt),
*[msg for msg in self.messages if msg.role != Role.system],
])
self.messages.update_system_prompt(new_system_prompt)
if len(self.messages) == 1:
self.stats.reset_context_state()

View file

@ -163,8 +163,7 @@ LEAN = AgentProfile(
"name": "mistral-testing",
"api_base": "https://api.mistral.ai/v1",
"api_key_env_var": "MISTRAL_API_KEY",
"api_style": "reasoning",
"backend": "generic",
"backend": "mistral",
}
],
"models": [

View file

@ -258,8 +258,7 @@ class GenericBackend:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=url,
response=e.response,
headers=e.response.headers,
error=e,
model=model.name,
messages=messages,
temperature=temperature,
@ -327,8 +326,7 @@ class GenericBackend:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=url,
response=e.response,
headers=e.response.headers,
error=e,
model=model.name,
messages=messages,
temperature=temperature,

View file

@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator, Sequence
import json
import os
import types
from typing import TYPE_CHECKING, NamedTuple, cast
from typing import TYPE_CHECKING, Literal, NamedTuple, cast
import httpx
from mistralai.client import Mistral
@ -32,7 +32,10 @@ from mistralai.client.models import (
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.llm.message_utils import (
merge_consecutive_user_messages,
strip_reasoning as strip_reasoning_message,
)
from vibe.core.types import (
AvailableTool,
Content,
@ -168,6 +171,18 @@ class MistralMapper:
for tool_call in tool_calls
]
def strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
return strip_reasoning_message(msg)
ReasoningEffortValue = Literal["none", "high"]
_THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = {
"low": "none",
"medium": "high",
"high": "high",
}
class MistralBackend:
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
@ -253,6 +268,14 @@ class MistralBackend:
) -> LLMChunk:
try:
merged_messages = merge_consecutive_user_messages(messages)
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
if reasoning_effort is not None:
temperature = 1.0
else:
merged_messages = [
strip_reasoning_message(msg) for msg in merged_messages
]
response = await self._get_client().chat.complete_async(
model=model.name,
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
@ -267,6 +290,7 @@ class MistralBackend:
http_headers=extra_headers,
metadata=metadata,
stream=False,
reasoning_effort=reasoning_effort,
)
parsed = (
@ -295,8 +319,7 @@ class MistralBackend:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,
response=e.raw_response,
headers=e.raw_response.headers,
error=e,
model=model.name,
messages=messages,
temperature=temperature,
@ -329,6 +352,14 @@ class MistralBackend:
) -> AsyncGenerator[LLMChunk, None]:
try:
merged_messages = merge_consecutive_user_messages(messages)
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
if reasoning_effort is not None:
temperature = 1.0
else:
merged_messages = [
strip_reasoning_message(msg) for msg in merged_messages
]
stream = await self._get_client().chat.stream_async(
model=model.name,
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
@ -342,6 +373,7 @@ class MistralBackend:
else None,
http_headers=extra_headers,
metadata=metadata,
reasoning_effort=reasoning_effort,
)
correlation_id = stream.response.headers.get("mistral-correlation-id")
async for chunk in stream:
@ -376,8 +408,7 @@ class MistralBackend:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,
response=e.raw_response,
headers=e.raw_response.headers,
error=e,
model=model.name,
messages=messages,
temperature=temperature,

View file

@ -6,7 +6,7 @@ from typing import Any, ClassVar
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.llm.message_utils import merge_consecutive_user_messages, strip_reasoning
from vibe.core.types import (
AvailableTool,
FunctionCall,
@ -107,13 +107,6 @@ class ReasoningAdapter(APIAdapter):
return payload
def _strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
if msg.role != Role.assistant or not msg.reasoning_content:
return msg
return msg.model_copy(
update={"reasoning_content": None, "reasoning_signature": None}
)
def prepare_request( # noqa: PLR0913
self,
*,
@ -130,7 +123,7 @@ class ReasoningAdapter(APIAdapter):
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
if thinking == "off":
merged_messages = [self._strip_reasoning(msg) for msg in merged_messages]
merged_messages = [strip_reasoning(msg) for msg in merged_messages]
converted_messages = [self._convert_message(msg) for msg in merged_messages]
payload = self._build_payload(

View file

@ -6,10 +6,13 @@ import json
from typing import Any
import httpx
from mistralai.client.errors import SDKError
from pydantic import BaseModel, ConfigDict, ValidationError
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
type HttpError = SDKError | httpx.HTTPStatusError
class ErrorDetail(BaseModel):
model_config = ConfigDict(extra="ignore")
@ -111,25 +114,22 @@ class BackendErrorBuilder:
*,
provider: str,
endpoint: str,
response: httpx.Response,
headers: Mapping[str, str] | None,
error: HttpError,
model: str,
messages: Sequence[LLMMessage],
temperature: float,
has_tools: bool,
tool_choice: StrToolChoice | AvailableTool | None,
) -> BackendError:
try:
body_text = response.text
except Exception: # On streaming responses, we can't read the body
body_text = None
response = error.raw_response if isinstance(error, SDKError) else error.response
body_text = cls._read_response_body(response, error)
return BackendError(
provider=provider,
endpoint=endpoint,
status=response.status_code,
reason=response.reason_phrase,
headers=headers or {},
headers=response.headers,
body_text=body_text,
parsed_error=cls._parse_provider_error(body_text),
model=model,
@ -165,6 +165,17 @@ class BackendErrorBuilder:
),
)
@staticmethod
def _read_response_body(response: httpx.Response, error: HttpError) -> str | None:
try:
response.read()
return response.text
except Exception:
pass
if body := getattr(error, "body", None):
return body
return str(error)
@staticmethod
def _parse_provider_error(body_text: str | None) -> str | None:
if not body_text:

View file

@ -5,6 +5,14 @@ from collections.abc import Sequence
from vibe.core.types import LLMMessage, Role
def strip_reasoning(msg: LLMMessage) -> LLMMessage:
if msg.role != Role.assistant or not msg.reasoning_content:
return msg
return msg.model_copy(
update={"reasoning_content": None, "reasoning_signature": None}
)
def merge_consecutive_user_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
"""Merge consecutive user messages into a single message.

View file

@ -0,0 +1,10 @@
from __future__ import annotations
from vibe.core.rewind.manager import (
Checkpoint,
FileSnapshot,
RewindError,
RewindManager,
)
__all__ = ["Checkpoint", "FileSnapshot", "RewindError", "RewindManager"]

192
vibe/core/rewind/manager.py Normal file
View file

@ -0,0 +1,192 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import os
from pathlib import Path
from vibe.core.logger import logger
from vibe.core.types import LLMMessage, MessageList, Role
class RewindError(Exception):
"""Raised when a rewind operation fails."""
@dataclass(frozen=True, slots=True)
class FileSnapshot:
"""Snapshot of a single file's content at a point in time.
content is None if the file did not exist (was created after the snapshot).
"""
path: str
content: bytes | None
@dataclass
class Checkpoint:
"""Snapshot of tracked files taken before a user message."""
message_index: int
files: list[FileSnapshot] = field(default_factory=list)
class RewindManager:
"""Manages conversation rewind: file snapshots, message truncation, and session forking."""
def __init__(
self,
messages: MessageList,
save_messages: Callable[[], Awaitable[None]],
reset_session: Callable[[], None],
) -> None:
self._checkpoints: list[Checkpoint] = []
self._messages = messages
self._save_messages = save_messages
self._reset_session = reset_session
self._is_rewinding = False
self._messages.on_reset(self._on_messages_reset)
# -- Checkpoint management -------------------------------------------------
@property
def checkpoints(self) -> list[Checkpoint]:
return list(self._checkpoints)
def create_checkpoint(self) -> None:
"""Snapshot known files and start a new checkpoint at the current message position.
Files known from the previous checkpoint are re-read from disk so
that each checkpoint captures the actual state at that point in time.
"""
files: list[FileSnapshot] = []
if self._checkpoints:
for snap in self._checkpoints[-1].files:
files.append(self._read_snapshot(snap.path))
self._checkpoints.append(
Checkpoint(message_index=len(self._messages), files=files)
)
def add_snapshot(self, snapshot: FileSnapshot) -> None:
"""Record a file snapshot into every checkpoint that doesn't have it yet."""
for cp in self._checkpoints:
if all(s.path != snapshot.path for s in cp.files):
cp.files.append(snapshot)
def has_file_changes_at(self, message_index: int) -> bool:
"""Check if files have changed since the checkpoint at *message_index*."""
checkpoint = self._get_checkpoint(message_index)
if checkpoint is None:
return False
return self._has_changes_since(checkpoint)
# -- Rewind operations -----------------------------------------------------
def get_rewindable_messages(self) -> list[tuple[int, str]]:
"""Return (message_index, content) for each user message."""
return [
(i, msg.content or "")
for i, msg in enumerate(self._messages)
if msg.role == Role.user and msg.content and not msg.injected
]
async def rewind_to_message(
self, message_index: int, *, restore_files: bool
) -> tuple[str, list[str]]:
"""Rewind the session to the given user message index.
Saves the current session, truncates messages, optionally restores
files, and forks to a new session.
Returns a tuple of (message_content, restore_errors).
Raises:
RewindError: If the message index is invalid or not a user message.
"""
messages: Sequence[LLMMessage] = self._messages
if message_index < 0 or message_index >= len(messages):
raise RewindError(f"Invalid message index: {message_index}")
user_msg = messages[message_index]
if user_msg.role != Role.user:
raise RewindError(f"Message at index {message_index} is not a user message")
message_content = user_msg.content or ""
restore_errors: list[str] = []
if restore_files:
checkpoint = self._get_checkpoint(message_index)
if checkpoint:
restore_errors = self._restore_checkpoint(checkpoint)
await self._save_messages()
self._checkpoints = [
cp for cp in self._checkpoints if cp.message_index < message_index
]
self._is_rewinding = True
try:
self._messages.reset(list(messages[:message_index]))
finally:
self._is_rewinding = False
self._reset_session()
return message_content, restore_errors
# -- Private helpers -------------------------------------------------------
def _get_checkpoint(self, message_index: int) -> Checkpoint | None:
for cp in self._checkpoints:
if cp.message_index == message_index:
return cp
return None
def _restore_checkpoint(self, checkpoint: Checkpoint) -> list[str]:
"""Restore files on disk to match the checkpoint state.
Returns a list of human-readable error messages for files that
could not be restored (empty when everything succeeded).
"""
errors: list[str] = []
for snap in checkpoint.files:
path = Path(snap.path)
if snap.content is None:
if path.exists():
try:
os.remove(path)
except Exception:
errors.append(f"Failed to delete file: {snap.path}")
else:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(snap.content)
except Exception:
errors.append(f"Failed to restore file: {snap.path}")
return errors
@staticmethod
def _has_changes_since(checkpoint: Checkpoint) -> bool:
for snap in checkpoint.files:
try:
current: bytes | None = Path(snap.path).read_bytes()
except FileNotFoundError:
current = None
if current != snap.content:
return True
return False
@staticmethod
def _read_snapshot(path: str) -> FileSnapshot:
try:
content: bytes | None = Path(path).read_bytes()
except FileNotFoundError:
content = None
except Exception:
logger.warning("Failed to read file for checkpoint: %s", path)
content = None
return FileSnapshot(path=path, content=content)
def _on_messages_reset(self) -> None:
"""Called when the message list is reset (session switch, clear, compact, etc.)."""
if not self._is_rewinding:
self._checkpoints.clear()

View file

@ -23,6 +23,8 @@ from typing import (
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from vibe.core.logger import logger
from vibe.core.rewind.manager import FileSnapshot
from vibe.core.types import ToolStreamEvent
from vibe.core.utils.io import read_safe
@ -355,6 +357,30 @@ class BaseTool[
"""
return None
def get_file_snapshot(self, args: ToolArgs) -> FileSnapshot | None:
"""Return a snapshot of the file this tool is about to modify.
Called before ``run()`` so the checkpoint system can capture
the file's state *before* the tool writes to it.
Override in tools that modify files on disk.
"""
return None
@staticmethod
def get_file_snapshot_for_path(path: str) -> FileSnapshot:
file_path = Path(path).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
file_path = file_path.resolve()
try:
content: bytes | None = file_path.read_bytes()
except FileNotFoundError:
content = None
except Exception:
logger.warning("Failed to read file for tool snapshot: %s", file_path)
content = None
return FileSnapshot(path=str(file_path), content=content)
def get_result_extra(self, result: ToolResult) -> str | None:
"""Optional extra context appended to the result text sent to the LLM.

View file

@ -10,6 +10,7 @@ from typing import ClassVar, NamedTuple, final
import anyio
from pydantic import BaseModel, Field
from vibe.core.rewind.manager import FileSnapshot
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -110,6 +111,9 @@ class SearchReplace(
def get_status_text(cls) -> str:
return "Editing files"
def get_file_snapshot(self, args: SearchReplaceArgs) -> FileSnapshot | None:
return self.get_file_snapshot_for_path(args.file_path)
def resolve_permission(self, args: SearchReplaceArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.file_path,

View file

@ -7,6 +7,7 @@ from typing import ClassVar, final
import anyio
from pydantic import BaseModel, Field
from vibe.core.rewind.manager import FileSnapshot
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -75,6 +76,9 @@ class WriteFile(
def get_status_text(cls) -> str:
return "Writing file"
def get_file_snapshot(self, args: WriteFileArgs) -> FileSnapshot | None:
return self.get_file_snapshot_for_path(args.path)
def resolve_permission(self, args: WriteFileArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.path,

View file

@ -215,6 +215,7 @@ class LLMMessage(BaseModel):
role: Role
content: Content | None = None
injected: bool = False
reasoning_content: Content | None = None
reasoning_signature: str | None = None
tool_calls: list[ToolCall] | None = None
@ -440,6 +441,7 @@ class MessageList(Sequence[LLMMessage]):
) -> None:
self._data: list[LLMMessage] = list(initial) if initial else []
self._observer = observer
self._reset_hooks: list[Callable[[], None]] = []
self._silent = False
if self._observer:
for msg in self._data:
@ -460,9 +462,19 @@ class MessageList(Sequence[LLMMessage]):
for msg in msgs:
self.append(msg)
def on_reset(self, hook: Callable[[], None]) -> None:
"""Register a callback that fires whenever the list is reset."""
self._reset_hooks.append(hook)
def reset(self, new: list[LLMMessage]) -> None:
"""Replace contents silently (never notifies)."""
self._data = list(new)
for hook in self._reset_hooks:
hook()
def update_system_prompt(self, new: str) -> None:
"""Update the system prompt in place."""
self._data[0] = LLMMessage(role=Role.system, content=new)
@contextmanager
def silent(self) -> Iterator[None]:

View file

@ -1,4 +1,2 @@
# What's new in v2.6.0
- **Text-to-speech**: Added TTS functionality
- **Standalone resume**: New --resume command for session picker
- **Fine-grained permissions**: Improved permissions granularity and persistence
# What's new in v2.7.0
- **Rewind mode**: Added navigation and forking of conversation history with /rewind