v2.7.0 (#534)
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:
parent
5ea69b547b
commit
6a50d1d521
49 changed files with 3726 additions and 99 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
147
vibe/cli/textual_ui/widgets/rewind_app.py
Normal file
147
vibe/cli/textual_ui/widgets/rewind_app.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
10
vibe/core/rewind/__init__.py
Normal file
10
vibe/core/rewind/__init__.py
Normal 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
192
vibe/core/rewind/manager.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue