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
49 changed files with 3726 additions and 99 deletions

View File

@@ -55,10 +55,10 @@ jobs:
run: echo github.repository=${{ github.repository }}
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv with caching
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7
with:
version: "latest"
enable-cache: true
@@ -140,7 +140,7 @@ jobs:
steps:
- name: Checkout repository
if: matrix.os != 'windows'
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install Nix
if: matrix.os != 'windows'

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv with caching
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7
@@ -58,7 +58,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv with caching
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7
@@ -94,7 +94,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv with caching
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7

View File

@@ -26,7 +26,7 @@ jobs:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7
- name: Install dependencies
run: uv sync --locked --dev

2
.vscode/launch.json vendored
View File

@@ -1,5 +1,5 @@
{
"version": "2.6.2",
"version": "2.7.0",
"configurations": [
{
"name": "ACP Server",

View File

@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.7.0] - 2026-03-24
### Added
- Rewind mode to navigate and fork conversation history
### Fixed
- Preserve message_id when aggregating streaming LLM chunks
- Improved error handling for SDK response errors
## [2.6.2] - 2026-03-23
### Changed

View File

@@ -38,7 +38,7 @@ runs:
python-version: ${{ inputs.python_version }}
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7
- name: Install Mistral Vibe
shell: bash

View File

@@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "2.6.2"
version = "2.7.0"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-darwin-aarch64-2.6.2.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-darwin-aarch64-2.7.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-darwin-x86_64-2.6.2.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-darwin-x86_64-2.7.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-linux-aarch64-2.6.2.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-linux-aarch64-2.7.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-linux-x86_64-2.6.2.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-linux-x86_64-2.7.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-windows-aarch64-2.6.2.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-windows-aarch64-2.7.0.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.2/vibe-acp-windows-x86_64-2.6.2.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.0/vibe-acp-windows-x86_64-2.7.0.zip"
cmd = "./vibe-acp.exe"

View File

@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.6.2"
version = "2.7.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -38,7 +38,7 @@ dependencies = [
"keyring>=25.6.0",
"markdownify>=1.2.2",
"mcp>=1.14.0",
"mistralai==2.0.0",
"mistralai==2.1.3",
"opentelemetry-api>=1.39.1",
"opentelemetry-exporter-otlp-proto-http>=1.39.1",
"opentelemetry-sdk>=1.39.1",

View File

@@ -28,7 +28,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.2"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.0"
)
assert response.auth_methods == []
@@ -52,7 +52,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.2"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.0"
)
assert response.auth_methods is not None

View File

@@ -13,9 +13,11 @@ the tests will be. Always prefer real API data over manually constructed example
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from typing import ClassVar, Literal
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from mistralai.client.errors import SDKError
from mistralai.client.models import AssistantMessage
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
import pytest
@@ -38,7 +40,7 @@ from vibe.core.config import ModelConfig, ProviderConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.exceptions import BackendError, BackendErrorBuilder
from vibe.core.llm.types import BackendLike
from vibe.core.types import Backend, FunctionCall, LLMChunk, LLMMessage, Role, ToolCall
from vibe.core.utils import get_user_agent
@@ -526,3 +528,237 @@ class TestMistralMapperPrepareMessage:
msg = LLMMessage(role=Role.assistant, content="Hello!")
result = mapper.prepare_message(msg)
assert result.content == "Hello!"
def test_strip_reasoning_removes_reasoning_from_assistant(
self, mapper: MistralMapper
) -> None:
msg = LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="thinking...",
reasoning_signature="sig",
)
stripped = mapper.strip_reasoning(msg)
assert stripped.content == "Answer"
assert stripped.reasoning_content is None
assert stripped.reasoning_signature is None
def test_strip_reasoning_leaves_non_assistant_unchanged(
self, mapper: MistralMapper
) -> None:
msg = LLMMessage(role=Role.user, content="hello")
assert mapper.strip_reasoning(msg) is msg
class TestMistralBackendReasoningEffort:
"""Tests that MistralBackend correctly passes reasoning_effort to the SDK."""
@pytest.fixture
def backend(self) -> MistralBackend:
provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="API_KEY",
)
return MistralBackend(provider=provider)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("thinking", "expected_effort", "expected_temperature"),
[
("off", None, 0.2),
("low", "none", 1.0),
("medium", "high", 1.0),
("high", "high", 1.0),
],
)
async def test_complete_passes_reasoning_effort(
self,
backend: MistralBackend,
thinking: Literal["off", "low", "medium", "high"],
expected_effort: str | None,
expected_temperature: float,
) -> None:
model = ModelConfig(
name="mistral-small-latest",
provider="mistral",
alias="mistral-small",
thinking=thinking,
)
messages = [LLMMessage(role=Role.user, content="hi")]
with patch.object(backend, "_get_client") as mock_get_client:
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "hello"
mock_response.choices[0].message.tool_calls = None
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 5
mock_client.chat.complete_async = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
await backend.complete(
model=model,
messages=messages,
temperature=0.2,
tools=None,
max_tokens=None,
tool_choice=None,
extra_headers=None,
)
call_kwargs = mock_client.chat.complete_async.call_args.kwargs
assert call_kwargs["reasoning_effort"] == expected_effort
assert call_kwargs["temperature"] == expected_temperature
@pytest.mark.asyncio
async def test_complete_strips_reasoning_when_thinking_off(
self, backend: MistralBackend
) -> None:
model = ModelConfig(
name="mistral-small-latest",
provider="mistral",
alias="mistral-small",
thinking="off",
)
messages = [
LLMMessage(role=Role.user, content="hi"),
LLMMessage(
role=Role.assistant, content="answer", reasoning_content="thinking..."
),
LLMMessage(role=Role.user, content="follow up"),
]
with patch.object(backend, "_get_client") as mock_get_client:
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "response"
mock_response.choices[0].message.tool_calls = None
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 5
mock_client.chat.complete_async = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
await backend.complete(
model=model,
messages=messages,
temperature=0.2,
tools=None,
max_tokens=None,
tool_choice=None,
extra_headers=None,
)
call_kwargs = mock_client.chat.complete_async.call_args.kwargs
assert call_kwargs["reasoning_effort"] is None
# The assistant message should have reasoning stripped
converted_msgs = call_kwargs["messages"]
assistant_msg = converted_msgs[1]
assert isinstance(assistant_msg, AssistantMessage)
assert assistant_msg.content == "answer"
class TestBuildHttpErrorBodyReading:
_MESSAGES: ClassVar[list[LLMMessage]] = [LLMMessage(role=Role.user, content="hi")]
_COMMON_KWARGS: ClassVar[dict] = dict(
provider="test",
endpoint="https://api.test.com",
model="test-model",
messages=_MESSAGES,
temperature=0.2,
has_tools=False,
tool_choice=None,
)
def _make_sdk_error(self, response: httpx.Response) -> SDKError:
return SDKError("sdk error", response)
def _make_http_status_error(
self, response: httpx.Response
) -> httpx.HTTPStatusError:
return httpx.HTTPStatusError(
"http error", request=response.request, response=response
)
def test_sdk_error_readable_body(self) -> None:
response = httpx.Response(
400,
json={"message": "invalid temperature"},
request=httpx.Request("POST", "https://api.test.com"),
)
err = BackendErrorBuilder.build_http_error(
error=self._make_sdk_error(response), **self._COMMON_KWARGS
)
assert err.status == 400
assert err.parsed_error == "invalid temperature"
assert "invalid temperature" in err.body_text
def test_http_status_error_readable_body(self) -> None:
response = httpx.Response(
400,
json={"message": "invalid temperature"},
request=httpx.Request("POST", "https://api.test.com"),
)
err = BackendErrorBuilder.build_http_error(
error=self._make_http_status_error(response), **self._COMMON_KWARGS
)
assert err.status == 400
assert err.parsed_error == "invalid temperature"
assert "invalid temperature" in err.body_text
def test_sdk_error_stream_response_falls_back_to_read(self) -> None:
response = httpx.Response(
400,
stream=httpx.ByteStream(b'{"message": "context too long"}'),
request=httpx.Request("POST", "https://api.test.com"),
)
sdk_err = SDKError(
"sdk error", response, body='{"message": "context too long"}'
)
err = BackendErrorBuilder.build_http_error(error=sdk_err, **self._COMMON_KWARGS)
assert err.parsed_error == "context too long"
assert "context too long" in err.body_text
def test_http_status_error_stream_response_falls_back_to_read(self) -> None:
response = httpx.Response(
400,
stream=httpx.ByteStream(b'{"message": "context too long"}'),
request=httpx.Request("POST", "https://api.test.com"),
)
err = BackendErrorBuilder.build_http_error(
error=self._make_http_status_error(response), **self._COMMON_KWARGS
)
assert err.parsed_error == "context too long"
assert "context too long" in err.body_text
def test_sdk_error_unreadable_response_falls_back_to_str(self) -> None:
response = MagicMock(spec=httpx.Response)
response.status_code = 400
response.reason_phrase = "Bad Request"
response.headers = {}
type(response).text = property(lambda self: (_ for _ in ()).throw(Exception))
response.read.side_effect = Exception("closed")
sdk_err = SDKError("sdk msg", response, body='{"message": "context too long"}')
err = BackendErrorBuilder.build_http_error(error=sdk_err, **self._COMMON_KWARGS)
assert err.body_text == '{"message": "context too long"}'
assert err.parsed_error == "context too long"
def test_http_status_error_unreadable_response_falls_back_to_str(self) -> None:
response = MagicMock(spec=httpx.Response)
response.status_code = 400
response.reason_phrase = "Bad Request"
response.headers = {}
type(response).text = property(lambda self: (_ for _ in ()).throw(Exception))
response.read.side_effect = Exception("closed")
response.request = httpx.Request("POST", "https://api.test.com")
http_err = httpx.HTTPStatusError(
"http error with details", request=response.request, response=response
)
err = BackendErrorBuilder.build_http_error(
error=http_err, **self._COMMON_KWARGS
)
assert "http error with details" in err.body_text

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from vibe.cli.textual_ui.windowing.state import LOAD_MORE_BATCH_SIZE, SessionWindowing
from vibe.core.types import LLMMessage, Role
def _msg(
role: Role = Role.user, *, content: str = "x", injected: bool = False
) -> LLMMessage:
return LLMMessage(role=role, content=content, injected=injected)
def test_recompute_backfill_keeps_cursor_when_oldest_widgets_skip_injected_prefix() -> (
None
):
"""min(visible_indices) can sit past injected-only tail slots; backfill must not overlap."""
w = SessionWindowing(LOAD_MORE_BATCH_SIZE)
w.set_backfill([_msg() for _ in range(80)])
assert w.remaining == 80
history = [
*[_msg() for _ in range(80)],
*[_msg(injected=True) for _ in range(5)],
_msg(content="visible"),
]
visible_indices = [85]
has_backfill = w.recompute_backfill(
history, visible_indices=visible_indices, visible_history_widgets_count=1
)
assert has_backfill
assert w.remaining == 80
def test_recompute_backfill_advances_cursor_when_prefix_was_pruned_not_injected() -> (
None
):
"""If DOM lost widgets for non-injected messages, align cursor with oldest remaining widget."""
history = [_msg() for _ in range(100)]
w = SessionWindowing(LOAD_MORE_BATCH_SIZE)
w.set_backfill(history[:70])
assert w.remaining == 70
has_backfill = w.recompute_backfill(
history, visible_indices=[80], visible_history_widgets_count=10
)
assert has_backfill
assert w.remaining == 80

View File

@@ -0,0 +1,308 @@
"""Integration tests for the rewind feature through agent_loop.act().
These tests drive the full pipeline:
act() → create_checkpoint() → _process_one_tool_call() →
get_file_snapshot() → add_snapshot() → tool writes file →
rewind_to_message() → verify file restoration.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.types import BaseEvent, FunctionCall, ToolCall
async def _act_and_collect(agent_loop, prompt: str) -> list[BaseEvent]:
return [ev async for ev in agent_loop.act(prompt)]
def _write_file_tool_call(
path: str, content: str, *, call_id: str = "call_1", overwrite: bool = False
) -> ToolCall:
args = json.dumps({"path": path, "content": content, "overwrite": overwrite})
return ToolCall(
id=call_id, index=0, function=FunctionCall(name="write_file", arguments=args)
)
def _search_replace_tool_call(
file_path: str, search: str, replace: str, *, call_id: str = "call_1"
) -> ToolCall:
content = f"<<<<<<< SEARCH\n{search}\n=======\n{replace}\n>>>>>>> REPLACE"
args = json.dumps({"file_path": file_path, "content": content})
return ToolCall(
id=call_id,
index=0,
function=FunctionCall(name="search_replace", arguments=args),
)
def _bash_tool_call(command: str, *, call_id: str = "call_1") -> ToolCall:
args = json.dumps({"command": command})
return ToolCall(
id=call_id, index=0, function=FunctionCall(name="bash", arguments=args)
)
def _make_agent_loop(backend: FakeBackend):
config = build_test_vibe_config(
enabled_tools=["write_file", "search_replace", "bash"],
tools={
"write_file": {"permission": "always"},
"search_replace": {"permission": "always"},
"bash": {"permission": "always"},
},
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
)
return build_test_agent_loop(
config=config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
)
@pytest.mark.asyncio
class TestRewindIntegration:
async def test_write_file_rewind_restores_original(
self, tmp_working_directory: Path
) -> None:
"""Write a file in turn 1, rewind to turn 1 → file should not exist."""
target = tmp_working_directory / "hello.txt"
backend = FakeBackend([
[
mock_llm_chunk(
content="Creating file.",
tool_calls=[_write_file_tool_call(str(target), "hello world")],
)
],
[mock_llm_chunk(content="Done.")],
])
agent_loop = _make_agent_loop(backend)
await _act_and_collect(agent_loop, "create hello.txt")
assert target.read_text() == "hello world"
rm = agent_loop.rewind_manager
rewindable = rm.get_rewindable_messages()
assert len(rewindable) == 1
await rm.rewind_to_message(rewindable[0][0], restore_files=True)
assert not target.exists()
async def test_search_replace_rewind_restores_previous_version(
self, tmp_working_directory: Path
) -> None:
"""Edit a pre-existing file with search_replace, rewind restores original."""
target = tmp_working_directory / "config.yaml"
target.write_text("key: original\n", encoding="utf-8")
backend = FakeBackend([
[
mock_llm_chunk(
content="Updating config.",
tool_calls=[
_search_replace_tool_call(
str(target), "key: original", "key: modified"
)
],
)
],
[mock_llm_chunk(content="Updated.")],
])
agent_loop = _make_agent_loop(backend)
await _act_and_collect(agent_loop, "update config")
assert target.read_text() == "key: modified\n"
rm = agent_loop.rewind_manager
rewindable = rm.get_rewindable_messages()
await rm.rewind_to_message(rewindable[0][0], restore_files=True)
assert target.read_text() == "key: original\n"
async def test_write_then_search_replace_rewind_to_middle(
self, tmp_working_directory: Path
) -> None:
"""Turn 1 creates a file with write_file, turn 2 patches it with
search_replace. Rewind to turn 2 restores the turn 1 version.
"""
target = tmp_working_directory / "app.py"
backend = FakeBackend([
# Turn 1: create the file
[
mock_llm_chunk(
content="Creating.",
tool_calls=[
_write_file_tool_call(str(target), "def main():\n pass\n")
],
)
],
[mock_llm_chunk(content="Created.")],
# Turn 2: patch with search_replace
[
mock_llm_chunk(
content="Updating.",
tool_calls=[
_search_replace_tool_call(
str(target),
" pass",
' print("hello")',
call_id="call_2",
)
],
)
],
[mock_llm_chunk(content="Updated.")],
])
agent_loop = _make_agent_loop(backend)
await _act_and_collect(agent_loop, "create app.py")
assert "pass" in target.read_text()
await _act_and_collect(agent_loop, "update app.py")
assert 'print("hello")' in target.read_text()
rm = agent_loop.rewind_manager
rewindable = rm.get_rewindable_messages()
assert len(rewindable) == 2
await rm.rewind_to_message(rewindable[1][0], restore_files=True)
assert target.read_text() == "def main():\n pass\n"
async def test_rewind_without_restore_keeps_files(
self, tmp_working_directory: Path
) -> None:
"""Rewind with restore_files=False keeps the file as-is."""
target = tmp_working_directory / "data.json"
backend = FakeBackend([
[
mock_llm_chunk(
content="Writing.",
tool_calls=[_write_file_tool_call(str(target), '{"a": 1}')],
)
],
[mock_llm_chunk(content="Done.")],
])
agent_loop = _make_agent_loop(backend)
await _act_and_collect(agent_loop, "write data")
assert target.read_text() == '{"a": 1}'
rm = agent_loop.rewind_manager
rewindable = rm.get_rewindable_messages()
await rm.rewind_to_message(rewindable[0][0], restore_files=False)
assert target.read_text() == '{"a": 1}'
async def test_rewind_then_new_turn(self, tmp_working_directory: Path) -> None:
"""After rewind, a new turn creates fresh checkpoints that work correctly."""
target = tmp_working_directory / "code.py"
backend = FakeBackend([
# Turn 1
[
mock_llm_chunk(
content="v1.", tool_calls=[_write_file_tool_call(str(target), "v1")]
)
],
[mock_llm_chunk(content="ok")],
# Turn 2
[
mock_llm_chunk(
content="v2.",
tool_calls=[
_write_file_tool_call(
str(target), "v2", call_id="call_2", overwrite=True
)
],
)
],
[mock_llm_chunk(content="ok")],
# Turn 3 (after rewind, new turn)
[
mock_llm_chunk(
content="v2bis.",
tool_calls=[
_write_file_tool_call(
str(target), "v2bis", call_id="call_3", overwrite=True
)
],
)
],
[mock_llm_chunk(content="ok")],
])
agent_loop = _make_agent_loop(backend)
await _act_and_collect(agent_loop, "turn1")
await _act_and_collect(agent_loop, "turn2")
assert target.read_text() == "v2"
rm = agent_loop.rewind_manager
rewindable = rm.get_rewindable_messages()
await rm.rewind_to_message(rewindable[1][0], restore_files=True)
assert target.read_text() == "v1"
await _act_and_collect(agent_loop, "turn2bis")
assert target.read_text() == "v2bis"
rewindable = rm.get_rewindable_messages()
await rm.rewind_to_message(rewindable[1][0], restore_files=True)
assert target.read_text() == "v1"
async def test_rewind_restores_file_deleted_by_bash(
self, tmp_working_directory: Path
) -> None:
"""A tracked file deleted via bash in turn 2 is restored on rewind.
Turn 1: create the file with write_file (file becomes tracked).
Turn 2: delete it with bash (bash has no snapshot, but
create_checkpoint re-reads known files).
Rewind to turn 2 → file restored to its turn-1 content.
"""
target = tmp_working_directory / "important.txt"
backend = FakeBackend([
# Turn 1: create the file
[
mock_llm_chunk(
content="Creating.",
tool_calls=[_write_file_tool_call(str(target), "precious data")],
)
],
[mock_llm_chunk(content="Created.")],
# Turn 2: delete it via bash
[
mock_llm_chunk(
content="Deleting.",
tool_calls=[_bash_tool_call(f"rm {target}", call_id="call_2")],
)
],
[mock_llm_chunk(content="Deleted.")],
])
agent_loop = _make_agent_loop(backend)
await _act_and_collect(agent_loop, "create file")
assert target.read_text() == "precious data"
await _act_and_collect(agent_loop, "delete file")
assert not target.exists()
rm = agent_loop.rewind_manager
rewindable = rm.get_rewindable_messages()
assert len(rewindable) == 2
# Rewind to turn 2 → restores the file to its state before turn 2
await rm.rewind_to_message(rewindable[1][0], restore_files=True)
assert target.exists()
assert target.read_text() == "precious data"

View File

@@ -0,0 +1,594 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from vibe.core.rewind.manager import FileSnapshot, RewindError, RewindManager
from vibe.core.types import LLMMessage, MessageList, Role
def _make_messages(*contents: str) -> MessageList:
"""Create a MessageList with a system message followed by user/assistant pairs."""
msgs = MessageList([LLMMessage(role=Role.system, content="system")])
for content in contents:
msgs.append(LLMMessage(role=Role.user, content=content))
msgs.append(LLMMessage(role=Role.assistant, content=f"reply to {content}"))
return msgs
def _snap(path: Path) -> FileSnapshot:
"""Create a FileSnapshot by reading a file (or None if missing)."""
resolved = str(path.resolve())
try:
content: bytes | None = path.read_bytes()
except FileNotFoundError:
content = None
return FileSnapshot(path=resolved, content=content)
def _make_manager(
messages: MessageList,
) -> tuple[RewindManager, list[bool], list[bool]]:
save_calls: list[bool] = []
reset_calls: list[bool] = []
async def save_messages() -> None:
save_calls.append(True)
def reset_session() -> None:
reset_calls.append(True)
mgr = RewindManager(
messages=messages, save_messages=save_messages, reset_session=reset_session
)
return mgr, save_calls, reset_calls
class TestCheckpoints:
def test_create_checkpoint_carries_forward_snapshots(self, tmp_path: Path) -> None:
messages = _make_messages("hello", "world")
mgr, _, _ = _make_manager(messages)
f = tmp_path / "f.txt"
f.write_text("v1", encoding="utf-8")
mgr.create_checkpoint()
mgr.add_snapshot(_snap(f))
f.write_text("v2", encoding="utf-8")
mgr.create_checkpoint()
# Second checkpoint should have re-read the file
assert len(mgr.checkpoints) == 2
assert mgr.checkpoints[1].files[0].content == b"v2"
def test_add_snapshot_to_all_checkpoints(self, tmp_path: Path) -> None:
messages = _make_messages("hello", "world")
mgr, _, _ = _make_manager(messages)
mgr.create_checkpoint()
mgr.create_checkpoint()
f = tmp_path / "late.txt"
f.write_text("content", encoding="utf-8")
mgr.add_snapshot(_snap(f))
resolved = str(f.resolve())
assert any(s.path == resolved for s in mgr.checkpoints[0].files)
assert any(s.path == resolved for s in mgr.checkpoints[1].files)
def test_add_snapshot_no_duplicate(self, tmp_path: Path) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
mgr.create_checkpoint()
f = tmp_path / "f.txt"
f.write_text("content", encoding="utf-8")
mgr.add_snapshot(_snap(f))
mgr.add_snapshot(_snap(f))
resolved = str(f.resolve())
matches = [s for s in mgr.checkpoints[0].files if s.path == resolved]
assert len(matches) == 1
def test_has_changes_detects_new_file(self, tmp_path: Path) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
f = tmp_path / "new.txt"
mgr.create_checkpoint()
mgr.add_snapshot(FileSnapshot(path=str(f.resolve()), content=None))
assert not mgr.has_file_changes_at(len(messages))
f.write_text("created", encoding="utf-8")
assert mgr.has_file_changes_at(len(messages))
def test_has_changes_false_when_unchanged(self, tmp_path: Path) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
f = tmp_path / "f.txt"
f.write_text("content", encoding="utf-8")
mgr.create_checkpoint()
mgr.add_snapshot(_snap(f))
assert not mgr.has_file_changes_at(len(messages))
def test_has_file_changes_at_no_checkpoint(self) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
assert not mgr.has_file_changes_at(1)
class TestRewind:
def test_get_rewindable_messages(self) -> None:
messages = _make_messages("hello", "world")
mgr, _, _ = _make_manager(messages)
result = mgr.get_rewindable_messages()
assert len(result) == 2
assert result[0] == (1, "hello")
assert result[1] == (3, "world")
def test_get_rewindable_messages_excludes_injected(self) -> None:
messages = _make_messages("hello")
# Insert an injected middleware message between turns
messages.append(
LLMMessage(role=Role.user, content="plan mode reminder", injected=True)
)
messages.append(LLMMessage(role=Role.user, content="world"))
messages.append(LLMMessage(role=Role.assistant, content="reply to world"))
mgr, _, _ = _make_manager(messages)
result = mgr.get_rewindable_messages()
assert len(result) == 2
assert result[0] == (1, "hello")
# Index 3 is the injected message — it must be skipped
assert result[1] == (4, "world")
@pytest.mark.asyncio
async def test_rewind_to_message(self) -> None:
messages = _make_messages("hello", "world")
mgr, save_calls, reset_calls = _make_manager(messages)
content, errors = await mgr.rewind_to_message(3, restore_files=False)
assert content == "world"
assert errors == []
assert len(save_calls) == 1
assert len(reset_calls) == 1
assert len(messages) == 3
@pytest.mark.asyncio
async def test_rewind_to_message_invalid_index(self) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
with pytest.raises(RewindError, match="Invalid message index"):
await mgr.rewind_to_message(99, restore_files=False)
@pytest.mark.asyncio
async def test_rewind_to_message_not_user(self) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
with pytest.raises(RewindError, match="not a user message"):
await mgr.rewind_to_message(2, restore_files=False)
def test_messages_reset_clears_checkpoints(self) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
mgr.create_checkpoint()
assert len(mgr.checkpoints) == 1
messages.reset([LLMMessage(role=Role.system, content="system")])
assert len(mgr.checkpoints) == 0
@pytest.mark.asyncio
async def test_rewind_preserves_earlier_checkpoints(self) -> None:
messages = _make_messages("hello", "world")
mgr, _, _ = _make_manager(messages)
mgr.create_checkpoint()
mgr._checkpoints[-1].message_index = 1
mgr.create_checkpoint()
mgr._checkpoints[-1].message_index = 3
assert len(mgr.checkpoints) == 2
await mgr.rewind_to_message(3, restore_files=False)
assert len(mgr.checkpoints) == 1
def test_update_system_prompt_preserves_checkpoints(self) -> None:
"""Switching agents via shift+tab calls update_system_prompt which must
NOT clear rewind checkpoints (unlike a full reset).
"""
messages = _make_messages("hello", "world")
mgr, _, _ = _make_manager(messages)
mgr.create_checkpoint()
mgr._checkpoints[-1].message_index = 1
mgr.create_checkpoint()
mgr._checkpoints[-1].message_index = 3
assert len(mgr.checkpoints) == 2
# Simulate shift+tab agent switch: only the system prompt changes
messages.update_system_prompt("new agent system prompt")
assert len(mgr.checkpoints) == 2
assert messages[0].content == "new agent system prompt"
def test_create_checkpoint_uses_current_message_count(self) -> None:
messages = _make_messages("hello")
mgr, _, _ = _make_manager(messages)
mgr.create_checkpoint()
assert mgr.checkpoints[0].message_index == len(messages)
class _Turn:
"""Helper that simulates one conversation turn."""
def __init__(self, mgr: RewindManager, messages: MessageList) -> None:
self._mgr = mgr
self._messages = messages
def begin(self, user_msg: str) -> None:
"""Start a new turn: create checkpoint then append user message.
This mirrors agent_loop.act() which calls create_checkpoint()
*before* the user message is added to the message list.
"""
self._mgr.create_checkpoint()
self._messages.append(LLMMessage(role=Role.user, content=user_msg))
def tool_write(self, path: Path, content: str) -> None:
"""Simulate a tool writing to a file (snapshot → write)."""
self._mgr.add_snapshot(_snap(path))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def tool_delete(self, path: Path) -> None:
"""Simulate a tool deleting a file (snapshot → unlink)."""
self._mgr.add_snapshot(_snap(path))
path.unlink()
def end(self, assistant_reply: str = "ok") -> None:
"""End the turn: append assistant reply."""
self._messages.append(LLMMessage(role=Role.assistant, content=assistant_reply))
@pytest.mark.asyncio
class TestRewindScenarios:
@staticmethod
def _setup() -> tuple[RewindManager, MessageList, _Turn]:
messages = MessageList([LLMMessage(role=Role.system, content="system")])
mgr, _, _ = _make_manager(messages)
return mgr, messages, _Turn(mgr, messages)
async def test_edit_file_across_turns_rewind_to_middle(
self, tmp_path: Path
) -> None:
"""A file is edited every turn. Rewinding restores the version from
*before* the target turn.
"""
mgr, messages, turn = self._setup()
f = tmp_path / "app.py"
f.write_text("v0", encoding="utf-8")
turn.begin("turn1")
turn.tool_write(f, "v1")
turn.end()
turn.begin("turn2")
turn.tool_write(f, "v2")
turn.end()
turn.begin("turn3")
turn.tool_write(f, "v3")
turn.end()
# Rewind to turn2 (message index 3) → file should be v1
rewindable = mgr.get_rewindable_messages()
turn2_idx = rewindable[1][0]
await mgr.rewind_to_message(turn2_idx, restore_files=True)
assert f.read_text(encoding="utf-8") == "v1"
async def test_file_created_then_rewind_before_creation(
self, tmp_path: Path
) -> None:
"""A file that didn't exist before should be deleted on rewind."""
mgr, messages, turn = self._setup()
new_file = tmp_path / "generated.py"
turn.begin("turn1")
turn.end()
turn.begin("turn2")
turn.tool_write(new_file, "print('hello')")
turn.end()
turn1_idx = mgr.get_rewindable_messages()[0][0]
await mgr.rewind_to_message(turn1_idx, restore_files=True)
assert not new_file.exists()
async def test_file_deleted_by_tool_rewind_restores(self, tmp_path: Path) -> None:
"""Rewinding past a deletion restores the file."""
mgr, _, turn = self._setup()
f = tmp_path / "config.yaml"
f.write_text("key: value", encoding="utf-8")
turn.begin("turn1")
turn.tool_write(f, "key: value") # touch to start tracking
turn.end()
turn.begin("turn2")
turn.tool_delete(f)
turn.end()
assert not f.exists()
turn2_idx = mgr.get_rewindable_messages()[1][0]
await mgr.rewind_to_message(turn2_idx, restore_files=True)
assert f.exists()
assert f.read_text(encoding="utf-8") == "key: value"
async def test_mixed_create_and_edit(self, tmp_path: Path) -> None:
"""Multiple files: one pre-existing and edited, one created mid-session."""
mgr, messages, turn = self._setup()
existing = tmp_path / "main.py"
existing.write_text("original", encoding="utf-8")
turn.begin("turn1")
turn.tool_write(existing, "modified")
turn.end()
turn.begin("turn2")
new_file = tmp_path / "utils.py"
turn.tool_write(new_file, "def helper(): ...")
turn.tool_write(existing, "modified again")
turn.end()
turn1_idx = mgr.get_rewindable_messages()[0][0]
await mgr.rewind_to_message(turn1_idx, restore_files=True)
assert existing.read_text(encoding="utf-8") == "original"
assert not new_file.exists()
async def test_user_manual_edit_between_turns(self, tmp_path: Path) -> None:
"""If the user edits a file between turns, the checkpoint at the next
turn captures the user's version, so rewind restores it.
"""
mgr, _, turn = self._setup()
f = tmp_path / "readme.md"
f.write_text("initial", encoding="utf-8")
turn.begin("turn1")
turn.tool_write(f, "tool wrote this")
turn.end()
# User manually edits the file outside the tool loop
f.write_text("user edited this", encoding="utf-8")
turn.begin("turn2")
turn.tool_write(f, "tool overwrote user")
turn.end()
# Rewind to turn2 → should restore the user's manual edit
turn2_idx = mgr.get_rewindable_messages()[1][0]
await mgr.rewind_to_message(turn2_idx, restore_files=True)
assert f.read_text(encoding="utf-8") == "user edited this"
async def test_rewind_without_restore(self, tmp_path: Path) -> None:
"""Rewinding with restore_files=False truncates messages but keeps
files as they are.
"""
mgr, messages, turn = self._setup()
f = tmp_path / "data.json"
f.write_text("{}", encoding="utf-8")
turn.begin("turn1")
turn.tool_write(f, '{"a": 1}')
turn.end()
turn.begin("turn2")
turn.tool_write(f, '{"a": 1, "b": 2}')
turn.end()
turn1_idx = mgr.get_rewindable_messages()[0][0]
await mgr.rewind_to_message(turn1_idx, restore_files=False)
# File untouched
assert f.read_text(encoding="utf-8") == '{"a": 1, "b": 2}'
# But messages were truncated
assert len(messages) == 1 # only system message
async def test_rewind_then_new_turns_then_rewind_again(
self, tmp_path: Path
) -> None:
"""After a rewind, new turns create new checkpoints. A second rewind
should work correctly with the new history.
"""
mgr, _, turn = self._setup()
f = tmp_path / "code.py"
f.write_text("v0", encoding="utf-8")
turn.begin("turn1")
turn.tool_write(f, "v1")
turn.end()
turn.begin("turn2")
turn.tool_write(f, "v2")
turn.end()
# Rewind to turn2
turn2_idx = mgr.get_rewindable_messages()[1][0]
await mgr.rewind_to_message(turn2_idx, restore_files=True)
assert f.read_text(encoding="utf-8") == "v1"
# New turn after rewind
turn.begin("turn2-bis")
turn.tool_write(f, "v2-bis")
turn.end()
turn.begin("turn3-bis")
turn.tool_write(f, "v3-bis")
turn.end()
# Rewind to turn2-bis
turn2bis_idx = mgr.get_rewindable_messages()[1][0]
await mgr.rewind_to_message(turn2bis_idx, restore_files=True)
assert f.read_text(encoding="utf-8") == "v1"
async def test_agent_switch_between_turns_preserves_rewind(
self, tmp_path: Path
) -> None:
"""Pressing shift+tab between two messages switches agents, which calls
update_system_prompt. Checkpoints must survive so a subsequent rewind
restores files correctly.
"""
mgr, messages, turn = self._setup()
f = tmp_path / "main.py"
f.write_text("v0", encoding="utf-8")
turn.begin("turn1")
turn.tool_write(f, "v1")
turn.end()
# User presses shift+tab → agent switch → system prompt replaced
messages.update_system_prompt("switched agent prompt")
turn.begin("turn2")
turn.tool_write(f, "v2")
turn.end()
# Rewind to turn2 should restore "v1"
turn2_idx = mgr.get_rewindable_messages()[1][0]
await mgr.rewind_to_message(turn2_idx, restore_files=True)
assert f.read_text(encoding="utf-8") == "v1"
async def test_binary_file_snapshot_and_restore(self, tmp_path: Path) -> None:
"""Binary files (non-UTF-8) are snapshotted and restored correctly."""
mgr, _, turn = self._setup()
f = tmp_path / "image.bin"
original = bytes(range(256))
f.write_bytes(original)
turn.begin("turn1")
mgr.add_snapshot(_snap(f))
f.write_bytes(b"\x00" * 256)
turn.end()
turn1_idx = mgr.get_rewindable_messages()[0][0]
await mgr.rewind_to_message(turn1_idx, restore_files=True)
assert f.read_bytes() == original
async def test_create_edit_delete_full_lifecycle(self, tmp_path: Path) -> None:
"""File goes through create → edit → delete. Rewind to each point
restores the correct state.
"""
mgr, _, turn = self._setup()
f = tmp_path / "temp.txt"
turn.begin("turn1")
turn.tool_write(f, "created")
turn.end()
turn.begin("turn2")
turn.tool_write(f, "edited")
turn.end()
turn.begin("turn3")
turn.tool_delete(f)
turn.end()
assert not f.exists()
# Rewind to turn3 → file should be "edited" (state before deletion)
turn3_idx = mgr.get_rewindable_messages()[2][0]
await mgr.rewind_to_message(turn3_idx, restore_files=True)
assert f.read_text(encoding="utf-8") == "edited"
async def test_user_creates_file_tool_overwrites(self, tmp_path: Path) -> None:
"""User creates a file manually before a turn. The tool overwrites it.
Rewind restores the user's version.
"""
mgr, _, turn = self._setup()
f = tmp_path / "notes.txt"
turn.begin("turn1")
turn.end()
# User creates the file manually between turns
f.write_text("user notes", encoding="utf-8")
turn.begin("turn2")
turn.tool_write(f, "overwritten by tool")
turn.end()
turn2_idx = mgr.get_rewindable_messages()[1][0]
await mgr.rewind_to_message(turn2_idx, restore_files=True)
assert f.read_text(encoding="utf-8") == "user notes"
async def test_nested_directory_files(self, tmp_path: Path) -> None:
"""Files in nested directories are restored including parent dirs."""
mgr, _, turn = self._setup()
deep = tmp_path / "src" / "pkg" / "module.py"
turn.begin("turn1")
turn.tool_write(deep, "def foo(): pass")
turn.end()
turn.begin("turn2")
turn.tool_write(deep, "def foo(): return 42")
turn.end()
turn1_idx = mgr.get_rewindable_messages()[0][0]
# Delete everything
deep.unlink()
(tmp_path / "src" / "pkg").rmdir()
(tmp_path / "src").rmdir()
await mgr.rewind_to_message(turn1_idx, restore_files=True)
# File didn't exist before turn1 → should be deleted
assert not deep.exists()
async def test_rewind_restores_errors_collected(self, tmp_path: Path) -> None:
"""When removing a file during rewind fails, errors are returned in the tuple."""
mgr, _, turn = self._setup()
created_file = tmp_path / "locked.txt"
turn.begin("turn1")
turn.end()
turn.begin("turn2")
# Snapshot runs before write → earlier checkpoints record content=None
turn.tool_write(created_file, "created in turn2")
turn.end()
turn1_idx = mgr.get_rewindable_messages()[0][0]
with patch(
"vibe.core.rewind.manager.os.remove",
side_effect=OSError("mocked removal failure"),
):
_, errors = await mgr.rewind_to_message(turn1_idx, restore_files=True)
assert len(errors) == 1
assert "Failed to delete file" in errors[0]
assert "locked.txt" in errors[0]

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,204 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #292929;font-weight: bold }
.terminal-r6 { fill: #9a9b99 }
.terminal-r7 { fill: #cc555a }
.terminal-r8 { fill: #608ab1;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">RewindSnapshotApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#ff8205" x="24.4" y="587.1" width="158.6" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="337.2" textLength="146.4" clip-path="url(#terminal-line-13)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="337.2" textLength="122" clip-path="url(#terminal-line-13)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="337.2" textLength="183" clip-path="url(#terminal-line-13)">devstral-latest</text><text class="terminal-r1" x="622.2" y="337.2" textLength="256.2" clip-path="url(#terminal-line-13)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="361.6" textLength="414.8" clip-path="url(#terminal-line-14)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="0" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">Type&#160;</text><text class="terminal-r3" x="231.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">/help</text><text class="terminal-r1" x="292.8" y="386" textLength="256.2" clip-path="url(#terminal-line-15)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="24.4" y="605.6" textLength="158.6" clip-path="url(#terminal-line-24)">third&#160;message</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r6" x="0" y="678.8" textLength="719.8" clip-path="url(#terminal-line-27)">┌──────────────────────────────────────────────────────────</text><text class="terminal-r7" x="719.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r6" x="1439.6" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">─┐</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r6" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r8" x="24.4" y="703.2" textLength="292.8" clip-path="url(#terminal-line-28)">Rewind&#160;to:&#160;third&#160;message</text><text class="terminal-r7" x="719.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="744.2" y="703.2" textLength="305" clip-path="url(#terminal-line-28)">Invalid&#160;message&#160;index:&#160;99</text><text class="terminal-r6" x="1451.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r6" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r7" x="719.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="1451.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r6" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r8" x="24.4" y="752" textLength="475.8" clip-path="url(#terminal-line-30)">&#160;1.&#160;Edit&#160;&amp;&#160;restore&#160;files&#160;to&#160;this&#160;point</text><text class="terminal-r6" x="1451.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r6" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="24.4" y="776.4" textLength="402.6" clip-path="url(#terminal-line-31)">&#160;&#160;2.&#160;Edit&#160;without&#160;restoring&#160;files</text><text class="terminal-r6" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r6" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r6" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r6" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r6" x="24.4" y="825.2" textLength="939.4" clip-path="url(#terminal-line-33)">Alt+↑↓&#160;or&#160;Ctrl+P/N&#160;browse&#160;messages&#160;&#160;↑↓&#160;pick&#160;option&#160;&#160;Enter&#160;confirm&#160;&#160;ESC&#160;cancel</text><text class="terminal-r6" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r6" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,201 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #9a9b99 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">RewindSnapshotApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="0" y="410.4" textLength="134.2" clip-path="url(#terminal-line-16)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="410.4" textLength="146.4" clip-path="url(#terminal-line-16)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="410.4" textLength="122" clip-path="url(#terminal-line-16)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="410.4" textLength="183" clip-path="url(#terminal-line-16)">devstral-latest</text><text class="terminal-r1" x="622.2" y="410.4" textLength="256.2" clip-path="url(#terminal-line-16)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="0" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="434.8" textLength="414.8" clip-path="url(#terminal-line-17)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="0" y="459.2" textLength="134.2" clip-path="url(#terminal-line-18)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">Type&#160;</text><text class="terminal-r3" x="231.8" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">/help</text><text class="terminal-r1" x="292.8" y="459.2" textLength="256.2" clip-path="url(#terminal-line-18)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r2" x="24.4" y="532.4" textLength="158.6" clip-path="url(#terminal-line-21)">first&#160;message</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="317.2" clip-path="url(#terminal-line-23)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="170.8" clip-path="url(#terminal-line-25)">second&#160;message</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r2" x="24.4" y="678.8" textLength="158.6" clip-path="url(#terminal-line-27)">third&#160;message</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #292929;font-weight: bold }
.terminal-r6 { fill: #9a9b99 }
.terminal-r7 { fill: #608ab1;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">RewindSnapshotApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#ff8205" x="24.4" y="587.1" width="158.6" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="337.2" textLength="146.4" clip-path="url(#terminal-line-13)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="337.2" textLength="122" clip-path="url(#terminal-line-13)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="337.2" textLength="183" clip-path="url(#terminal-line-13)">devstral-latest</text><text class="terminal-r1" x="622.2" y="337.2" textLength="256.2" clip-path="url(#terminal-line-13)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="361.6" textLength="414.8" clip-path="url(#terminal-line-14)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="0" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">Type&#160;</text><text class="terminal-r3" x="231.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">/help</text><text class="terminal-r1" x="292.8" y="386" textLength="256.2" clip-path="url(#terminal-line-15)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="24.4" y="605.6" textLength="158.6" clip-path="url(#terminal-line-24)">third&#160;message</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r6" x="0" y="678.8" textLength="1464" clip-path="url(#terminal-line-27)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r6" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r7" x="24.4" y="703.2" textLength="292.8" clip-path="url(#terminal-line-28)">Rewind&#160;to:&#160;third&#160;message</text><text class="terminal-r6" x="1451.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r6" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="1451.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r6" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r7" x="24.4" y="752" textLength="475.8" clip-path="url(#terminal-line-30)">&#160;1.&#160;Edit&#160;&amp;&#160;restore&#160;files&#160;to&#160;this&#160;point</text><text class="terminal-r6" x="1451.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r6" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="24.4" y="776.4" textLength="402.6" clip-path="url(#terminal-line-31)">&#160;&#160;2.&#160;Edit&#160;without&#160;restoring&#160;files</text><text class="terminal-r6" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r6" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r6" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r6" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r6" x="24.4" y="825.2" textLength="939.4" clip-path="url(#terminal-line-33)">Alt+↑↓&#160;or&#160;Ctrl+P/N&#160;browse&#160;messages&#160;&#160;↑↓&#160;pick&#160;option&#160;&#160;Enter&#160;confirm&#160;&#160;ESC&#160;cancel</text><text class="terminal-r6" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r6" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #292929;font-weight: bold }
.terminal-r6 { fill: #9a9b99 }
.terminal-r7 { fill: #608ab1;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">RewindSnapshotApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#ff8205" x="24.4" y="538.3" width="170.8" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="337.2" textLength="146.4" clip-path="url(#terminal-line-13)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="337.2" textLength="122" clip-path="url(#terminal-line-13)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="337.2" textLength="183" clip-path="url(#terminal-line-13)">devstral-latest</text><text class="terminal-r1" x="622.2" y="337.2" textLength="256.2" clip-path="url(#terminal-line-13)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="361.6" textLength="414.8" clip-path="url(#terminal-line-14)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="0" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">Type&#160;</text><text class="terminal-r3" x="231.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">/help</text><text class="terminal-r1" x="292.8" y="386" textLength="256.2" clip-path="url(#terminal-line-15)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r5" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r2" x="24.4" y="605.6" textLength="158.6" clip-path="url(#terminal-line-24)">third&#160;message</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r6" x="0" y="678.8" textLength="1464" clip-path="url(#terminal-line-27)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r6" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r7" x="24.4" y="703.2" textLength="305" clip-path="url(#terminal-line-28)">Rewind&#160;to:&#160;second&#160;message</text><text class="terminal-r6" x="1451.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r6" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="1451.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r6" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r7" x="24.4" y="752" textLength="475.8" clip-path="url(#terminal-line-30)">&#160;1.&#160;Edit&#160;&amp;&#160;restore&#160;files&#160;to&#160;this&#160;point</text><text class="terminal-r6" x="1451.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r6" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="24.4" y="776.4" textLength="402.6" clip-path="url(#terminal-line-31)">&#160;&#160;2.&#160;Edit&#160;without&#160;restoring&#160;files</text><text class="terminal-r6" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r6" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r6" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r6" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r6" x="24.4" y="825.2" textLength="939.4" clip-path="url(#terminal-line-33)">Alt+↑↓&#160;or&#160;Ctrl+P/N&#160;browse&#160;messages&#160;&#160;↑↓&#160;pick&#160;option&#160;&#160;Enter&#160;confirm&#160;&#160;ESC&#160;cancel</text><text class="terminal-r6" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r6" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #292929;font-weight: bold }
.terminal-r6 { fill: #9a9b99 }
.terminal-r7 { fill: #608ab1;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">RewindSnapshotApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#ff8205" x="24.4" y="587.1" width="158.6" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="0" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="337.2" textLength="146.4" clip-path="url(#terminal-line-13)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="337.2" textLength="122" clip-path="url(#terminal-line-13)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="337.2" textLength="183" clip-path="url(#terminal-line-13)">devstral-latest</text><text class="terminal-r1" x="622.2" y="337.2" textLength="256.2" clip-path="url(#terminal-line-13)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="0" y="361.6" textLength="134.2" clip-path="url(#terminal-line-14)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="361.6" textLength="414.8" clip-path="url(#terminal-line-14)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="0" y="386" textLength="134.2" clip-path="url(#terminal-line-15)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">Type&#160;</text><text class="terminal-r3" x="231.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">/help</text><text class="terminal-r1" x="292.8" y="386" textLength="256.2" clip-path="url(#terminal-line-15)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r2" x="24.4" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">first&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="317.2" clip-path="url(#terminal-line-20)">Hello!&#160;How&#160;can&#160;I&#160;help&#160;you?</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r2" x="24.4" y="556.8" textLength="170.8" clip-path="url(#terminal-line-22)">second&#160;message</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="24.4" y="605.6" textLength="158.6" clip-path="url(#terminal-line-24)">third&#160;message</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r6" x="0" y="678.8" textLength="1464" clip-path="url(#terminal-line-27)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r6" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r7" x="24.4" y="703.2" textLength="292.8" clip-path="url(#terminal-line-28)">Rewind&#160;to:&#160;third&#160;message</text><text class="terminal-r6" x="1451.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r6" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="1451.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r6" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r7" x="24.4" y="752" textLength="475.8" clip-path="url(#terminal-line-30)">&#160;1.&#160;Edit&#160;&amp;&#160;restore&#160;files&#160;to&#160;this&#160;point</text><text class="terminal-r6" x="1451.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r6" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="24.4" y="776.4" textLength="402.6" clip-path="url(#terminal-line-31)">&#160;&#160;2.&#160;Edit&#160;without&#160;restoring&#160;files</text><text class="terminal-r6" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r6" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r6" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r6" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r6" x="24.4" y="825.2" textLength="939.4" clip-path="url(#terminal-line-33)">Alt+↑↓&#160;or&#160;Ctrl+P/N&#160;browse&#160;messages&#160;&#160;↑↓&#160;pick&#160;option&#160;&#160;Enter&#160;confirm&#160;&#160;ESC&#160;cancel</text><text class="terminal-r6" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r6" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,201 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #9a9b99 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppWithInjectedMessages</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="0" y="410.4" textLength="134.2" clip-path="url(#terminal-line-16)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="410.4" textLength="146.4" clip-path="url(#terminal-line-16)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="410.4" textLength="122" clip-path="url(#terminal-line-16)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="410.4" textLength="183" clip-path="url(#terminal-line-16)">devstral-latest</text><text class="terminal-r1" x="622.2" y="410.4" textLength="256.2" clip-path="url(#terminal-line-16)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="0" y="434.8" textLength="134.2" clip-path="url(#terminal-line-17)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="434.8" textLength="414.8" clip-path="url(#terminal-line-17)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="0" y="459.2" textLength="134.2" clip-path="url(#terminal-line-18)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">Type&#160;</text><text class="terminal-r3" x="231.8" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">/help</text><text class="terminal-r1" x="292.8" y="459.2" textLength="256.2" clip-path="url(#terminal-line-18)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r2" x="24.4" y="532.4" textLength="280.6" clip-path="url(#terminal-line-21)">Hello,&#160;can&#160;you&#160;help&#160;me?</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Sure!&#160;What&#160;do&#160;you&#160;need?</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="329.4" clip-path="url(#terminal-line-25)">Please&#160;read&#160;my&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">Here&#160;is&#160;the&#160;content&#160;of&#160;your&#160;config&#160;file.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -8,3 +8,8 @@ def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.banner.banner.__version__", "0.0.0"
)
@pytest.fixture(autouse=True)
def _pin_alt_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("vibe.cli.textual_ui.widgets.rewind_app.ALT_KEY", "Alt")

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from textual.pilot import Pilot
from tests.mock.utils import mock_llm_chunk
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend
from vibe.core.rewind import RewindError
class RewindSnapshotApp(BaseSnapshotTestApp):
"""Test app with a multi-turn conversation for rewind snapshots."""
def __init__(self) -> None:
fake_backend = FakeBackend([
mock_llm_chunk(content="Hello! How can I help you?")
])
super().__init__(backend=fake_backend)
async def _send_messages(pilot: Pilot) -> None:
"""Send three messages to build up conversation history.
Also patches ``has_file_changes_at`` to always return True so the
rewind panel shows the "restore files" option.
"""
for msg in ["first message", "second message", "third message"]:
await pilot.press(*msg)
await pilot.press("enter")
await pilot.pause(0.4)
app: RewindSnapshotApp = pilot.app # type: ignore[assignment]
rm = app.agent_loop.rewind_manager
patch.object(rm, "has_file_changes_at", return_value=True).start()
def test_snapshot_rewind_panel_shown(snap_compare: SnapCompare) -> None:
"""Pressing alt+up enters rewind mode and shows the panel."""
async def run_before(pilot: Pilot) -> None:
await _send_messages(pilot)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_rewind.py:RewindSnapshotApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_rewind_navigate_up(snap_compare: SnapCompare) -> None:
"""Pressing alt+up twice selects the second-to-last message."""
async def run_before(pilot: Pilot) -> None:
await _send_messages(pilot)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_rewind.py:RewindSnapshotApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_rewind_navigate_down(snap_compare: SnapCompare) -> None:
"""Navigate up twice then down once returns to the last message."""
async def run_before(pilot: Pilot) -> None:
await _send_messages(pilot)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
await pilot.press("alt+down")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_rewind.py:RewindSnapshotApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_rewind_exit_on_escape(snap_compare: SnapCompare) -> None:
"""Pressing escape exits rewind mode and restores the input panel."""
async def run_before(pilot: Pilot) -> None:
await _send_messages(pilot)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_rewind.py:RewindSnapshotApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_rewind_error_shows_toast(
snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When rewind_to_message fails, a toast is shown and rewind mode stays active."""
async def failing_rewind(*_args, **_kwargs):
raise RewindError("Invalid message index: 99")
async def run_before(pilot: Pilot) -> None:
await _send_messages(pilot)
app: RewindSnapshotApp = pilot.app # type: ignore[assignment]
monkeypatch.setattr(
app.agent_loop.rewind_manager, "rewind_to_message", failing_rewind
)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
await pilot.press("enter")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.3)
assert snap_compare(
"test_ui_snapshot_rewind.py:RewindSnapshotApp",
terminal_size=(120, 36),
run_before=run_before,
)

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from vibe.core.types import LLMMessage, Role
class SnapshotTestAppWithInjectedMessages(BaseSnapshotTestApp):
"""Simulates resuming a session that contains injected middleware messages.
The injected plan-mode reminder between the two user turns must not
appear in the rendered history.
"""
def __init__(self) -> None:
super().__init__()
self.agent_loop.messages.extend([
LLMMessage(role=Role.user, content="Hello, can you help me?"),
LLMMessage(role=Role.assistant, content="Sure! What do you need?"),
# Middleware-injected plan mode reminder — should be hidden
LLMMessage(
role=Role.user,
content="<vibe_warning>Plan mode is active. You MUST NOT make any edits.</vibe_warning>",
injected=True,
),
LLMMessage(role=Role.user, content="Please read my config file."),
LLMMessage(
role=Role.assistant, content="Here is the content of your config file."
),
])
def test_snapshot_session_resume_hides_injected_messages(
snap_compare: SnapCompare,
) -> None:
"""Injected middleware messages must not be rendered when resuming a session."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_session_resume_injected.py:SnapshotTestAppWithInjectedMessages",
terminal_size=(120, 36),
run_before=run_before,
)

View File

@@ -442,12 +442,16 @@ async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> N
@pytest.mark.asyncio
async def test_rate_limit(observer_capture) -> None:
observed, observer = observer_capture
response = httpx.Response(HTTPStatus.TOO_MANY_REQUESTS)
response = httpx.Response(
HTTPStatus.TOO_MANY_REQUESTS, request=httpx.Request("POST", "http://test")
)
error = httpx.HTTPStatusError(
"rate limited", request=response.request, response=response
)
backend_error = BackendErrorBuilder.build_http_error(
provider="mistral",
endpoint="test",
response=response,
headers=None,
error=error,
model="test-model",
messages=[],
temperature=0.0,
@@ -640,3 +644,27 @@ async def test_empty_content_chunks_do_not_trigger_false_yields() -> None:
("ReasoningEvent", " more reasoning"),
("AssistantEvent", "Actual content"),
]
@pytest.mark.asyncio
async def test_streaming_assistant_event_message_id_matches_stored_message() -> None:
backend = FakeBackend([
mock_llm_chunk(content="Hello"),
mock_llm_chunk(content=" world"),
])
agent = build_test_agent_loop(
config=make_config(), backend=backend, enable_streaming=True
)
events = [event async for event in agent.act("Test")]
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
assert len(assistant_events) == 2
# All chunks of the same assistant turn share one message_id
message_ids = {e.message_id for e in assistant_events}
assert len(message_ids) == 1
# The stored LLMMessage must carry that same message_id
stored_msg = next(m for m in agent.messages if m.role == Role.assistant)
assert stored_msg.message_id == assistant_events[0].message_id

230
tests/test_ui_rewind.py Normal file
View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_app
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.app import BottomApp, VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import UserMessage
def _make_app(num_responses: int = 3) -> VibeApp:
backend = FakeBackend([
mock_llm_chunk(content=f"Response {i + 1}") for i in range(num_responses)
])
agent_loop = build_test_agent_loop(backend=backend)
return build_test_vibe_app(agent_loop=agent_loop)
async def _send_messages(pilot, messages: list[str]) -> None:
for msg in messages:
await pilot.press(*msg)
await pilot.press("enter")
await pilot.pause(0.4)
@pytest.mark.asyncio
async def test_rewind_mode_activates_on_alt_up() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello", "world"])
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_mode is True
assert app._current_bottom_app == BottomApp.Rewind
@pytest.mark.asyncio
async def test_rewind_highlights_last_user_message() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello", "world"])
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "world"
@pytest.mark.asyncio
async def test_rewind_navigates_to_previous_message() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello", "world"])
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "hello"
@pytest.mark.asyncio
async def test_rewind_navigates_down() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello", "world"])
# Go up twice, then down once
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
await pilot.press("alt+down")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "world"
@pytest.mark.asyncio
async def test_rewind_escape_exits_mode() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello", "world"])
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
await pilot.press("escape")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_mode is False
assert app._rewind_highlighted_widget is None
assert app._current_bottom_app == BottomApp.Input
@pytest.mark.asyncio
async def test_rewind_ctrl_p_n_alternate_bindings() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello", "world"])
# ctrl+p should enter rewind mode
await pilot.press("ctrl+p")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_mode is True
assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "world"
# ctrl+p again goes to previous
await pilot.press("ctrl+p")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "hello"
# ctrl+n goes back
await pilot.press("ctrl+n")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "world"
@pytest.mark.asyncio
async def test_rewind_confirm_edits_message_and_prefills_input() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello", "world"])
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
# Confirm with enter (selects "Edit message from here")
await pilot.press("enter")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
assert app._rewind_mode is False
assert app._current_bottom_app == BottomApp.Input
# Input should be pre-filled with the rewound message
chat_input = app.query_one(ChatInputContainer)
assert chat_input.value == "world"
@pytest.mark.asyncio
async def test_rewind_removes_messages_after_selected() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["first", "second", "third"])
# Navigate to "second"
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "second"
# Confirm
await pilot.press("enter")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
# Only "first" should remain as a UserMessage
messages_area = app.query_one("#messages")
user_widgets = [
child for child in messages_area.children if isinstance(child, UserMessage)
]
assert len(user_widgets) == 1
assert user_widgets[0]._content == "first"
@pytest.mark.asyncio
async def test_rewind_does_not_activate_while_agent_running() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello"])
app._agent_running = True
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
assert app._rewind_mode is False
@pytest.mark.asyncio
async def test_rewind_option_selection_with_number_keys() -> None:
app = _make_app()
async with app.run_test() as pilot:
await _send_messages(pilot, ["hello"])
await pilot.press("alt+up")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.1)
# Press "1" to select first option directly
await pilot.press("1")
await pilot.app.workers.wait_for_complete()
await pilot.pause(0.2)
assert app._rewind_mode is False
assert app._current_bottom_app == BottomApp.Input

20
uv.lock generated
View File

@@ -561,6 +561,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jsonpath-python"
version = "1.1.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -770,7 +779,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.6.2"
version = "2.7.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@@ -843,7 +852,7 @@ requires-dist = [
{ name = "keyring", specifier = ">=25.6.0" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" },
{ name = "mistralai", specifier = "==2.0.0" },
{ name = "mistralai", specifier = "==2.1.3" },
{ name = "opentelemetry-api", specifier = ">=1.39.1" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.1" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.1" },
@@ -891,20 +900,21 @@ dev = [
[[package]]
name = "mistralai"
version = "2.0.0"
version = "2.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eval-type-backport" },
{ name = "httpx" },
{ name = "jsonpath-python" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/5c/22fd7d1ec7e333f83dc5e2d0b176952a5d9a1f08519898c55616c92a81d8/mistralai-2.0.0.tar.gz", hash = "sha256:acb7937a53119ece67f4978809d4cf630fbf54b4dfe85c0eeae778ac40850fab", size = 317705, upload-time = "2026-03-10T17:12:48.616Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/98/5fe39d514c19477f06a07e088ce4a44c2e60ac9deebefb9e2c8ed8ef87d2/mistralai-2.1.3.tar.gz", hash = "sha256:0c5de4855b043cd0582406d5c1ddfd91e176f484a158e6ee0b4a0054231be266", size = 331929, upload-time = "2026-03-23T15:00:29.579Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/95/1587d555837bf635db28e2acee366cc47edc473cd3155515be14acced91b/mistralai-2.0.0-py3-none-any.whl", hash = "sha256:e551fc36d60d4c969140e37f10eab04986480e487f357c900da05d740b9a0baf", size = 709642, upload-time = "2026-03-10T17:12:50.104Z" },
{ url = "https://files.pythonhosted.org/packages/15/7c/f91a26bf469c1cff57325379afa112baeb113ac577d28e69dd408cee5745/mistralai-2.1.3-py3-none-any.whl", hash = "sha256:26daac3bdc69fc2dd58f2c421710eb34131be7883b44a9ea81904a6306e6a90a", size = 754931, upload-time = "2026-03-23T15:00:30.934Z" },
]
[[package]]

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