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

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