Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-16 17:51:47 +01:00 committed by GitHub
parent 9421fbc08e
commit 5103019b01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 7277 additions and 691 deletions

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.4.2"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.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.4.2"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.0"
)
assert response.auth_methods is not None

View file

@ -51,7 +51,8 @@ class TestMultiSessionCore:
)
assert isinstance(exc_info.value, RequestError)
assert str(exc_info.value) == "Invalid params"
assert exc_info.value.code == -32602
assert "Session not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_simultaneous_message_processing(

View file

@ -51,9 +51,11 @@ class TestAvailableCommandsUpdate:
assert len(available_commands_updates) == 1
update = available_commands_updates[0].update
assert len(update.available_commands) == 1
assert update.available_commands[0].name == "proxy-setup"
assert "proxy" in update.available_commands[0].description.lower()
proxy_cmd = next(
(c for c in update.available_commands if c.name == "proxy-setup"), None
)
assert proxy_cmd is not None
assert "proxy" in proxy_cmd.description.lower()
class TestProxySetupCommand:

View file

@ -0,0 +1,451 @@
from __future__ import annotations
import asyncio
import io
import struct
import time
from unittest.mock import MagicMock, patch
import wave
import pytest
try:
import sounddevice as sd
except OSError:
pytest.skip("PortAudio library not available", allow_module_level=True)
from vibe.core.audio_recorder.audio_recorder import AudioRecorder
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
AudioRecording,
IncompatibleSampleRateError,
NoAudioInputDeviceError,
RecordingMode,
)
def _make_pcm_frames(value: int, n_samples: int = 1024) -> bytes:
"""Create raw PCM int16 bytes with all samples set to `value`."""
return struct.pack(f"<{n_samples}h", *([value] * n_samples))
def _get_callback(mock_stream_cls: MagicMock):
"""Extract the callback kwarg passed to the mocked RawInputStream."""
return mock_stream_cls.call_args.kwargs["callback"]
class TestAudioRecorderInitialState:
def test_not_recording(self) -> None:
recorder = AudioRecorder()
assert recorder.is_recording is False
def test_peak_is_zero(self) -> None:
recorder = AudioRecorder()
assert recorder.peak == 0.0
class TestBufferMode:
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_start_sets_recording_state(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
assert recorder.is_recording is True
mock_stream_cls.return_value.start.assert_called_once()
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_start_when_already_recording_raises(
self, mock_stream_cls: MagicMock
) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
with pytest.raises(AlreadyRecordingError):
recorder.start(RecordingMode.BUFFER)
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_stop_returns_valid_wav(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
pcm_data = _make_pcm_frames(5000)
callback(pcm_data, 1024, {}, sd.CallbackFlags())
result = recorder.stop()
assert recorder.is_recording is False
assert len(result.data) > 0
assert result.data[:4] == b"RIFF"
with wave.open(io.BytesIO(result.data), "rb") as wf:
assert wf.getnchannels() == 1
assert wf.getsampwidth() == 2
assert wf.getframerate() == 48_000
assert wf.getnframes() == 1024
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_stop_returns_positive_duration(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(100), 1024, {}, sd.CallbackFlags())
result = recorder.stop()
assert result.duration > 0.0
def test_stop_when_not_recording_returns_empty(self) -> None:
recorder = AudioRecorder()
result = recorder.stop()
assert result.data == b""
assert result.duration == 0.0
@pytest.mark.asyncio
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
async def test_buffer_mode_audio_stream_yields_nothing(
self, mock_stream_cls: MagicMock
) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
collected: list[bytes] = []
async for chunk in recorder.audio_stream():
collected.append(chunk)
assert collected == []
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_can_record_multiple_times(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
result1 = recorder.stop()
assert len(result1.data) > 0
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(3000), 1024, {}, sd.CallbackFlags())
result2 = recorder.stop()
assert len(result2.data) > 0
assert result1.data[:4] == b"RIFF"
assert result2.data[:4] == b"RIFF"
class TestStreamMode:
@pytest.mark.asyncio
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
async def test_audio_stream_yields_chunks(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.STREAM)
callback = _get_callback(mock_stream_cls)
chunk1 = _make_pcm_frames(1000, n_samples=512)
chunk2 = _make_pcm_frames(2000, n_samples=512)
collected: list[bytes] = []
async def consume() -> None:
async for chunk in recorder.audio_stream():
collected.append(chunk)
task = asyncio.create_task(consume())
callback(chunk1, 512, {}, sd.CallbackFlags())
callback(chunk2, 512, {}, sd.CallbackFlags())
await asyncio.sleep(0.05)
recorder.stop()
await task
assert len(collected) == 2
assert collected[0] == chunk1
assert collected[1] == chunk2
@pytest.mark.asyncio
async def test_audio_stream_without_start_returns_nothing(self) -> None:
recorder = AudioRecorder()
collected: list[bytes] = []
async for chunk in recorder.audio_stream():
collected.append(chunk)
assert collected == []
@pytest.mark.asyncio
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
async def test_stream_audio_does_not_leak_into_buffer_recording(
self, mock_stream_cls: MagicMock
) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.STREAM)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(1000), 1024, {}, sd.CallbackFlags())
async def consume() -> None:
async for _ in recorder.audio_stream():
pass
task = asyncio.create_task(consume())
await asyncio.sleep(0.05)
recorder.stop()
await task
recorder.start(RecordingMode.BUFFER)
result = recorder.stop()
with wave.open(io.BytesIO(result.data), "rb") as wf:
assert wf.getnframes() == 0
@pytest.mark.asyncio
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
async def test_stop_from_event_loop_does_not_block(
self, mock_stream_cls: MagicMock
) -> None:
"""stop() called from the event loop thread must not block waiting for drain."""
recorder = AudioRecorder()
recorder.start(RecordingMode.STREAM)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(1000), 1024, {}, sd.CallbackFlags())
collected: list[bytes] = []
async def consume() -> None:
async for chunk in recorder.audio_stream():
collected.append(chunk)
task = asyncio.create_task(consume())
await asyncio.sleep(0.05)
start = time.monotonic()
result = recorder.stop()
elapsed = time.monotonic() - start
await task
assert elapsed < 1.0
assert result.data == b""
assert result.duration > 0.0
assert len(collected) == 1
@pytest.mark.asyncio
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
async def test_stop_returns_empty_data_in_stream_mode(
self, mock_stream_cls: MagicMock
) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.STREAM)
async def consume() -> None:
async for _ in recorder.audio_stream():
pass
task = asyncio.create_task(consume())
await asyncio.sleep(0.01)
result = recorder.stop()
await task
assert result.data == b""
assert result.duration > 0.0
@pytest.mark.asyncio
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
async def test_stop_without_drain_returns_promptly(
self, mock_stream_cls: MagicMock
) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.STREAM)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(1000), 1024, {}, sd.CallbackFlags())
start = time.monotonic()
result = recorder.stop(wait_for_queue_drained=False)
elapsed = time.monotonic() - start
assert elapsed < 1.0
assert result.data == b""
assert result.duration > 0.0
assert recorder.is_recording is False
class TestCancel:
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_cancel_discards_audio(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
recorder.cancel()
assert recorder.is_recording is False
mock_stream_cls.return_value.stop.assert_called_once()
mock_stream_cls.return_value.close.assert_called_once()
def test_cancel_when_not_recording_is_noop(self) -> None:
recorder = AudioRecorder()
recorder.cancel()
assert recorder.is_recording is False
class TestMaxDuration:
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_auto_stops_after_max_duration(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER, max_duration=0.1)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
time.sleep(0.3)
assert recorder.is_recording is False
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_on_expire_receives_audio(self, mock_stream_cls: MagicMock) -> None:
"""on_expire callback receives the WAV data when the timer fires."""
received: list[AudioRecording] = []
recorder = AudioRecorder()
recorder.start(
RecordingMode.BUFFER, max_duration=0.1, on_expire=received.append
)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
time.sleep(0.3)
assert recorder.is_recording is False
assert len(received) == 1
assert received[0].data[:4] == b"RIFF"
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_manual_stop_prevents_on_expire(self, mock_stream_cls: MagicMock) -> None:
expired: list[AudioRecording] = []
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER, max_duration=0.2, on_expire=expired.append)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(5000), 1024, {}, sd.CallbackFlags())
result = recorder.stop()
assert recorder.is_recording is False
assert result.data[:4] == b"RIFF"
time.sleep(0.35)
assert expired == []
assert recorder.is_recording is False
class TestPeak:
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_peak_updates_from_callback(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(16_384), 1024, {}, sd.CallbackFlags())
assert recorder.peak == pytest.approx(16_384 / 32_768, abs=0.01)
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_peak_clamps_to_one(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(32_767), 1024, {}, sd.CallbackFlags())
assert recorder.peak <= 1.0
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_silent_audio_has_zero_peak(self, mock_stream_cls: MagicMock) -> None:
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
callback = _get_callback(mock_stream_cls)
callback(_make_pcm_frames(0), 1024, {}, sd.CallbackFlags())
assert recorder.peak == 0.0
class TestGuardAudioInput:
def test_guard_returns_sample_rate_when_compatible(self) -> None:
with (
patch(
"vibe.core.audio_recorder.audio_recorder.sd.query_devices"
) as mock_query,
patch("vibe.core.audio_recorder.audio_recorder.sd.check_input_settings"),
):
mock_query.return_value = {"default_samplerate": 48000.0}
result = AudioRecorder._guard_audio_input(48000, 1)
assert result == 48000
def test_guard_raises_when_no_input_device(self) -> None:
with patch(
"vibe.core.audio_recorder.audio_recorder.sd.query_devices",
side_effect=sd.PortAudioError(-1),
):
with pytest.raises(NoAudioInputDeviceError):
AudioRecorder._guard_audio_input(48000, 1)
def test_guard_raises_with_fallback_when_rate_incompatible(self) -> None:
with (
patch(
"vibe.core.audio_recorder.audio_recorder.sd.query_devices"
) as mock_query,
patch(
"vibe.core.audio_recorder.audio_recorder.sd.check_input_settings",
side_effect=sd.PortAudioError(-1),
),
):
mock_query.return_value = {"default_samplerate": 16000.0}
with pytest.raises(IncompatibleSampleRateError) as exc_info:
AudioRecorder._guard_audio_input(48000, 1)
assert exc_info.value.fallback_sample_rate == 16000
def test_start_raises_when_no_sounddevice(self) -> None:
with patch("vibe.core.audio_recorder.audio_recorder.sd", None):
recorder = AudioRecorder()
with pytest.raises(AudioBackendUnavailableError):
recorder.start(RecordingMode.BUFFER)
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_start_raises_when_no_device(self, mock_stream_cls: MagicMock) -> None:
with patch(
"vibe.core.audio_recorder.audio_recorder.sd.query_devices",
side_effect=sd.PortAudioError(-1),
):
recorder = AudioRecorder()
with pytest.raises(NoAudioInputDeviceError):
recorder.start(RecordingMode.BUFFER)
assert recorder.is_recording is False
mock_stream_cls.assert_not_called()
@patch("vibe.core.audio_recorder.audio_recorder.sd.RawInputStream")
def test_start_retries_with_fallback_sample_rate(
self, mock_stream_cls: MagicMock
) -> None:
with (
patch(
"vibe.core.audio_recorder.audio_recorder.sd.query_devices"
) as mock_query,
patch(
"vibe.core.audio_recorder.audio_recorder.sd.check_input_settings"
) as mock_check,
):
mock_query.return_value = {"default_samplerate": 16000.0}
mock_check.side_effect = sd.PortAudioError(-1)
recorder = AudioRecorder()
recorder.start(RecordingMode.BUFFER)
assert recorder.is_recording is True
assert mock_stream_cls.call_args.kwargs["samplerate"] == 16000

View file

@ -4,7 +4,9 @@ from pathlib import Path
import pytest
import vibe.core.autocompletion.completers as completers_module
from vibe.core.autocompletion.completers import PathCompleter
from vibe.core.autocompletion.fuzzy import fuzzy_match as real_fuzzy_match
@pytest.fixture()
@ -120,3 +122,229 @@ def test_fuzzy_matches_directory_traversal(file_tree: Path) -> None:
assert "@src/main.py" in results
assert "@src/core/" in results
assert "@src/utils/" in results
def test_directory_prefix_can_match_from_a_nested_path_segment(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "vibe" / "acp").mkdir(parents=True)
(tmp_path / "vibe" / "acp" / "entrypoint.py").write_text("", encoding="utf-8")
(tmp_path / "vibe" / "myacp").mkdir(parents=True)
(tmp_path / "vibe" / "myacp" / "entrypoint.py").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
results = PathCompleter().get_completions("@acp/", cursor_pos=5)
assert "@vibe/acp/entrypoint.py" in results
assert "@vibe/myacp/entrypoint.py" not in results
def test_prefers_exact_filename_match_over_other_path_matches(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "src").mkdir(parents=True)
(tmp_path / "src" / "chat-input.tsx").write_text("", encoding="utf-8")
(tmp_path / "src" / "features").mkdir(parents=True)
(tmp_path / "src" / "features" / "chat-input-state.ts").write_text(
"", encoding="utf-8"
)
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "chat-input.md").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
results = PathCompleter().get_completions("@chat-input", cursor_pos=11)
assert results[0] == "@src/chat-input.tsx"
def test_keeps_late_strong_match_when_target_matches_is_small(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
for i in range(10):
(tmp_path / f"prefix_{i}_chatnoise.txt").write_text("", encoding="utf-8")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "chat-input.tsx").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
results = PathCompleter(target_matches=5).get_completions("@chati", cursor_pos=6)
assert results[0] == "@src/chat-input.tsx"
def test_prefers_source_file_over_lock_file_for_stem_query(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "foo.lock").write_text("", encoding="utf-8")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "foo.py").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
results = PathCompleter().get_completions("@foo", cursor_pos=4)
assert results[0] == "@src/foo.py"
def test_prefers_exact_filename_matches_for_filename_query(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(
tmp_path
/ "ts"
/ "apps"
/ "le-chat-web"
/ "src"
/ "app"
/ "chat"
/ "_components"
).mkdir(parents=True)
(
tmp_path
/ "ts"
/ "apps"
/ "le-chat-web"
/ "src"
/ "app"
/ "chat"
/ "_components"
/ "chat-input.tsx"
).write_text("", encoding="utf-8")
(tmp_path / "ts" / "apps" / "le-chat-web" / "src" / "components").mkdir(
parents=True
)
(
tmp_path
/ "ts"
/ "apps"
/ "le-chat-web"
/ "src"
/ "components"
/ "chat-input.tsx"
).write_text("", encoding="utf-8")
(
tmp_path / "ts" / "apps" / "le-chat-mobile" / "components" / "SearchInput.tsx"
).parent.mkdir(parents=True)
(
tmp_path / "ts" / "apps" / "le-chat-mobile" / "components" / "SearchInput.tsx"
).write_text("", encoding="utf-8")
(
tmp_path
/ "ts"
/ "apps"
/ "le-chat-web"
/ "src"
/ "app"
/ "chat"
/ "_components"
/ "message-input.tsx"
).write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
results = PathCompleter().get_completions("@chat-input.tsx", cursor_pos=16)
assert set(results[:2]) == {
"@ts/apps/le-chat-web/src/app/chat/_components/chat-input.tsx",
"@ts/apps/le-chat-web/src/components/chat-input.tsx",
}
assert results.index("@ts/apps/le-chat-mobile/components/SearchInput.tsx") > 1
assert (
results.index("@ts/apps/le-chat-web/src/app/chat/_components/message-input.tsx")
> 1
)
def test_exact_path_query_ranks_children_ahead_of_unrelated_fuzzy_matches(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "scripts" / "zephyr" / "generators").mkdir(parents=True)
(tmp_path / "zephyr" / "generators" / "tests").mkdir(parents=True)
(tmp_path / "zephyr" / "generators" / "prompts").mkdir(parents=True)
(tmp_path / "zephyr" / "generators" / "common.py").write_text("", encoding="utf-8")
(tmp_path / "zephyr" / "generators" / "README.md").write_text("", encoding="utf-8")
(
tmp_path
/ "zephyr"
/ "datasets"
/ "synthetic_sp_up_conflict"
/ "grounded_policies"
/ "hotel"
).mkdir(parents=True)
(
tmp_path
/ "zephyr"
/ "datasets"
/ "synthetic_sp_up_conflict"
/ "grounded_policies"
/ "hotel"
/ "generators.py"
).write_text("", encoding="utf-8")
(
tmp_path
/ "zephyr"
/ "datasets"
/ "synthetic_sp_up_conflict"
/ "grounded_policies"
/ "retail"
).mkdir(parents=True)
(
tmp_path
/ "zephyr"
/ "datasets"
/ "synthetic_sp_up_conflict"
/ "grounded_policies"
/ "retail"
/ "generators.py"
).write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
results = PathCompleter().get_completions("@zephyr/generators", cursor_pos=18)
assert results[0] == "@zephyr/generators/"
assert results.index("@zephyr/generators/common.py") < results.index(
"@zephyr/datasets/synthetic_sp_up_conflict/grounded_policies/hotel/generators.py"
)
assert results.index("@zephyr/generators/prompts/") < results.index(
"@zephyr/datasets/synthetic_sp_up_conflict/grounded_policies/retail/generators.py"
)
assert results.index("@zephyr/generators/tests/") < results.index(
"@zephyr/datasets/synthetic_sp_up_conflict/grounded_policies/hotel/generators.py"
)
def test_skips_fuzzy_scoring_for_entries_missing_required_query_characters(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "chat-input.tsx").write_text("", encoding="utf-8")
for name in ("alpha.txt", "theta.txt", "notes.md"):
(tmp_path / name).write_text("", encoding="utf-8")
for i in range(20):
(tmp_path / f"zzzz_{i}.txt").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
fuzzy_calls: list[str] = []
def counting_fuzzy_match(
pattern: str, text: str, text_lower: str | None = None
) -> object:
fuzzy_calls.append(text)
return real_fuzzy_match(pattern, text, text_lower)
monkeypatch.setattr(completers_module, "fuzzy_match", counting_fuzzy_match)
results = PathCompleter().get_completions("@chati", cursor_pos=6)
assert results[0] == "@src/chat-input.tsx"
assert fuzzy_calls == ["src/chat-input.tsx"]
def test_non_ascii_queries_still_match_when_ascii_prefilter_is_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "café.txt").write_text("", encoding="utf-8")
monkeypatch.chdir(tmp_path)
results = PathCompleter().get_completions("@café", cursor_pos=5)
assert results == ["@café.txt"]

View file

@ -156,7 +156,7 @@ async def test_path_completion_popup_lists_files_and_directories(
await pilot.press(*"@s")
popup_content = str(popup.render())
assert "@src/" in popup_content
assert "src/" in popup_content
assert popup.styles.display == "block"
@ -177,16 +177,16 @@ async def test_path_completion_popup_shows_up_to_ten_results(
await pilot.press(*"@src/core/extra/")
popup_content = str(popup.render())
assert "@src/core/extra/extra_file_1.py" in popup_content
assert "@src/core/extra/extra_file_10.py" in popup_content
assert "@src/core/extra/extra_file_11.py" in popup_content
assert "@src/core/extra/extra_file_12.py" in popup_content
assert "@src/core/extra/extra_file_2.py" in popup_content
assert "@src/core/extra/extra_file_3.py" in popup_content
assert "@src/core/extra/extra_file_4.py" in popup_content
assert "@src/core/extra/extra_file_5.py" in popup_content
assert "@src/core/extra/extra_file_6.py" in popup_content
assert "@src/core/extra/extra_file_7.py" in popup_content
assert "src/core/extra/extra_file_1.py" in popup_content
assert "src/core/extra/extra_file_10.py" in popup_content
assert "src/core/extra/extra_file_11.py" in popup_content
assert "src/core/extra/extra_file_12.py" in popup_content
assert "src/core/extra/extra_file_2.py" in popup_content
assert "src/core/extra/extra_file_3.py" in popup_content
assert "src/core/extra/extra_file_4.py" in popup_content
assert "src/core/extra/extra_file_5.py" in popup_content
assert "src/core/extra/extra_file_6.py" in popup_content
assert "src/core/extra/extra_file_7.py" in popup_content
assert popup.styles.display == "block"
@ -230,7 +230,7 @@ async def test_fuzzy_matches_subsequence_characters(
await pilot.press(*"@src/utils/handling")
popup_content = str(popup.render())
assert "@src/utils/error_handling.py" in popup_content
assert "src/utils/error_handling.py" in popup_content
assert popup.styles.display == "block"
@ -244,7 +244,7 @@ async def test_fuzzy_matches_word_boundaries(
await pilot.press(*"@src/utils/eh")
popup_content = str(popup.render())
assert "@src/utils/error_handling.py" in popup_content
assert "src/utils/error_handling.py" in popup_content
assert popup.styles.display == "block"
@ -258,7 +258,7 @@ async def test_finds_files_recursively_by_filename(
await pilot.press(*"@entryp")
popup_content = str(popup.render())
assert "@vibe/acp/entrypoint.py" in popup_content
assert "vibe/acp/entrypoint.py" in popup_content
assert popup.styles.display == "block"
@ -272,7 +272,7 @@ async def test_finds_files_recursively_with_partial_path(
await pilot.press(*"@acp/entry")
popup_content = str(popup.render())
assert "@vibe/acp/entrypoint.py" in popup_content
assert "vibe/acp/entrypoint.py" in popup_content
assert popup.styles.display == "block"

View file

@ -16,7 +16,7 @@ import json
from unittest.mock import MagicMock, patch
import httpx
from mistralai.utils.retries import BackoffStrategy, RetryConfig
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
import pytest
import respx
@ -426,7 +426,7 @@ class TestMistralRetry:
async def test_client_creation_includes_timeout_and_retry_config(self):
backend = self._create_test_backend()
with patch("mistralai.Mistral") as mock_mistral_class:
with patch("vibe.core.llm.backend.mistral.Mistral") as mock_mistral_class:
mock_mistral_class.return_value = MagicMock()
backend._get_client()
mock_mistral_class.assert_called_once_with(

View file

@ -77,7 +77,7 @@ class TestThinkingBlocksConversion:
reasoning_content="Let me think...",
),
]
payload = _prepare(adapter, provider, messages)
payload = _prepare(adapter, provider, messages, thinking="medium")
msg = payload["messages"][1]
assert msg["content"] == [
{
@ -87,6 +87,18 @@ class TestThinkingBlocksConversion:
{"type": "text", "text": "Answer"},
]
def test_reasoning_stripped_when_thinking_off(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hi"),
LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="Let me think...",
),
]
payload = _prepare(adapter, provider, messages, thinking="off")
assert payload["messages"][1]["content"] == "Answer"
def test_assistant_without_reasoning_is_plain_string(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hi"),
@ -111,7 +123,7 @@ class TestThinkingBlocksConversion:
],
),
]
payload = _prepare(adapter, provider, messages)
payload = _prepare(adapter, provider, messages, thinking="medium")
msg = payload["messages"][1]
assert msg["content"][0]["type"] == "thinking"
assert msg["content"][1] == {"type": "text", "text": "Let me search."}

View file

@ -145,3 +145,28 @@ async def test_raises_on_invalid_boolean_string(respx_mock: respx.MockRouter) ->
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")
@pytest.mark.asyncio
async def test_return_unknown_plan_on_unsupported_plan_type(
respx_mock: respx.MockRouter,
) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"plan_type": "SOMETHING",
"plan_name": "INDIVIDUAL",
"prompt_switching_to_pro_plan": "false",
},
)
)
gateway = HttpWhoAmIGateway(base_url="http://test")
response = await gateway.whoami("api-key")
assert response == WhoAmIResponse(
plan_type=WhoAmIPlanType.UNKNOWN,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=False,
)

View file

@ -0,0 +1,319 @@
from __future__ import annotations
import pytest
from vibe.cli.textual_ui.widgets.messages import StreamingMessageBase
class FakeStream:
def __init__(self) -> None:
self.written: list[str] = []
self.stopped = False
async def write(self, content: str) -> None:
self.written.append(content)
async def stop(self) -> None:
self.stopped = True
@property
def all_written(self) -> str:
return "".join(self.written)
class MessageTestDouble(StreamingMessageBase):
"""Minimal test double for StreamingMessageBase that bypasses Textual internals."""
def __init__(self, at_bottom: bool = True, should_write: bool = True) -> None:
# Initialise only the fields used by the buffer logic — no Textual setup.
self._content = ""
self._content_initialized = False
self._to_write_buffer = ""
self._stream = None
self._markdown = None
self._at_bottom = at_bottom
self._should_write = should_write
self._fake_stream: FakeStream = FakeStream()
# --- overrides used by the buffer logic ---
def _ensure_stream(self) -> FakeStream: # type: ignore[override]
if self._stream is None:
self._stream = self._fake_stream # type: ignore[assignment]
return self._fake_stream
def _is_chat_at_bottom(self) -> bool:
return self._at_bottom
def _should_write_content(self) -> bool:
return self._should_write
def make_msg(*, at_bottom: bool = True, should_write: bool = True) -> MessageTestDouble:
return MessageTestDouble(at_bottom=at_bottom, should_write=should_write)
class TestAppendContent:
@pytest.mark.asyncio
async def test_at_bottom_writes_directly_no_buffer(self) -> None:
msg = make_msg(at_bottom=True)
await msg.append_content("hello")
assert msg._fake_stream.all_written == "hello"
assert msg._to_write_buffer == ""
@pytest.mark.asyncio
async def test_scrolled_away_buffers_without_writing(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("hello")
assert msg._fake_stream.all_written == ""
assert msg._to_write_buffer == "hello"
@pytest.mark.asyncio
async def test_multiple_chunks_scrolled_away_accumulate(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("foo")
await msg.append_content(" bar")
assert msg._fake_stream.all_written == ""
assert msg._to_write_buffer == "foo bar"
@pytest.mark.asyncio
async def test_scroll_back_flushes_buffer_with_new_chunk(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("buffered")
msg._at_bottom = True
await msg.append_content(" new")
# Both buffered and new chunk written together in one write call.
assert msg._fake_stream.all_written == "buffered new"
assert msg._to_write_buffer == ""
@pytest.mark.asyncio
async def test_scroll_back_subsequent_chunks_written_directly(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("a")
await msg.append_content("b")
msg._at_bottom = True
await msg.append_content("c")
await msg.append_content("d")
assert msg._fake_stream.all_written == "abc" + "d"
assert msg._to_write_buffer == ""
@pytest.mark.asyncio
async def test_empty_content_is_ignored(self) -> None:
msg = make_msg()
await msg.append_content("")
assert msg._fake_stream.all_written == ""
assert msg._to_write_buffer == ""
@pytest.mark.asyncio
async def test_should_write_false_skips_stream_and_buffer(self) -> None:
msg = make_msg(should_write=False)
await msg.append_content("invisible")
assert msg._fake_stream.all_written == ""
assert msg._to_write_buffer == ""
# Content still accumulates in _content for later full-replay.
assert msg._content == "invisible"
class TestStopStream:
@pytest.mark.asyncio
async def test_flushes_remaining_buffer(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("pending")
await msg.stop_stream()
assert msg._fake_stream.all_written == "pending"
assert msg._to_write_buffer == ""
@pytest.mark.asyncio
async def test_stops_the_stream(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("x")
await msg.stop_stream()
assert msg._fake_stream.stopped is True
@pytest.mark.asyncio
async def test_idempotent_no_double_write(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("once")
await msg.stop_stream()
await msg.stop_stream()
assert msg._fake_stream.all_written == "once"
@pytest.mark.asyncio
async def test_does_not_write_when_should_write_false(self) -> None:
msg = make_msg(at_bottom=True, should_write=False)
# Manually poke the buffer to verify the guard is respected.
msg._to_write_buffer = "invisible"
await msg.stop_stream()
assert msg._fake_stream.all_written == ""
assert msg._to_write_buffer == ""
@pytest.mark.asyncio
async def test_empty_buffer_no_extra_write(self) -> None:
msg = make_msg()
await msg.append_content("live") # written directly, buffer stays empty
await msg.stop_stream()
# Only the original direct write, no spurious extra write from stop_stream.
assert msg._fake_stream.written == ["live"]
class TestWriteInitialContent:
@pytest.mark.asyncio
async def test_writes_accumulated_content(self) -> None:
msg = make_msg()
msg._content = "full content"
await msg.write_initial_content()
assert msg._fake_stream.all_written == "full content"
@pytest.mark.asyncio
async def test_is_idempotent_second_call_writes_nothing(self) -> None:
msg = make_msg()
msg._content = "content"
await msg.write_initial_content()
await msg.write_initial_content()
assert msg._fake_stream.all_written == "content"
@pytest.mark.asyncio
async def test_already_initialized_flag_is_noop(self) -> None:
msg = make_msg()
msg._content = "something"
msg._content_initialized = True
await msg.write_initial_content()
assert msg._fake_stream.all_written == ""
@pytest.mark.asyncio
async def test_empty_content_writes_nothing(self) -> None:
msg = make_msg()
await msg.write_initial_content()
assert msg._fake_stream.all_written == ""
@pytest.mark.asyncio
async def test_should_write_false_writes_nothing(self) -> None:
msg = make_msg(should_write=False)
msg._content = "hidden"
await msg.write_initial_content()
assert msg._fake_stream.all_written == ""
@pytest.mark.asyncio
async def test_clears_buffer_after_writing(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("buffered chunk")
await msg.write_initial_content()
assert msg._to_write_buffer == ""
class TestNoDoubleWrite:
@pytest.mark.asyncio
async def test_write_initial_then_stop_stream_no_duplication(self) -> None:
"""Regression: buffer must be cleared after write_initial_content so
stop_stream does not re-write the same content.
"""
msg = make_msg(at_bottom=False)
await msg.append_content("part one ")
await msg.append_content("part two")
await msg.write_initial_content()
await msg.stop_stream()
written = msg._fake_stream.all_written
assert written == "part one part two", (
f"Expected content written exactly once, got: {written!r}"
)
@pytest.mark.asyncio
async def test_write_initial_before_streaming_then_buffered_then_stop(self) -> None:
"""Reflects the real call order: write_initial_content is called once at
mount time (stream is pristine, _content is empty), then streaming begins.
Buffered content must be flushed exactly once by stop_stream.
Note: calling write_initial_content AFTER live content has already been
written to the stream is not a supported usage it would duplicate the
already-written portion because write_initial_content replays the full
_content. In practice this never occurs because _mount_and_scroll calls
write_initial_content immediately on mount, before streaming starts.
"""
msg = make_msg(at_bottom=False)
# At mount time content is empty — write_initial_content is a no-op
# but arms _content_initialized so it can never write again.
await msg.write_initial_content()
await msg.append_content("buffered chunk one ")
await msg.append_content("buffered chunk two")
await msg.stop_stream()
written = msg._fake_stream.all_written
assert written == "buffered chunk one buffered chunk two", (
f"Expected buffered content written exactly once, got: {written!r}"
)
@pytest.mark.asyncio
async def test_write_initial_before_streaming_at_bottom_then_stop(self) -> None:
"""write_initial_content at mount (no-op), streaming at bottom writes
directly, stop_stream has nothing extra to flush.
"""
msg = make_msg(at_bottom=True)
await msg.write_initial_content()
await msg.append_content("live one ")
await msg.append_content("live two")
await msg.stop_stream()
assert msg._fake_stream.all_written == "live one live two"
@pytest.mark.asyncio
async def test_stop_stream_twice_no_double_write(self) -> None:
msg = make_msg(at_bottom=False)
await msg.append_content("data")
await msg.stop_stream()
await msg.stop_stream()
assert msg._fake_stream.all_written == "data"
@pytest.mark.asyncio
async def test_write_initial_twice_no_double_write(self) -> None:
msg = make_msg()
msg._content = "once"
await msg.write_initial_content()
await msg.write_initial_content()
assert msg._fake_stream.all_written == "once"

View file

@ -9,6 +9,7 @@ import tomli_w
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_voice_manager import FakeVoiceManager
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
@ -75,6 +76,22 @@ def config_dir(
return config_dir
@pytest.fixture(autouse=True)
def _reset_trusted_folders_manager(config_dir: Path) -> None:
"""Prevent the singleton from writing to the real ~/.vibe/trusted_folders.toml.
The module-level ``trusted_folders_manager`` captures its file path at import
time (before any monkeypatch), so it would otherwise target the real home
directory. Redirect it to the temp config dir used by the ``config_dir``
fixture.
"""
from vibe.core.trusted_folders import trusted_folders_manager
trusted_folders_manager._file_path = config_dir / "trusted_folders.toml"
trusted_folders_manager._trusted = []
trusted_folders_manager._untrusted = []
@pytest.fixture(autouse=True)
def _init_harness_files_manager():
reset_harness_files_manager()
@ -215,6 +232,7 @@ def build_test_vibe_app(
resolved_current_version = (
CORE_VERSION if current_version is None else current_version
)
voice_manager = kwargs.pop("voice_manager", FakeVoiceManager())
return VibeApp(
agent_loop=resolved_agent_loop,
@ -223,5 +241,6 @@ def build_test_vibe_app(
update_cache_repository=resolved_update_cache_repository,
plan_offer_gateway=resolved_plan_offer_gateway,
initial_prompt=kwargs.pop("initial_prompt", None),
voice_manager=voice_manager,
**kwargs,
)

View file

@ -237,6 +237,236 @@ class TestUserAgentsDirs:
assert mgr.user_agents_dirs == [agents_dir]
class TestLoadProjectDocs:
def test_returns_empty_when_project_not_in_sources(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
mgr = HarnessFilesManager(sources=("user",))
assert mgr.load_project_docs() == []
def test_returns_empty_when_not_trusted(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False)
(tmp_path / "AGENTS.md").write_text("# Hello", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.load_project_docs() == []
def test_returns_single_doc_when_trust_root_is_cwd(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
)
(tmp_path / "AGENTS.md").write_text("# Root doc", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
docs = mgr.load_project_docs()
assert len(docs) == 1
assert docs[0] == (tmp_path.resolve(), "# Root doc")
def test_walks_up_to_trust_root(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
child = tmp_path / "sub" / "deep"
child.mkdir(parents=True)
monkeypatch.chdir(child)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
)
(tmp_path / "AGENTS.md").write_text("# Root", encoding="utf-8")
(child / "AGENTS.md").write_text("# Child", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
docs = mgr.load_project_docs()
# outermost first
assert docs[0] == (tmp_path.resolve(), "# Root")
assert docs[-1] == (child.resolve(), "# Child")
def test_skips_dirs_without_agents_md(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
child = tmp_path / "sub" / "deep"
child.mkdir(parents=True)
monkeypatch.chdir(child)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
)
# Only root has AGENTS.md, intermediate "sub" does not
(tmp_path / "AGENTS.md").write_text("# Root", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
docs = mgr.load_project_docs()
assert len(docs) == 1
assert docs[0] == (tmp_path.resolve(), "# Root")
def test_stops_at_trust_root_boundary(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
trust_root = tmp_path / "root"
child = trust_root / "sub"
child.mkdir(parents=True)
monkeypatch.chdir(child)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: trust_root.resolve()
)
# Place AGENTS.md above trust root — should NOT be loaded
(tmp_path / "AGENTS.md").write_text("# Above root", encoding="utf-8")
(trust_root / "AGENTS.md").write_text("# At root", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
docs = mgr.load_project_docs()
assert len(docs) == 1
assert docs[0][0] == trust_root.resolve()
def test_returns_empty_when_trust_root_not_ancestor(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
child = tmp_path / "project" / "sub"
child.mkdir(parents=True)
trust_root = tmp_path / "other-root"
trust_root.mkdir()
monkeypatch.chdir(child)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: trust_root.resolve()
)
(tmp_path / "AGENTS.md").write_text("# Outside", encoding="utf-8")
(child / "AGENTS.md").write_text("# Child", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.load_project_docs() == []
def test_ignores_empty_agents_md(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
)
(tmp_path / "AGENTS.md").write_text(" \n ", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.load_project_docs() == []
class TestFindSubdirectoryAgentsMd:
def test_returns_empty_when_project_not_in_sources(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
target = sub / "file.py"
target.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",))
assert mgr.find_subdirectory_agents_md(target) == []
def test_returns_empty_when_not_trusted(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "find_trust_root", lambda _: None)
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
target = sub / "file.py"
target.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.find_subdirectory_agents_md(target) == []
def test_returns_empty_when_file_outside_cwd(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
cwd = tmp_path / "project"
cwd.mkdir()
monkeypatch.chdir(cwd)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
outside = tmp_path / "other" / "file.py"
outside.parent.mkdir(parents=True)
outside.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.find_subdirectory_agents_md(outside) == []
def test_returns_empty_when_file_directly_in_cwd(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
(tmp_path / "AGENTS.md").write_text("# Root", encoding="utf-8")
target = tmp_path / "file.py"
target.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
# cwd-level AGENTS.md is handled by load_project_docs, not this method
assert mgr.find_subdirectory_agents_md(target) == []
def test_returns_agents_md_from_file_parent(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8")
target = sub / "file.py"
target.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
docs = mgr.find_subdirectory_agents_md(target)
assert len(docs) == 1
assert docs[0] == (sub.resolve(), "# Sub instructions")
def test_returns_multiple_agents_md_outermost_first(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
outer = tmp_path / "a"
inner = outer / "b"
inner.mkdir(parents=True)
(outer / "AGENTS.md").write_text("# Outer", encoding="utf-8")
(inner / "AGENTS.md").write_text("# Inner", encoding="utf-8")
target = inner / "file.py"
target.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
docs = mgr.find_subdirectory_agents_md(target)
assert len(docs) == 2
assert docs[0] == (outer.resolve(), "# Outer")
assert docs[1] == (inner.resolve(), "# Inner")
def test_skips_dirs_without_agents_md(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
outer = tmp_path / "a"
inner = outer / "b"
inner.mkdir(parents=True)
# Only inner has AGENTS.md
(inner / "AGENTS.md").write_text("# Inner", encoding="utf-8")
target = inner / "file.py"
target.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
docs = mgr.find_subdirectory_agents_md(target)
assert len(docs) == 1
assert docs[0] == (inner.resolve(), "# Inner")
def test_ignores_empty_agents_md(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text(" \n ", encoding="utf-8")
target = sub / "file.py"
target.write_text("", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.find_subdirectory_agents_md(target) == []
class TestProjectSkillsDirs:
def test_returns_empty_list_when_no_skills_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -332,3 +562,33 @@ class TestProjectSkillsDirs:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
mgr = HarnessFilesManager(sources=("user", "project"))
assert mgr.project_skills_dirs == [tmp_path / ".vibe" / "skills"]
class TestLoadUserDoc:
def test_returns_empty_when_user_not_in_sources(self) -> None:
mgr = HarnessFilesManager(sources=("project",))
assert mgr.load_user_doc() == ""
def test_returns_empty_when_file_does_not_exist(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path)
mgr = HarnessFilesManager(sources=("user",))
assert mgr.load_user_doc() == ""
def test_returns_file_content_when_file_exists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path)
(tmp_path / "AGENTS.md").write_text("# User instructions", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",))
assert mgr.load_user_doc() == "# User instructions"
def test_returns_empty_string_for_whitespace_only_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# load_user_doc strips — consistent with _collect_agents_md
monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path)
(tmp_path / "AGENTS.md").write_text(" \n ", encoding="utf-8")
mgr = HarnessFilesManager(sources=("user",))
assert mgr.load_user_doc() == ""

View file

@ -1,11 +1,13 @@
from __future__ import annotations
from pathlib import Path
import tomllib
import pytest
import tomli_w
from tests.conftest import build_test_vibe_config
from vibe.core.config import ModelConfig
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.config.harness_files import (
HarnessFilesManager,
init_harness_files_manager,
@ -84,6 +86,60 @@ class TestResolveConfigFile:
assert mgr.config_file == VIBE_HOME.path / "config.toml"
class TestMigrateRemovesFindFromBashAllowlist:
def test_removes_find_from_config_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {"tools": {"bash": {"allowlist": ["echo", "find", "ls"]}}}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert "find" not in result["tools"]["bash"]["allowlist"]
assert result["tools"]["bash"]["allowlist"] == ["echo", "ls"]
def test_noop_when_find_not_present(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {"tools": {"bash": {"allowlist": ["echo", "ls"]}}}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["tools"]["bash"]["allowlist"] == ["echo", "ls"]
def test_noop_when_no_bash_tools_section(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {"active_model": "test"}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert "tools" not in result
class TestAutoCompactThresholdFallback:
def test_model_without_explicit_threshold_inherits_global(self) -> None:
model = ModelConfig(name="m", provider="p", alias="m")
@ -119,3 +175,52 @@ class TestAutoCompactThresholdFallback:
auto_compact_threshold=75_000, models=[model], active_model="m"
)
assert cfg2.get_active_model().auto_compact_threshold == 75_000
class TestCompactionModel:
def test_get_compaction_model_returns_active_when_unset(self) -> None:
cfg = build_test_vibe_config()
assert cfg.get_compaction_model() == cfg.get_active_model()
def test_get_compaction_model_returns_configured_model(self) -> None:
compaction = ModelConfig(
name="compact-model", provider="mistral", alias="compact"
)
cfg = build_test_vibe_config(compaction_model=compaction)
assert cfg.get_compaction_model().name == "compact-model"
def test_compaction_model_provider_must_match_active(self) -> None:
from vibe.core.config import ProviderConfig
compaction = ModelConfig(
name="compact-model", provider="other", alias="compact"
)
providers = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
),
ProviderConfig(
name="other",
api_base="https://other.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
),
]
with pytest.raises(ValueError, match="must share the same provider"):
build_test_vibe_config(compaction_model=compaction, providers=providers)
def test_compaction_model_provider_must_exist(self) -> None:
compaction = ModelConfig(
name="compact-model", provider="missing-provider", alias="compact"
)
with pytest.raises(
ValueError,
match="Provider 'missing-provider' for model 'compact-model' not found in configuration",
):
build_test_vibe_config(compaction_model=compaction)
def test_compaction_model_excluded_from_model_dump_when_none(self) -> None:
cfg = build_test_vibe_config()
dumped = cfg.model_dump()
assert "compaction_model" not in dumped

View file

@ -0,0 +1,115 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.config import (
DEFAULT_TRANSCRIBE_MODELS,
DEFAULT_TRANSCRIBE_PROVIDERS,
TranscribeClient,
TranscribeModelConfig,
TranscribeProviderConfig,
)
class TestTranscribeConfigDefaults:
def test_default_transcribe_providers_loaded(self) -> None:
config = build_test_vibe_config()
assert len(config.transcribe_providers) == len(DEFAULT_TRANSCRIBE_PROVIDERS)
assert config.transcribe_providers[0].name == "mistral"
assert config.transcribe_providers[0].api_base == "wss://api.mistral.ai"
def test_default_transcribe_models_loaded(self) -> None:
config = build_test_vibe_config()
assert len(config.transcribe_models) == len(DEFAULT_TRANSCRIBE_MODELS)
assert config.transcribe_models[0].alias == "voxtral-realtime"
assert (
config.transcribe_models[0].name == "voxtral-mini-transcribe-realtime-2602"
)
def test_default_active_transcribe_model(self) -> None:
config = build_test_vibe_config()
assert config.active_transcribe_model == "voxtral-realtime"
class TestGetActiveTranscribeModel:
def test_resolves_by_alias(self) -> None:
config = build_test_vibe_config()
model = config.get_active_transcribe_model()
assert model.alias == "voxtral-realtime"
assert model.name == "voxtral-mini-transcribe-realtime-2602"
def test_raises_for_unknown_alias(self) -> None:
config = build_test_vibe_config(active_transcribe_model="nonexistent")
with pytest.raises(ValueError, match="not found in configuration"):
config.get_active_transcribe_model()
class TestGetTranscribeProviderForModel:
def test_resolves_by_name(self) -> None:
config = build_test_vibe_config()
model = config.get_active_transcribe_model()
provider = config.get_transcribe_provider_for_model(model)
assert provider.name == "mistral"
assert provider.api_base == "wss://api.mistral.ai"
def test_raises_for_unknown_provider(self) -> None:
config = build_test_vibe_config(
transcribe_models=[
TranscribeModelConfig(
name="test-model", provider="nonexistent", alias="test"
)
],
active_transcribe_model="test",
)
model = config.get_active_transcribe_model()
with pytest.raises(ValueError, match="not found in configuration"):
config.get_transcribe_provider_for_model(model)
class TestTranscribeModelUniqueness:
def test_duplicate_aliases_raise(self) -> None:
with pytest.raises(ValueError, match="Duplicate transcribe model alias"):
build_test_vibe_config(
transcribe_models=[
TranscribeModelConfig(
name="model-a", provider="mistral", alias="same-alias"
),
TranscribeModelConfig(
name="model-b", provider="mistral", alias="same-alias"
),
],
active_transcribe_model="same-alias",
)
class TestTranscribeModelConfig:
def test_alias_defaults_to_name(self) -> None:
model = TranscribeModelConfig.model_validate({
"name": "my-model",
"provider": "mistral",
})
assert model.alias == "my-model"
def test_explicit_alias(self) -> None:
model = TranscribeModelConfig(
name="my-model", provider="mistral", alias="custom-alias"
)
assert model.alias == "custom-alias"
def test_default_values(self) -> None:
model = TranscribeModelConfig(
name="my-model", provider="mistral", alias="my-model"
)
assert model.sample_rate == 16000
assert model.encoding == "pcm_s16le"
assert model.language == "en"
assert model.target_streaming_delay_ms == 500
class TestTranscribeProviderConfig:
def test_default_values(self) -> None:
provider = TranscribeProviderConfig(name="test")
assert provider.api_base == "wss://api.mistral.ai"
assert provider.api_key_env_var == ""
assert provider.client == TranscribeClient.MISTRAL

View file

@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest
import tomli_w
from vibe.core.paths import AGENTS_MD_FILENAMES, TRUSTED_FOLDERS_FILE
from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import (
TrustedFoldersManager,
has_agents_md_file,
@ -209,6 +209,99 @@ class TestTrustedFoldersManager:
assert manager.is_trusted(tmp_path) is True
class TestIsTrustedInheritance:
"""Tests for the walk-up trust inheritance behaviour."""
def test_child_of_trusted_folder_returns_true(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
child = tmp_path / "sub" / "deep"
child.mkdir(parents=True)
assert manager.is_trusted(child) is True
def test_child_of_untrusted_folder_returns_false(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
child = tmp_path / "sub"
child.mkdir()
assert manager.is_trusted(child) is False
def test_most_specific_ancestor_wins(self, tmp_path: Path) -> None:
parent = tmp_path / "parent"
child = parent / "child"
child.mkdir(parents=True)
manager = TrustedFoldersManager()
manager.add_trusted(parent)
manager.add_untrusted(child)
assert manager.is_trusted(parent) is True
assert manager.is_trusted(child) is False
assert manager.is_trusted(child / "grandchild") is False
def test_untrusted_parent_trusted_child(self, tmp_path: Path) -> None:
parent = tmp_path / "parent"
child = parent / "child"
child.mkdir(parents=True)
manager = TrustedFoldersManager()
manager.add_untrusted(parent)
manager.add_trusted(child)
assert manager.is_trusted(parent) is False
assert manager.is_trusted(child) is True
assert manager.is_trusted(child / "grandchild") is True
def test_deep_nesting_inherits_trust(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
deep = tmp_path / "a" / "b" / "c" / "d"
deep.mkdir(parents=True)
assert manager.is_trusted(deep) is True
def test_no_match_returns_none(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
assert manager.is_trusted(tmp_path / "unknown") is None
class TestFindTrustRoot:
def test_returns_path_when_path_is_trusted(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
assert manager.find_trust_root(tmp_path) == tmp_path.resolve()
def test_returns_ancestor_when_child(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
child = tmp_path / "sub" / "deep"
child.mkdir(parents=True)
assert manager.find_trust_root(child) == tmp_path.resolve()
def test_returns_none_when_no_trusted_ancestor(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
assert manager.find_trust_root(tmp_path) is None
def test_returns_closest_trusted_ancestor(self, tmp_path: Path) -> None:
parent = tmp_path / "parent"
child = parent / "child"
child.mkdir(parents=True)
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
manager.add_trusted(parent)
# child should find parent (closest), not tmp_path
assert manager.find_trust_root(child) == parent.resolve()
def test_ignores_untrusted_ancestors(self, tmp_path: Path) -> None:
parent = tmp_path / "parent"
child = parent / "child"
child.mkdir(parents=True)
manager = TrustedFoldersManager()
manager.add_untrusted(parent)
manager.add_trusted(tmp_path)
# find_trust_root skips untrusted, finds tmp_path
assert manager.find_trust_root(child) == tmp_path.resolve()
class TestHasAgentsMdFile:
def test_returns_false_for_empty_directory(self, tmp_path: Path) -> None:
assert has_agents_md_file(tmp_path) is False
@ -217,21 +310,13 @@ class TestHasAgentsMdFile:
(tmp_path / "AGENTS.md").write_text("# Agents", encoding="utf-8")
assert has_agents_md_file(tmp_path) is True
def test_returns_true_when_vibe_md_exists(self, tmp_path: Path) -> None:
(tmp_path / "VIBE.md").write_text("# Vibe", encoding="utf-8")
assert has_agents_md_file(tmp_path) is True
def test_returns_true_when_dot_vibe_md_exists(self, tmp_path: Path) -> None:
(tmp_path / ".vibe.md").write_text("# Vibe", encoding="utf-8")
assert has_agents_md_file(tmp_path) is True
def test_returns_false_when_only_other_files_exist(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("", encoding="utf-8")
(tmp_path / ".vibe").mkdir()
assert has_agents_md_file(tmp_path) is False
def test_agents_md_filenames_constant(self) -> None:
assert AGENTS_MD_FILENAMES == ["AGENTS.md", "VIBE.md", ".vibe.md"]
def test_agents_md_filename_constant(self) -> None:
assert AGENTS_MD_FILENAME == "AGENTS.md"
class TestHasTrustableContent:
@ -244,10 +329,9 @@ class TestHasTrustableContent:
assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
for name in AGENTS_MD_FILENAMES:
(tmp_path / name).write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is True
(tmp_path / name).unlink()
(tmp_path / AGENTS_MD_FILENAME).write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is True
(tmp_path / AGENTS_MD_FILENAME).unlink()
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
(tmp_path / "other.txt").write_text("", encoding="utf-8")

View file

@ -0,0 +1,136 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from vibe.core.config.harness_files import (
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.tools.builtins.read_file import (
ReadFile,
ReadFileResult,
ReadFileState,
ReadFileToolConfig,
)
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.utils import VIBE_WARNING_TAG
@pytest.fixture()
def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Initialize harness files manager for tests, reset after."""
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
monkeypatch.setattr(
trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve()
)
reset_harness_files_manager()
init_harness_files_manager("user", "project")
yield
reset_harness_files_manager()
def _make_read_file() -> ReadFile:
return ReadFile(config=ReadFileToolConfig(), state=ReadFileState())
class TestGetResultExtra:
@pytest.mark.usefixtures("_setup_manager")
def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
target = sub / "file.py"
target.write_text("hello", encoding="utf-8")
tool = _make_read_file()
result = ReadFileResult(
path=str(target), content="hello", lines_read=1, was_truncated=False
)
assert tool.get_result_extra(result) is None
@pytest.mark.usefixtures("_setup_manager")
def test_returns_tagged_content_when_agents_md_found(self, tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8")
target = sub / "file.py"
target.write_text("hello", encoding="utf-8")
tool = _make_read_file()
result = ReadFileResult(
path=str(target), content="hello", lines_read=1, was_truncated=False
)
annotation = tool.get_result_extra(result)
assert annotation is not None
assert f"<{VIBE_WARNING_TAG}>" in annotation
assert f"</{VIBE_WARNING_TAG}>" in annotation
assert "# Sub instructions" in annotation
assert "project instructions for this directory" in annotation
@pytest.mark.usefixtures("_setup_manager")
def test_deduplicates_across_calls(self, tmp_path: Path) -> None:
sub = tmp_path / "sub"
sub.mkdir()
(sub / "AGENTS.md").write_text("# Sub", encoding="utf-8")
file1 = sub / "a.py"
file2 = sub / "b.py"
file1.write_text("a", encoding="utf-8")
file2.write_text("b", encoding="utf-8")
tool = _make_read_file()
result1 = ReadFileResult(
path=str(file1), content="a", lines_read=1, was_truncated=False
)
assert tool.get_result_extra(result1) is not None
# Second call for a different file in the same dir → no duplicate
result2 = ReadFileResult(
path=str(file2), content="b", lines_read=1, was_truncated=False
)
assert tool.get_result_extra(result2) is None
@pytest.mark.usefixtures("_setup_manager")
def test_injects_new_dir_after_dedup(self, tmp_path: Path) -> None:
sub_a = tmp_path / "a"
sub_b = tmp_path / "b"
sub_a.mkdir()
sub_b.mkdir()
(sub_a / "AGENTS.md").write_text("# A", encoding="utf-8")
(sub_b / "AGENTS.md").write_text("# B", encoding="utf-8")
file_a = sub_a / "f.py"
file_b = sub_b / "f.py"
file_a.write_text("", encoding="utf-8")
file_b.write_text("", encoding="utf-8")
tool = _make_read_file()
r1 = ReadFileResult(
path=str(file_a), content="", lines_read=0, was_truncated=False
)
ann1 = tool.get_result_extra(r1)
assert ann1 is not None
assert "# A" in ann1
# Different subdirectory → should inject its AGENTS.md
r2 = ReadFileResult(
path=str(file_b), content="", lines_read=0, was_truncated=False
)
ann2 = tool.get_result_extra(r2)
assert ann2 is not None
assert "# B" in ann2
def test_returns_none_when_manager_not_initialized(self, tmp_path: Path) -> None:
reset_harness_files_manager()
tool = _make_read_file()
result = ReadFileResult(
path=str(tmp_path / "file.py"),
content="",
lines_read=0,
was_truncated=False,
)
assert tool.get_result_extra(result) is None
reset_harness_files_manager()

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">RecordingActiveApp</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="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-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-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="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="605.6" textLength="183" clip-path="url(#terminal-line-24)">devstral-latest</text><text class="terminal-r1" x="622.2" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;·&#160;[Subscription]&#160;Pro</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="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type&#160;</text><text class="terminal-r3" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">&#160;for&#160;more&#160;information</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="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-r4" 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-r4" 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)"></text><text class="terminal-r5" x="48.8" y="776.4" textLength="1390.8" clip-path="url(#terminal-line-31)">hello&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r4" 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-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" 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-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" 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-r4" 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,200 @@
<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: #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">VoiceDisabledRecordingAttemptApp</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="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-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-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="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="605.6" textLength="183" clip-path="url(#terminal-line-24)">devstral-latest</text><text class="terminal-r1" x="622.2" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;·&#160;[Subscription]&#160;Pro</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="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type&#160;</text><text class="terminal-r3" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">&#160;for&#160;more&#160;information</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="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-r4" 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-r4" 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-r1" x="48.8" y="776.4" textLength="1390.8" clip-path="url(#terminal-line-31)">hello&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r4" 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-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" 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-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" 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-r4" 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-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" 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,200 @@
<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: #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">RecordingActiveApp</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="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-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-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="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="605.6" textLength="183" clip-path="url(#terminal-line-24)">devstral-latest</text><text class="terminal-r1" x="622.2" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;·&#160;[Subscription]&#160;Pro</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="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type&#160;</text><text class="terminal-r3" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">&#160;for&#160;more&#160;information</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="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-r4" 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-r4" 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-r1" x="48.8" y="776.4" textLength="1390.8" clip-path="url(#terminal-line-31)">hello&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r4" 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-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" 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-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" 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-r4" 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-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" 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,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">RecordingThenConversationApp</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="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-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="134.2" clip-path="url(#terminal-line-20)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type&#160;</text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">&#160;for&#160;more&#160;information</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-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="61" clip-path="url(#terminal-line-25)">hello</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="268.4" clip-path="url(#terminal-line-27)">I&#x27;m&#160;ready&#160;to&#160;help&#160;you.</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: 13 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">RecordingActiveApp</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="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-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-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="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="605.6" textLength="183" clip-path="url(#terminal-line-24)">devstral-latest</text><text class="terminal-r1" x="622.2" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;·&#160;[Subscription]&#160;Pro</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="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type&#160;</text><text class="terminal-r3" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">&#160;for&#160;more&#160;information</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="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-r4" 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-r4" 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)"></text><text class="terminal-r5" x="48.8" y="776.4" textLength="1390.8" clip-path="url(#terminal-line-31)">Hello&#160;world&#160;from&#160;voice&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r4" 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-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r4" 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-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r4" 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-r4" 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,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">VoiceDisableApp</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="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-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-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;·&#160;[Subscription]&#160;Pro</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="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="134.2" clip-path="url(#terminal-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type&#160;</text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)">&#160;for&#160;more&#160;information</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-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r2" x="24.4" y="654.4" textLength="73.2" clip-path="url(#terminal-line-26)">/voice</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="244" clip-path="url(#terminal-line-27)">Voice&#160;mode&#160;disabled.</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: 13 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">VoiceEnableApp</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="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-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-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;·&#160;[Subscription]&#160;Pro</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="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="134.2" clip-path="url(#terminal-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type&#160;</text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)">&#160;for&#160;more&#160;information</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-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r2" x="24.4" y="654.4" textLength="73.2" clip-path="url(#terminal-line-26)">/voice</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="634.4" clip-path="url(#terminal-line-27)">Voice&#160;mode&#160;enabled.&#160;Press&#160;ctrl+r&#160;to&#160;start&#160;recording.</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: 13 KiB

View file

@ -0,0 +1,148 @@
from __future__ import annotations
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 tests.stubs.fake_voice_manager import FakeVoiceManager
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
class VoiceEnableApp(BaseSnapshotTestApp):
def __init__(self) -> None:
super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=False))
class VoiceDisableApp(BaseSnapshotTestApp):
def __init__(self) -> None:
super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=True))
class RecordingActiveApp(BaseSnapshotTestApp):
"""Voice enabled — used for recording snapshot tests."""
def __init__(self) -> None:
super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=True))
class VoiceDisabledRecordingAttemptApp(BaseSnapshotTestApp):
"""Voice disabled — ctrl+r should be ignored."""
def __init__(self) -> None:
super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=False))
class RecordingThenConversationApp(BaseSnapshotTestApp):
"""Voice enabled + FakeBackend that returns an LLM response."""
def __init__(self) -> None:
fake_backend = FakeBackend(mock_llm_chunk(content="I'm ready to help you."))
super().__init__(
voice_manager=FakeVoiceManager(is_voice_ready=True), backend=fake_backend
)
def test_snapshot_voice_enable(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"/voice")
await pilot.press("enter")
await pilot.pause(0.4)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:VoiceEnableApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_voice_disable(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"/voice")
await pilot.press("enter")
await pilot.pause(0.4)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:VoiceDisableApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_recording_indicator_shown(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"hello")
await pilot.pause(0.2)
await pilot.press("ctrl+r")
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:RecordingActiveApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_recording_stopped_after_keypress(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"hello")
await pilot.pause(0.2)
await pilot.press("ctrl+r")
await pilot.pause(0.5)
await pilot.press("a")
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:RecordingActiveApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_recording_not_started_when_voice_disabled(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"hello")
await pilot.pause(0.2)
await pilot.press("ctrl+r")
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:VoiceDisabledRecordingAttemptApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_recording_then_conversation(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press(*"hello")
await pilot.pause(0.2)
await pilot.press("ctrl+r")
await pilot.pause(0.5)
await pilot.press("a")
await pilot.pause(0.5)
await pilot.press("enter")
await pilot.pause(0.4)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:RecordingThenConversationApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_transcription_populates_text_area(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.press("ctrl+r")
await pilot.pause(0.3)
pilot.app.query_one(ChatInputBody).on_transcribe_text("Hello world from voice")
await pilot.pause(0.3)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:RecordingActiveApp",
terminal_size=(120, 36),
run_before=run_before,
)

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable
from vibe.core.audio_recorder import (
AlreadyRecordingError,
AudioRecording,
RecordingMode,
)
FAKE_WAV_DATA = b"RIFF\x00\x00\x00\x00WAVEfmt "
class FakeAudioRecorder:
def __init__(self, *, fake_wav_data: bytes = FAKE_WAV_DATA) -> None:
self._recording = False
self._peak = 0.0
self._mode = RecordingMode.BUFFER
self._fake_wav_data = fake_wav_data
self._stream_chunks: list[bytes] = []
@property
def is_recording(self) -> bool:
return self._recording
@property
def peak(self) -> float:
return self._peak
@property
def mode(self) -> RecordingMode:
return self._mode
def start(
self,
mode: RecordingMode,
*,
sample_rate: int = 48_000,
channels: int = 1,
max_duration: float = 300.0,
on_expire: Callable[[AudioRecording], object] | None = None,
) -> None:
if self._recording:
raise AlreadyRecordingError("Already recording")
self._recording = True
self._mode = mode
def stop(self, *, wait_for_queue_drained: bool = True) -> AudioRecording:
if not self._recording:
return AudioRecording(data=b"", duration=0.0)
self._recording = False
return AudioRecording(data=self._fake_wav_data, duration=1.0)
def cancel(self) -> None:
self._recording = False
async def audio_stream(self) -> AsyncGenerator[bytes, None]:
for chunk in self._stream_chunks:
yield chunk
def set_peak(self, value: float) -> None:
self._peak = value
def set_stream_chunks(self, chunks: list[bytes]) -> None:
self._stream_chunks = chunks

View file

@ -0,0 +1,22 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
from vibe.core.transcribe.transcribe_client_port import TranscribeEvent
class FakeTranscribeClient:
def __init__(
self, *_args: Any, events: list[TranscribeEvent] | None = None, **_kwargs: Any
) -> None:
self._events: list[TranscribeEvent] = events or []
def set_events(self, events: list[TranscribeEvent]) -> None:
self._events = events
async def transcribe(
self, audio_stream: AsyncIterator[bytes]
) -> AsyncIterator[TranscribeEvent]:
for event in self._events:
yield event

View file

@ -0,0 +1,74 @@
from __future__ import annotations
from tests.stubs.fake_audio_recorder import FakeAudioRecorder
from vibe.cli.voice_manager import VoiceToggleResult
from vibe.cli.voice_manager.voice_manager_port import (
TranscribeState,
VoiceManagerListener,
)
from vibe.core.audio_recorder import AudioRecorderPort
from vibe.core.audio_recorder.audio_recorder_port import RecordingMode
class FakeVoiceManager:
def __init__(
self,
*,
is_voice_ready: bool = False,
audio_recorder: AudioRecorderPort | None = None,
) -> None:
self._enabled = is_voice_ready
self._audio_recorder: AudioRecorderPort = audio_recorder or FakeAudioRecorder()
self._transcribe_state = TranscribeState.IDLE
self._listeners: list[VoiceManagerListener] = []
@property
def is_enabled(self) -> bool:
return self._enabled
@property
def transcribe_state(self) -> TranscribeState:
return self._transcribe_state
@property
def peak(self) -> float:
return self._audio_recorder.peak
def toggle_voice_mode(self) -> VoiceToggleResult:
self._enabled = not self._enabled
if not self._enabled:
self.cancel_recording()
for listener in self._listeners:
listener.on_voice_mode_change(self._enabled)
return VoiceToggleResult(enabled=self._enabled)
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None:
self._set_state(TranscribeState.RECORDING)
async def stop_recording(self) -> None:
self._set_state(TranscribeState.FLUSHING)
self._set_state(TranscribeState.IDLE)
def cancel_recording(self) -> None:
if self._transcribe_state == TranscribeState.IDLE:
return
if self._transcribe_state == TranscribeState.RECORDING:
pass # fake: no actual audio to cancel
self._set_state(TranscribeState.IDLE)
def add_listener(self, listener: VoiceManagerListener) -> None:
if listener not in self._listeners:
self._listeners.append(listener)
def remove_listener(self, listener: VoiceManagerListener) -> None:
try:
self._listeners.remove(listener)
except ValueError:
pass
def _set_state(self, state: TranscribeState) -> None:
if self._transcribe_state == state:
return
self._transcribe_state = state
for listener in self._listeners:
listener.on_transcribe_state_change(state)

View file

@ -9,6 +9,7 @@ from tests.conftest import (
)
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.config import ModelConfig
from vibe.core.types import (
AssistantEvent,
CompactEndEvent,
@ -125,3 +126,56 @@ async def test_compact_replaces_messages_with_summary() -> None:
assert agent.messages[0].role == Role.system
assert agent.messages[-1].role == Role.assistant
assert agent.messages[-1].content == "<final>"
class _ModelTrackingBackend(FakeBackend):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.requested_models: list[ModelConfig] = []
async def complete(self, *, model, **kwargs):
self.requested_models.append(model)
return await super().complete(model=model, **kwargs)
@pytest.mark.asyncio
async def test_compact_uses_compaction_model() -> None:
"""When compaction_model is set, compact() uses it instead of active_model."""
compaction = ModelConfig(
name="compaction-model",
provider="mistral",
alias="compaction",
auto_compact_threshold=1,
)
backend = _ModelTrackingBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(
models=make_test_models(auto_compact_threshold=1), compaction_model=compaction
)
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
assert backend.requested_models[0].name == "compaction-model"
assert backend.requested_models[1].name != "compaction-model"
@pytest.mark.asyncio
async def test_compact_uses_active_model_when_no_compaction_model() -> None:
"""Without compaction_model, compact() falls back to the active model."""
backend = _ModelTrackingBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
active = cfg.get_active_model()
assert backend.requested_models[0].name == active.name
assert backend.requested_models[1].name == active.name

View file

@ -111,6 +111,17 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
assert agent.stats.context_tokens == 275
@pytest.mark.asyncio
async def test_passes_session_id_to_backend(vibe_config: VibeConfig):
backend = FakeBackend([mock_llm_chunk(content="Response")])
agent = build_test_agent_loop(config=vibe_config, backend=backend)
[_ async for _ in agent.act("Hello")]
assert len(backend.requests_metadata) > 0
assert backend.requests_metadata[0] == {"session_id": agent.session_id}
@pytest.mark.asyncio
async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
metadata = EntrypointMetadata(
@ -135,6 +146,7 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
"agent_version": "2.0.0",
"client_name": "vibe_ide",
"client_version": "0.5.0",
"session_id": agent.session_id,
}

View file

@ -7,6 +7,7 @@ from typing import cast
from unittest.mock import AsyncMock, Mock
import httpx
from pydantic import BaseModel
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
@ -395,12 +396,16 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
)
middleware = CountingMiddleware()
agent.middleware_pipeline.add(middleware)
agent.set_approval_callback(
lambda _name, _args, _id: (
async def _reject_callback(
_name: str, _args: BaseModel, _id: str
) -> tuple[ApprovalResponse, str | None]:
return (
ApprovalResponse.NO,
str(get_user_cancellation_message(CancellationReason.OPERATION_CANCELLED)),
)
)
agent.set_approval_callback(_reject_callback)
agent.session_logger.save_interaction = AsyncMock(return_value=None)
events = [event async for event in agent.act("Cancel mid stream?")]
@ -418,6 +423,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
assert events[-1].skipped is True
assert events[-1].skip_reason is not None
assert "<user_cancellation>" in events[-1].skip_reason
assert events[-1].cancelled is True
assert agent.session_logger.save_interaction.await_count >= 1

View file

@ -16,13 +16,13 @@ from vibe.core.config import VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoItem
from vibe.core.types import (
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
BaseEvent,
FunctionCall,
LLMMessage,
Role,
SyncApprovalCallback,
ToolCall,
ToolCallEvent,
ToolResultEvent,
@ -58,7 +58,7 @@ def make_agent_loop(
auto_approve: bool = True,
todo_permission: ToolPermission = ToolPermission.ALWAYS,
backend: FakeBackend,
approval_callback: SyncApprovalCallback | None = None,
approval_callback: ApprovalCallback | None = None,
) -> AgentLoop:
agent_name = (
BuiltinAgentName.AUTO_APPROVE if auto_approve else BuiltinAgentName.DEFAULT
@ -165,7 +165,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved(
@pytest.mark.asyncio
async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None:
def approval_callback(
async def approval_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.YES, None)
@ -209,7 +209,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
) -> None:
custom_feedback = "User declined tool execution"
def approval_callback(
async def approval_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.NO, custom_feedback)
@ -237,6 +237,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason == custom_feedback
assert events[3].cancelled is False
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@ -297,7 +298,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
callback_invocations = []
agent_ref: AgentLoop | None = None
def approval_callback(
async def approval_callback(
tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
callback_invocations.append(tool_name)
@ -441,9 +442,9 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
async def test_tool_call_can_be_interrupted() -> None:
"""Test that tool calls can be interrupted via asyncio.CancelledError.
Note: KeyboardInterrupt is no longer handled here as ctrl+C now quits the app directly.
When a tool raises CancelledError, the error is captured as a cancellation event
and the agent loop stops gracefully after the current tool batch completes.
"""
exception_class = asyncio.CancelledError
tool_call = ToolCall(
id="call_8", index=0, function=FunctionCall(name="stub_tool", arguments="{}")
)
@ -460,12 +461,11 @@ async def test_tool_call_can_be_interrupted() -> None:
agent_loop.tool_manager._available["stub_tool"] = FakeTool
stub_tool_instance = agent_loop.tool_manager.get("stub_tool")
assert isinstance(stub_tool_instance, FakeTool)
stub_tool_instance._exception_to_raise = exception_class()
stub_tool_instance._exception_to_raise = asyncio.CancelledError()
events: list[BaseEvent] = []
with pytest.raises(exception_class):
async for ev in agent_loop.act("Execute tool"):
events.append(ev)
async for ev in agent_loop.act("Execute tool"):
events.append(ev)
tool_result_event = next(
(e for e in events if isinstance(e, ToolResultEvent)), None
@ -473,6 +473,11 @@ async def test_tool_call_can_be_interrupted() -> None:
assert tool_result_event is not None
assert tool_result_event.error is not None
assert "execution interrupted by user" in tool_result_event.error.lower()
assert agent_loop.stats.tool_calls_failed == 1
# Agent loop should stop after cancellation — no second LLM turn
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
assert len(assistant_events) == 1
@pytest.mark.asyncio
@ -529,3 +534,353 @@ async def test_ensure_assistant_after_tool_appends_understood() -> None:
idx = next(i for i, m in enumerate(agent_loop.messages) if m.role == Role.tool)
assert agent_loop.messages[idx + 1].role == Role.assistant
assert agent_loop.messages[idx + 1].content == "Understood."
@pytest.mark.asyncio
async def test_parallel_tool_calls_produce_correct_events(
telemetry_events: list[dict],
) -> None:
"""Two tool calls in one LLM response should execute in parallel and produce correct events."""
tool_call_1 = make_todo_tool_call("call_p1", index=0)
tool_call_2 = make_todo_tool_call("call_p2", index=1)
backend = FakeBackend([
[
mock_llm_chunk(
content="Let me check two things.",
tool_calls=[tool_call_1, tool_call_2],
)
],
[mock_llm_chunk(content="Both done.")],
])
agent_loop = make_agent_loop(auto_approve=True, backend=backend)
events = await act_and_collect_events(agent_loop, "Check two things")
event_types = [type(e) for e in events]
# UserMessage, Assistant, ToolCall, ToolCall, then two ToolResults (order may vary), then Assistant
assert event_types[0] is UserMessageEvent
assert event_types[1] is AssistantEvent
# Both ToolCallEvents emitted upfront
assert event_types[2] is ToolCallEvent
assert event_types[3] is ToolCallEvent
tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
assert {e.tool_call_id for e in tool_call_events} == {"call_p1", "call_p2"}
# Both ToolResultEvents present
tool_result_events = [e for e in events if isinstance(e, ToolResultEvent)]
assert len(tool_result_events) == 2
assert {e.tool_call_id for e in tool_result_events} == {"call_p1", "call_p2"}
for tool_result in tool_result_events:
assert tool_result.error is None
assert tool_result.skipped is False
assert tool_result.result is not None
# Final assistant message
assert event_types[-1] is AssistantEvent
assert isinstance(events[-1], AssistantEvent)
assert events[-1].content == "Both done."
# Verify conversation history has both tool responses
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert {m.tool_call_id for m in tool_msgs} == {"call_p1", "call_p2"}
assert agent_loop.stats.tool_calls_succeeded == 2
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 2
@pytest.mark.asyncio
async def test_parallel_tool_calls_with_approval_callback(
telemetry_events: list[dict],
) -> None:
"""Two parallel tool calls requiring approval should both succeed when approved."""
approval_calls: list[str] = []
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
approval_calls.append(tool_call_id)
return (ApprovalResponse.YES, None)
tool_call_1 = make_todo_tool_call("call_a1", index=0)
tool_call_2 = make_todo_tool_call("call_a2", index=1)
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
backend=FakeBackend([
[
mock_llm_chunk(
content="Checking two.", tool_calls=[tool_call_1, tool_call_2]
)
],
[mock_llm_chunk(content="Both approved and done.")],
]),
)
events = await act_and_collect_events(agent_loop, "Check two things")
# Both should have been approved
assert set(approval_calls) == {"call_a1", "call_a2"}
tool_result_events = [e for e in events if isinstance(e, ToolResultEvent)]
assert len(tool_result_events) == 2
for tool_result in tool_result_events:
assert tool_result.error is None
assert tool_result.skipped is False
assert tool_result.result is not None
assert agent_loop.stats.tool_calls_agreed == 2
assert agent_loop.stats.tool_calls_succeeded == 2
@pytest.mark.asyncio
async def test_parallel_approvals_can_run_concurrently() -> None:
"""The core does not serialize approval callbacks — that is a CLI-layer concern.
Parallel tool calls may invoke the approval callback concurrently.
"""
concurrency = 0
max_concurrency = 0
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
nonlocal concurrency, max_concurrency
concurrency += 1
max_concurrency = max(max_concurrency, concurrency)
await asyncio.sleep(0.01)
concurrency -= 1
return (ApprovalResponse.YES, None)
tool_calls = [make_todo_tool_call(f"call_s{i}", index=i) for i in range(3)]
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
backend=FakeBackend([
[mock_llm_chunk(content="Three tools.", tool_calls=tool_calls)],
[mock_llm_chunk(content="All done.")],
]),
)
await act_and_collect_events(agent_loop, "Go")
assert max_concurrency > 1
assert agent_loop.stats.tool_calls_agreed == 3
assert agent_loop.stats.tool_calls_succeeded == 3
@pytest.mark.asyncio
async def test_parallel_mixed_approval_and_rejection(
telemetry_events: list[dict],
) -> None:
"""One tool approved, one rejected — both should produce correct events."""
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
if tool_call_id == "call_yes":
return (ApprovalResponse.YES, None)
return (ApprovalResponse.NO, "Denied by user")
tc_yes = make_todo_tool_call("call_yes", index=0)
tc_no = make_todo_tool_call("call_no", index=1)
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
backend=FakeBackend([
[mock_llm_chunk(content="Two tools.", tool_calls=[tc_yes, tc_no])],
[mock_llm_chunk(content="Mixed results.")],
]),
)
events = await act_and_collect_events(agent_loop, "Go")
tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
assert len(tool_results) == 2
results_by_id = {e.tool_call_id: e for e in tool_results}
assert results_by_id["call_yes"].error is None
assert results_by_id["call_yes"].skipped is False
assert results_by_id["call_yes"].result is not None
assert results_by_id["call_no"].skipped is True
assert results_by_id["call_no"].skip_reason == "Denied by user"
assert results_by_id["call_no"].cancelled is False
assert agent_loop.stats.tool_calls_agreed == 1
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_succeeded == 1
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 2
@pytest.mark.asyncio
async def test_parallel_three_tools_all_succeed(telemetry_events: list[dict]) -> None:
"""Three parallel tool calls should all complete successfully."""
tool_calls = [make_todo_tool_call(f"call_t{i}", index=i) for i in range(3)]
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Three tools.", tool_calls=tool_calls)],
[mock_llm_chunk(content="All three done.")],
]),
)
events = await act_and_collect_events(agent_loop, "Go")
tool_call_events = [e for e in events if isinstance(e, ToolCallEvent)]
tool_result_events = [e for e in events if isinstance(e, ToolResultEvent)]
assert len(tool_call_events) == 3
assert len(tool_result_events) == 3
assert {e.tool_call_id for e in tool_call_events} == {
"call_t0",
"call_t1",
"call_t2",
}
assert {e.tool_call_id for e in tool_result_events} == {
"call_t0",
"call_t1",
"call_t2",
}
for tool_result in tool_result_events:
assert tool_result.error is None
assert tool_result.result is not None
assert agent_loop.stats.tool_calls_succeeded == 3
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert len(tool_msgs) == 3
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe.tool_call_finished"
]
assert len(tool_finished) == 3
@pytest.mark.asyncio
async def test_parallel_one_tool_error_does_not_block_others() -> None:
"""If one parallel tool fails, the other should still succeed."""
tc_good = make_todo_tool_call("call_good", index=0)
tc_bad = make_todo_tool_call(
"call_bad", index=1, arguments='{"action": "invalid_action"}'
)
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Two tools.", tool_calls=[tc_good, tc_bad])],
[mock_llm_chunk(content="Done.")],
]),
)
events = await act_and_collect_events(agent_loop, "Go")
tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
assert len(tool_results) == 2
results_by_id = {e.tool_call_id: e for e in tool_results}
assert results_by_id["call_good"].error is None
assert results_by_id["call_good"].result is not None
assert results_by_id["call_bad"].error is not None
assert results_by_id["call_bad"].result is None
assert agent_loop.stats.tool_calls_succeeded == 1
assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
async def test_parallel_all_rejected_no_callback() -> None:
"""Parallel tools with no approval callback should all be skipped."""
tc1 = make_todo_tool_call("call_nc1", index=0)
tc2 = make_todo_tool_call("call_nc2", index=1)
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
backend=FakeBackend([
[mock_llm_chunk(content="Two tools.", tool_calls=[tc1, tc2])],
[mock_llm_chunk(content="Cannot proceed.")],
]),
)
events = await act_and_collect_events(agent_loop, "Go")
tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
assert len(tool_results) == 2
for tool_result in tool_results:
assert tool_result.skipped is True
assert tool_result.result is None
assert agent_loop.stats.tool_calls_rejected == 2
assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
async def test_parallel_all_permission_never() -> None:
"""Parallel tools with NEVER permission skip without calling the approval callback."""
approval_calls: list[str] = []
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
approval_calls.append(tool_call_id)
return (ApprovalResponse.YES, None)
tc1 = make_todo_tool_call("call_nv1", index=0)
tc2 = make_todo_tool_call("call_nv2", index=1)
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.NEVER,
approval_callback=approval_callback,
backend=FakeBackend([
[mock_llm_chunk(content="Two tools.", tool_calls=[tc1, tc2])],
[mock_llm_chunk(content="Both disabled.")],
]),
)
events = await act_and_collect_events(agent_loop, "Go")
assert len(approval_calls) == 0
tool_results = [e for e in events if isinstance(e, ToolResultEvent)]
assert len(tool_results) == 2
for tool_result in tool_results:
assert tool_result.skipped is True
assert "permanently disabled" in (tool_result.skip_reason or "").lower()
assert agent_loop.stats.tool_calls_rejected == 2
@pytest.mark.asyncio
async def test_parallel_tool_call_events_emitted_before_results() -> None:
"""All ToolCallEvents must appear before any ToolResultEvent in the event stream."""
tool_calls = [make_todo_tool_call(f"call_o{i}", index=i) for i in range(3)]
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Three tools.", tool_calls=tool_calls)],
[mock_llm_chunk(content="Done.")],
]),
)
events = await act_and_collect_events(agent_loop, "Go")
last_call_idx = max(i for i, e in enumerate(events) if isinstance(e, ToolCallEvent))
first_result_idx = min(
i for i, e in enumerate(events) if isinstance(e, ToolResultEvent)
)
assert last_call_idx < first_result_idx
@pytest.mark.asyncio
async def test_parallel_conversation_history_has_all_tool_messages() -> None:
"""All parallel tool results must appear in the conversation messages."""
tool_calls = [make_todo_tool_call(f"call_h{i}", index=i) for i in range(4)]
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Four tools.", tool_calls=tool_calls)],
[mock_llm_chunk(content="All four done.")],
]),
)
await act_and_collect_events(agent_loop, "Go")
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert {m.tool_call_id for m in tool_msgs} == {
"call_h0",
"call_h1",
"call_h2",
"call_h3",
}
assert agent_loop.stats.tool_calls_succeeded == 4

View file

@ -155,6 +155,7 @@ class TestAgentProfile:
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE,
BuiltinAgentName.LEAN,
}
@ -536,6 +537,26 @@ class TestAgentManagerFiltering:
assert "auto-approve" in agents
assert "explore" in agents
def test_install_required_agents_hidden_by_default(self) -> None:
config = build_test_vibe_config(
include_project_context=False, include_prompt_detail=False
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert "lean" not in agents
def test_install_required_agents_visible_when_installed(self) -> None:
config = build_test_vibe_config(
include_project_context=False,
include_prompt_detail=False,
installed_agents=["lean"],
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert "lean" in agents
def test_get_subagents_respects_filtering(self) -> None:
config = build_test_vibe_config(
include_project_context=False,

View file

@ -19,7 +19,7 @@ def test_history_manager_normalizes_loaded_entries_like_numbers_to_strings(
)
manager = HistoryManager(history_file)
result = manager.get_previous(current_input="", prefix="1")
result = manager.get_previous(current_input="")
assert result == "123"
@ -35,11 +35,11 @@ def test_history_manager_retains_a_fixed_number_of_entries(tmp_path: Path) -> No
reloaded = HistoryManager(history_file)
assert reloaded.get_previous(current_input="", prefix="") == "fourth"
assert reloaded.get_previous(current_input="", prefix="") == "third"
assert reloaded.get_previous(current_input="", prefix="") == "second"
assert reloaded.get_previous(current_input="") == "fourth"
assert reloaded.get_previous(current_input="") == "third"
assert reloaded.get_previous(current_input="") == "second"
# "first" is not proposed as we defined number of entries to 3
assert reloaded.get_previous(current_input="", prefix="") is None
assert reloaded.get_previous(current_input="") is None
def test_history_manager_filters_invalid_and_duplicated_entries(tmp_path: Path) -> None:
@ -54,11 +54,11 @@ def test_history_manager_filters_invalid_and_duplicated_entries(tmp_path: Path)
reloaded = HistoryManager(history_file)
assert reloaded.get_previous(current_input="", prefix="") == "third"
assert reloaded.get_previous(current_input="", prefix="") == "second"
assert reloaded.get_previous(current_input="", prefix="") == "first"
assert reloaded.get_previous(current_input="", prefix="") is None
assert reloaded.get_previous(current_input="", prefix="") is None
assert reloaded.get_previous(current_input="") == "third"
assert reloaded.get_previous(current_input="") == "second"
assert reloaded.get_previous(current_input="") == "first"
assert reloaded.get_previous(current_input="") is None
assert reloaded.get_previous(current_input="") is None
def test_history_manager_filters_commands(tmp_path: Path) -> None:
@ -69,9 +69,8 @@ def test_history_manager_filters_commands(tmp_path: Path) -> None:
reloaded = HistoryManager(history_file)
assert reloaded.get_previous(current_input="", prefix="/") == None
assert reloaded.get_previous(current_input="", prefix="") == "first"
assert reloaded.get_previous(current_input="", prefix="") is None
assert reloaded.get_previous(current_input="") == "first"
assert reloaded.get_previous(current_input="") is None
def test_history_manager_allows_navigation_round_trip(tmp_path: Path) -> None:
@ -88,7 +87,9 @@ def test_history_manager_allows_navigation_round_trip(tmp_path: Path) -> None:
assert manager.get_next() is None
def test_history_manager_prefix_filtering(tmp_path: Path) -> None:
def test_history_manager_preserves_original_draft_during_navigation(
tmp_path: Path,
) -> None:
history_file = tmp_path / "history.jsonl"
manager = HistoryManager(history_file)
@ -96,6 +97,7 @@ def test_history_manager_prefix_filtering(tmp_path: Path) -> None:
manager.add("bar")
manager.add("fizz")
assert manager.get_previous(current_input="", prefix="f") == "fizz"
assert manager.get_previous(current_input="", prefix="f") == "foo"
assert manager.get_previous(current_input="", prefix="f") is None
assert manager.get_previous(current_input="draft") == "fizz"
assert manager.get_previous(current_input="overwritten draft") == "bar"
assert manager.get_next() == "fizz"
assert manager.get_next() == "draft"

View file

@ -3,7 +3,12 @@ from __future__ import annotations
from unittest.mock import MagicMock
import httpx
import mistralai
from mistralai.client.models import (
AssistantMessage,
ContentChunk,
TextChunk,
ThinkChunk,
)
import pytest
import respx
@ -38,8 +43,8 @@ class TestMistralMapperParseContent:
def test_parse_content_text_chunk_returns_content_only(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.TextChunk(type="text", text="Hello from text chunk")
content: list[ContentChunk] = [
TextChunk(type="text", text="Hello from text chunk")
]
result = mapper.parse_content(content)
@ -50,12 +55,12 @@ class TestMistralMapperParseContent:
def test_parse_content_thinking_chunk_extracts_reasoning(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.ThinkChunk(
content: list[ContentChunk] = [
ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="Let me think...")],
thinking=[TextChunk(type="text", text="Let me think...")],
),
mistralai.TextChunk(type="text", text="The answer is 42."),
TextChunk(type="text", text="The answer is 42."),
]
result = mapper.parse_content(content)
@ -66,16 +71,16 @@ class TestMistralMapperParseContent:
def test_parse_content_multiple_thinking_chunks_concatenates(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.ThinkChunk(
content: list[ContentChunk] = [
ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="First thought. ")],
thinking=[TextChunk(type="text", text="First thought. ")],
),
mistralai.ThinkChunk(
ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="Second thought.")],
thinking=[TextChunk(type="text", text="Second thought.")],
),
mistralai.TextChunk(type="text", text="Final answer."),
TextChunk(type="text", text="Final answer."),
]
result = mapper.parse_content(content)
@ -86,10 +91,10 @@ class TestMistralMapperParseContent:
def test_parse_content_thinking_only_returns_empty_content(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = [
mistralai.ThinkChunk(
content: list[ContentChunk] = [
ThinkChunk(
type="thinking",
thinking=[mistralai.TextChunk(type="text", text="Just thinking...")],
thinking=[TextChunk(type="text", text="Just thinking...")],
)
]
@ -99,7 +104,7 @@ class TestMistralMapperParseContent:
def test_parse_content_empty_list_returns_empty(self):
mapper = MistralMapper()
content: list[mistralai.ContentChunk] = []
content: list[ContentChunk] = []
result = mapper.parse_content(content)
@ -113,7 +118,7 @@ class TestMistralMapperPrepareMessage:
result = mapper.prepare_message(msg)
assert isinstance(result, mistralai.AssistantMessage)
assert isinstance(result, AssistantMessage)
assert result.content == "Hello!"
def test_prepare_assistant_message_with_reasoning_creates_chunks(self):
@ -126,20 +131,20 @@ class TestMistralMapperPrepareMessage:
result = mapper.prepare_message(msg)
assert isinstance(result, mistralai.AssistantMessage)
assert isinstance(result, AssistantMessage)
assert isinstance(result.content, list)
assert len(result.content) == 2
think_chunk = result.content[0]
assert isinstance(think_chunk, mistralai.ThinkChunk)
assert isinstance(think_chunk, ThinkChunk)
assert think_chunk.type == "thinking"
assert len(think_chunk.thinking) == 1
inner_chunk = think_chunk.thinking[0]
assert isinstance(inner_chunk, mistralai.TextChunk)
assert isinstance(inner_chunk, TextChunk)
assert inner_chunk.text == "Let me calculate..."
text_chunk = result.content[1]
assert isinstance(text_chunk, mistralai.TextChunk)
assert isinstance(text_chunk, TextChunk)
assert text_chunk.type == "text"
assert text_chunk.text == "The answer is 42."
@ -151,20 +156,20 @@ class TestMistralMapperPrepareMessage:
result = mapper.prepare_message(msg)
assert isinstance(result, mistralai.AssistantMessage)
assert isinstance(result, AssistantMessage)
assert isinstance(result.content, list)
assert len(result.content) == 2
think_chunk = result.content[0]
assert isinstance(think_chunk, mistralai.ThinkChunk)
assert isinstance(think_chunk, ThinkChunk)
assert think_chunk.type == "thinking"
assert len(think_chunk.thinking) == 1
inner_chunk = think_chunk.thinking[0]
assert isinstance(inner_chunk, mistralai.TextChunk)
assert isinstance(inner_chunk, TextChunk)
assert inner_chunk.text == "Just thinking..."
text_chunk = result.content[1]
assert isinstance(text_chunk, mistralai.TextChunk)
assert isinstance(text_chunk, TextChunk)
assert text_chunk.text == ""

View file

@ -53,6 +53,23 @@ async def test_ui_navigation_through_input_history(
assert chat_input.value == ""
@pytest.mark.asyncio
async def test_ui_navigation_restores_partially_typed_draft_after_round_trip(
vibe_app: VibeApp, history_file: Path
) -> None:
async with vibe_app.run_test() as pilot:
inject_history_file(vibe_app, history_file)
chat_input = vibe_app.query_one(ChatInputContainer)
await pilot.press(*"he")
assert chat_input.value == "he"
await pilot.press("up")
assert chat_input.value == "how are you?"
await pilot.press("down")
assert chat_input.value == "he"
@pytest.mark.asyncio
async def test_ui_does_nothing_if_command_completion_is_active(
vibe_app: VibeApp, history_file: Path

View file

@ -76,6 +76,13 @@ async def test_decodes_non_utf8_bytes(bash):
assert result.stderr == ""
def test_find_not_in_default_allowlist():
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
# find -exec runs arbitrary commands; must not be allowlisted by default
permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
assert permission is not ToolPermission.ALWAYS
def test_resolve_permission():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState())
@ -89,3 +96,92 @@ def test_resolve_permission():
assert denylisted is ToolPermission.NEVER
assert mixed is None
assert empty is None
class TestResolvePermissionWindowsSyntax:
"""Verify allowlist/denylist works with Windows-style commands."""
def _make_bash(self, **kwargs) -> Bash:
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
def test_dir_with_windows_flags_allowlisted(self):
bash_tool = self._make_bash(allowlist=["dir"])
result = bash_tool.resolve_permission(BashArgs(command="dir /s /b"))
assert result is ToolPermission.ALWAYS
def test_type_command_allowlisted(self):
bash_tool = self._make_bash(allowlist=["type"])
result = bash_tool.resolve_permission(BashArgs(command="type file.txt"))
assert result is ToolPermission.ALWAYS
def test_findstr_allowlisted(self):
bash_tool = self._make_bash(allowlist=["findstr"])
result = bash_tool.resolve_permission(
BashArgs(command="findstr /s pattern *.txt")
)
assert result is ToolPermission.ALWAYS
def test_ver_allowlisted(self):
bash_tool = self._make_bash(allowlist=["ver"])
result = bash_tool.resolve_permission(BashArgs(command="ver"))
assert result is ToolPermission.ALWAYS
def test_where_allowlisted(self):
bash_tool = self._make_bash(allowlist=["where"])
result = bash_tool.resolve_permission(BashArgs(command="where python"))
assert result is ToolPermission.ALWAYS
def test_cmd_k_denylisted(self):
bash_tool = self._make_bash(denylist=["cmd /k"])
result = bash_tool.resolve_permission(BashArgs(command="cmd /k something"))
assert result is ToolPermission.NEVER
def test_powershell_noexit_denylisted(self):
bash_tool = self._make_bash(denylist=["powershell -NoExit"])
result = bash_tool.resolve_permission(BashArgs(command="powershell -NoExit"))
assert result is ToolPermission.NEVER
def test_notepad_denylisted(self):
bash_tool = self._make_bash(denylist=["notepad"])
result = bash_tool.resolve_permission(BashArgs(command="notepad file.txt"))
assert result is ToolPermission.NEVER
def test_cmd_standalone_denylisted(self):
bash_tool = self._make_bash(denylist_standalone=["cmd"])
result = bash_tool.resolve_permission(BashArgs(command="cmd"))
assert result is ToolPermission.NEVER
def test_powershell_standalone_denylisted(self):
bash_tool = self._make_bash(denylist_standalone=["powershell"])
result = bash_tool.resolve_permission(BashArgs(command="powershell"))
assert result is ToolPermission.NEVER
def test_powershell_cmdlet_asks(self):
bash_tool = self._make_bash(allowlist=["dir", "echo"])
result = bash_tool.resolve_permission(BashArgs(command="Get-ChildItem -Path ."))
assert result is None
def test_mixed_allowed_and_unknown_asks(self):
bash_tool = self._make_bash(allowlist=["git status"])
result = bash_tool.resolve_permission(
BashArgs(command="git status && npm install")
)
assert result is None
def test_chained_windows_commands_all_allowed(self):
bash_tool = self._make_bash(allowlist=["dir", "echo"])
result = bash_tool.resolve_permission(BashArgs(command="dir /s && echo done"))
assert result is ToolPermission.ALWAYS
def test_chained_commands_one_denied(self):
bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"])
result = bash_tool.resolve_permission(BashArgs(command="dir /s && rm -rf /"))
assert result is ToolPermission.NEVER
def test_piped_windows_commands(self):
bash_tool = self._make_bash(allowlist=["findstr", "type"])
result = bash_tool.resolve_permission(
BashArgs(command="type file.txt | findstr pattern")
)
assert result is ToolPermission.ALWAYS

View file

@ -45,7 +45,7 @@ class TestInvokeContext:
assert ctx.approval_callback is None
def test_approval_callback_can_be_set(self) -> None:
def dummy_callback(
async def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None
@ -75,7 +75,7 @@ class TestToolInvokeWithContext:
@pytest.mark.asyncio
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
def dummy_callback(
async def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None

View file

@ -2,7 +2,15 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import mistralai
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
from mistralai.client.models import (
ConversationResponse,
ConversationUsageInfo,
MessageOutputEntry,
TextChunk,
ToolReferenceChunk,
)
import pytest
from tests.mock.utils import collect_result
@ -13,13 +21,13 @@ from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSear
def _make_response(
content: list | None = None, outputs: list | None = None
) -> mistralai.ConversationResponse:
) -> ConversationResponse:
if outputs is None:
outputs = [mistralai.MessageOutputEntry(content=content or [])]
return mistralai.ConversationResponse(
outputs = [MessageOutputEntry(content=content or [])]
return ConversationResponse(
conversation_id="test",
outputs=outputs,
usage=mistralai.ConversationUsageInfo(
usage=ConversationUsageInfo(
prompt_tokens=10, completion_tokens=20, total_tokens=30
),
)
@ -34,7 +42,7 @@ def websearch(monkeypatch):
def test_parse_text_chunks(websearch):
response = _make_response(
content=[mistralai.TextChunk(text="Hello "), mistralai.TextChunk(text="world")]
content=[TextChunk(text="Hello "), TextChunk(text="world")]
)
result = websearch._parse_response(response)
assert result.answer == "Hello world"
@ -44,16 +52,12 @@ def test_parse_text_chunks(websearch):
def test_parse_sources_deduped(websearch):
response = _make_response(
content=[
mistralai.TextChunk(text="Answer"),
mistralai.ToolReferenceChunk(
tool="web_search", title="Site A", url="https://a.com"
),
mistralai.ToolReferenceChunk(
TextChunk(text="Answer"),
ToolReferenceChunk(tool="web_search", title="Site A", url="https://a.com"),
ToolReferenceChunk(
tool="web_search", title="Site A duplicate", url="https://a.com"
),
mistralai.ToolReferenceChunk(
tool="web_search", title="Site B", url="https://b.com"
),
ToolReferenceChunk(tool="web_search", title="Site B", url="https://b.com"),
]
)
result = websearch._parse_response(response)
@ -67,8 +71,8 @@ def test_parse_sources_deduped(websearch):
def test_parse_skips_source_without_url(websearch):
response = _make_response(
content=[
mistralai.TextChunk(text="Answer"),
mistralai.ToolReferenceChunk(tool="web_search", title="No URL"),
TextChunk(text="Answer"),
ToolReferenceChunk(tool="web_search", title="No URL"),
]
)
result = websearch._parse_response(response)
@ -82,16 +86,14 @@ def test_parse_empty_text_raises(websearch):
def test_parse_whitespace_only_raises(websearch):
response = _make_response(content=[mistralai.TextChunk(text=" ")])
response = _make_response(content=[TextChunk(text=" ")])
with pytest.raises(ToolError, match="No text in agent response"):
websearch._parse_response(response)
def test_parse_skips_non_message_entries(websearch):
response = _make_response(
outputs=[
mistralai.MessageOutputEntry(content=[mistralai.TextChunk(text="Answer")])
]
outputs=[MessageOutputEntry(content=[TextChunk(text="Answer")])]
)
result = websearch._parse_response(response)
assert result.answer == "Answer"
@ -110,18 +112,18 @@ async def test_run_missing_api_key(monkeypatch):
async def test_run_returns_parsed_result(websearch):
response = _make_response(
content=[
mistralai.TextChunk(text="The answer"),
mistralai.ToolReferenceChunk(
TextChunk(text="The answer"),
ToolReferenceChunk(
tool="web_search", title="Source", url="https://example.com"
),
]
)
mock_start = AsyncMock(return_value=response)
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
with patch.object(Mistral, "beta", create=True) as mock_beta:
mock_beta.conversations.start_async = mock_start
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
with patch.object(Mistral, "__aenter__", return_value=None):
with patch.object(Mistral, "__aexit__", return_value=None):
result = await collect_result(
websearch.run(WebSearchArgs(query="test query"))
)
@ -142,12 +144,12 @@ async def test_run_sdk_error_wrapped(websearch):
mock_response.text = "error"
mock_response.headers = httpx.Headers({"content-type": "application/json"})
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
with patch.object(Mistral, "beta", create=True) as mock_beta:
mock_beta.conversations.start_async = AsyncMock(
side_effect=mistralai.SDKError("API failed", mock_response)
side_effect=SDKError("API failed", mock_response)
)
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
with patch.object(Mistral, "__aenter__", return_value=None):
with patch.object(Mistral, "__aexit__", return_value=None):
with pytest.raises(ToolError, match="Mistral API error"):
await collect_result(websearch.run(WebSearchArgs(query="test")))

View file

@ -0,0 +1,157 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from unittest.mock import MagicMock
import pytest
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
from vibe.core.transcribe import (
MistralTranscribeClient,
TranscribeDone,
TranscribeError,
TranscribeSessionCreated,
TranscribeTextDelta,
)
def _make_provider() -> TranscribeProviderConfig:
return TranscribeProviderConfig(
name="mistral",
api_base="https://api.mistral.ai",
api_key_env_var="MISTRAL_API_KEY",
)
def _make_model() -> TranscribeModelConfig:
return TranscribeModelConfig(
name="mistral-small-transcribe",
alias="foo",
provider="mistral",
encoding="pcm_s16le",
sample_rate=16_000,
target_streaming_delay_ms=200,
)
async def _empty_audio_stream() -> AsyncIterator[bytes]:
return
yield
def _make_sdk_session_created() -> MagicMock:
from mistralai.client.models import RealtimeTranscriptionSessionCreated
return MagicMock(spec=RealtimeTranscriptionSessionCreated)
def _make_sdk_text_delta(text: str) -> MagicMock:
from mistralai.client.models import TranscriptionStreamTextDelta
m = MagicMock(spec=TranscriptionStreamTextDelta)
m.text = text
return m
def _make_sdk_done(text: str) -> MagicMock:
from mistralai.client.models import TranscriptionStreamDone
m = MagicMock(spec=TranscriptionStreamDone)
m.text = text
return m
def _make_sdk_error(message: str) -> MagicMock:
from mistralai.client.models import RealtimeTranscriptionError
m = MagicMock(spec=RealtimeTranscriptionError)
m.error = MagicMock()
m.error.message = message
return m
def _make_sdk_unknown() -> MagicMock:
from mistralai.extra.realtime import UnknownRealtimeEvent
return MagicMock(spec=UnknownRealtimeEvent)
async def _collect(client: MistralTranscribeClient) -> list[object]:
events: list[object] = []
async for event in client.transcribe(_empty_audio_stream()):
events.append(event)
return events
def _patch_sdk(sdk_events: list[object]) -> MagicMock:
async def _fake_stream(**_kwargs: object) -> AsyncIterator[object]:
for e in sdk_events:
yield e
mock_client = MagicMock()
mock_client.audio.realtime.transcribe_stream = _fake_stream
return mock_client
class TestEventMapping:
@pytest.mark.asyncio
async def test_session_created(self) -> None:
mock_client = _patch_sdk([_make_sdk_session_created()])
client = MistralTranscribeClient(_make_provider(), _make_model())
client._client = mock_client
events = await _collect(client)
assert len(events) == 1
assert isinstance(events[0], TranscribeSessionCreated)
@pytest.mark.asyncio
async def test_text_delta(self) -> None:
mock_client = _patch_sdk([_make_sdk_text_delta("hello")])
client = MistralTranscribeClient(_make_provider(), _make_model())
client._client = mock_client
events = await _collect(client)
assert len(events) == 1
assert isinstance(events[0], TranscribeTextDelta)
assert events[0].text == "hello"
@pytest.mark.asyncio
async def test_done(self) -> None:
mock_client = _patch_sdk([_make_sdk_done("full text")])
client = MistralTranscribeClient(_make_provider(), _make_model())
client._client = mock_client
events = await _collect(client)
assert len(events) == 1
assert isinstance(events[0], TranscribeDone)
@pytest.mark.asyncio
async def test_error(self) -> None:
mock_client = _patch_sdk([_make_sdk_error("something broke")])
client = MistralTranscribeClient(_make_provider(), _make_model())
client._client = mock_client
events = await _collect(client)
assert len(events) == 1
assert isinstance(events[0], TranscribeError)
assert events[0].message == "something broke"
@pytest.mark.asyncio
async def test_unknown_event_is_skipped(self) -> None:
mock_client = _patch_sdk([
_make_sdk_session_created(),
_make_sdk_unknown(),
_make_sdk_text_delta("hi"),
])
client = MistralTranscribeClient(_make_provider(), _make_model())
client._client = mock_client
events = await _collect(client)
assert len(events) == 2
assert isinstance(events[0], TranscribeSessionCreated)
assert isinstance(events[1], TranscribeTextDelta)

View file

View file

@ -0,0 +1,394 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_audio_recorder import FakeAudioRecorder
from tests.stubs.fake_transcribe_client import FakeTranscribeClient
from vibe.cli.voice_manager.voice_manager import VoiceManager
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
TranscribeState,
VoiceManagerListener,
VoiceToggleResult,
)
from vibe.core.audio_recorder.audio_recorder_port import (
AudioBackendUnavailableError,
NoAudioInputDeviceError,
)
from vibe.core.config import VibeConfig
from vibe.core.transcribe.transcribe_client_port import (
TranscribeDone,
TranscribeError,
TranscribeSessionCreated,
TranscribeTextDelta,
)
class StateListener(VoiceManagerListener):
def __init__(self) -> None:
self.state_changes: list[TranscribeState] = []
self.voice_mode_changes: list[bool] = []
self.transcribed_texts: list[str] = []
def on_transcribe_state_change(self, state: TranscribeState) -> None:
self.state_changes.append(state)
def on_voice_mode_change(self, enabled: bool) -> None:
self.voice_mode_changes.append(enabled)
def on_transcribe_text(self, text: str) -> None:
self.transcribed_texts.append(text)
def _make_manager(
*,
voice_mode_enabled: bool = True,
transcribe_client: FakeTranscribeClient | None = None,
) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]:
recorder = FakeAudioRecorder()
client = transcribe_client or FakeTranscribeClient()
config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled)
manager = VoiceManager(
config_getter=lambda: config, audio_recorder=recorder, transcribe_client=client
)
return manager, recorder, client
class TestStartRecording:
@pytest.mark.asyncio
async def test_start_sets_state_to_recording(self) -> None:
manager, _, _ = _make_manager()
manager.start_recording()
assert manager.transcribe_state == TranscribeState.RECORDING
@pytest.mark.asyncio
async def test_start_starts_audio_recorder_in_stream_mode(self) -> None:
manager, recorder, _ = _make_manager()
manager.start_recording()
assert recorder.is_recording
@pytest.mark.asyncio
async def test_start_noop_when_not_idle(self) -> None:
manager, recorder, _ = _make_manager()
manager.start_recording()
recorder.set_peak(0.5)
manager.start_recording()
assert recorder.peak == 0.5
assert manager.transcribe_state == TranscribeState.RECORDING
@pytest.mark.asyncio
async def test_start_raises_when_no_audio_input(self) -> None:
def raise_no_input(*a, **kw):
raise NoAudioInputDeviceError("no device")
manager, recorder, _ = _make_manager()
recorder.start = raise_no_input
with pytest.raises(RecordingStartError, match="No audio input device found"):
manager.start_recording()
assert manager.transcribe_state == TranscribeState.IDLE
@pytest.mark.asyncio
async def test_start_raises_when_no_backend(self) -> None:
def raise_no_backend(*a, **kw):
raise AudioBackendUnavailableError("no sd")
manager, recorder, _ = _make_manager()
recorder.start = raise_no_backend
with pytest.raises(RecordingStartError, match="Audio backend is unavailable"):
manager.start_recording()
assert manager.transcribe_state == TranscribeState.IDLE
@pytest.mark.asyncio
async def test_start_raises_when_no_transcribe_client(self) -> None:
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=None,
)
with pytest.raises(
RecordingStartError, match="Transcribe client is not available"
):
manager.start_recording()
assert manager.transcribe_state == TranscribeState.IDLE
assert not recorder.is_recording
class TestStopRecording:
@pytest.mark.asyncio
async def test_stop_transitions_through_flushing_to_idle(self) -> None:
manager, _, _ = _make_manager()
listener = StateListener()
manager.add_listener(listener)
manager.start_recording()
await manager.stop_recording()
assert listener.state_changes == [
TranscribeState.RECORDING,
TranscribeState.FLUSHING,
TranscribeState.IDLE,
]
@pytest.mark.asyncio
async def test_stop_noop_when_not_recording(self) -> None:
manager, _, _ = _make_manager()
listener = StateListener()
manager.add_listener(listener)
await manager.stop_recording()
assert listener.state_changes == []
@pytest.mark.asyncio
async def test_stop_recovers_after_transcription_timeout(self) -> None:
import asyncio
class HangingTranscribeClient:
def __init__(self, provider=None, model=None) -> None:
pass
async def transcribe(self, audio_stream):
await asyncio.Event().wait()
return
yield # makes this an async generator
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=HangingTranscribeClient(),
)
manager.start_recording()
with patch(
"vibe.cli.voice_manager.voice_manager.TRANSCRIPTION_DRAIN_TIMEOUT", 0.01
):
await manager.stop_recording()
assert manager.transcribe_state == TranscribeState.IDLE
@pytest.mark.asyncio
async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeError(
message="Cannot flush audio before sending any audio bytes"
),
]
)
manager, recorder, _ = _make_manager(transcribe_client=client)
listener = StateListener()
manager.add_listener(listener)
manager.start_recording()
await manager.stop_recording()
assert manager.transcribe_state == TranscribeState.IDLE
assert not recorder.is_recording
class TestCancelRecording:
@pytest.mark.asyncio
async def test_cancel_from_recording(self) -> None:
manager, recorder, _ = _make_manager()
manager.start_recording()
manager.cancel_recording()
assert manager.transcribe_state == TranscribeState.IDLE
assert not recorder.is_recording
@pytest.mark.asyncio
async def test_cancel_then_start_not_corrupted_by_stale_finally(self) -> None:
import asyncio
class HangingTranscribeClient:
def __init__(self, provider=None, model=None) -> None:
pass
async def transcribe(self, audio_stream):
await asyncio.Event().wait()
return
yield
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=HangingTranscribeClient(),
)
manager.start_recording()
manager.cancel_recording()
manager.start_recording()
await asyncio.sleep(0)
assert manager.transcribe_state == TranscribeState.RECORDING
def test_cancel_noop_when_idle(self) -> None:
manager, _, _ = _make_manager()
listener = StateListener()
manager.add_listener(listener)
manager.cancel_recording()
assert listener.state_changes == []
class TestToggleVoiceMode:
@patch.object(VibeConfig, "save_updates")
def test_toggle_enables(self, _mock_save) -> None:
manager, _, _ = _make_manager(voice_mode_enabled=False)
result = manager.toggle_voice_mode()
assert result == VoiceToggleResult(enabled=True)
@patch.object(VibeConfig, "save_updates")
def test_toggle_disables(self, _mock_save) -> None:
manager, _, _ = _make_manager(voice_mode_enabled=True)
result = manager.toggle_voice_mode()
assert result == VoiceToggleResult(enabled=False)
@pytest.mark.asyncio
@patch.object(VibeConfig, "save_updates")
async def test_toggle_disable_cancels_active_recording(self, _mock_save) -> None:
manager, _, _ = _make_manager(voice_mode_enabled=True)
manager.start_recording()
manager.toggle_voice_mode()
assert manager.transcribe_state == TranscribeState.IDLE
class TestListeners:
@pytest.mark.asyncio
async def test_listener_notified_on_state_change(self) -> None:
manager, _, _ = _make_manager()
listener = StateListener()
manager.add_listener(listener)
manager.start_recording()
assert listener.state_changes == [TranscribeState.RECORDING]
@patch.object(VibeConfig, "save_updates")
def test_listener_notified_on_voice_mode_change(self, _mock_save) -> None:
manager, _, _ = _make_manager(voice_mode_enabled=False)
listener = StateListener()
manager.add_listener(listener)
manager.toggle_voice_mode()
assert listener.voice_mode_changes == [True]
@pytest.mark.asyncio
async def test_remove_listener(self) -> None:
manager, _, _ = _make_manager()
listener = StateListener()
manager.add_listener(listener)
manager.remove_listener(listener)
manager.start_recording()
assert listener.state_changes == []
def test_remove_nonexistent_listener_no_error(self) -> None:
manager, _, _ = _make_manager()
listener = StateListener()
manager.remove_listener(listener)
class TestPeak:
def test_peak_delegates_to_audio_recorder(self) -> None:
manager, recorder, _ = _make_manager()
recorder.set_peak(0.42)
assert manager.peak == 0.42
class TestTranscription:
@pytest.mark.asyncio
async def test_text_deltas_notify_listeners(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeTextDelta(text="hello "),
TranscribeTextDelta(text="world"),
TranscribeDone(),
]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
manager.add_listener(listener)
manager.start_recording()
await manager.stop_recording()
assert listener.transcribed_texts == ["hello ", "world"]
@pytest.mark.asyncio
async def test_transcription_error_does_not_crash(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeTextDelta(text="partial"),
TranscribeError(message="something broke"),
TranscribeDone(),
]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
manager.add_listener(listener)
manager.start_recording()
await manager.stop_recording()
assert "partial" in listener.transcribed_texts
@pytest.mark.asyncio
async def test_cancel_during_transcription(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
manager.add_listener(listener)
manager.start_recording()
manager.cancel_recording()
assert manager.transcribe_state == TranscribeState.IDLE
@pytest.mark.asyncio
async def test_session_created_is_silent(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(), TranscribeDone()]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
manager.add_listener(listener)
manager.start_recording()
await manager.stop_recording()
assert listener.transcribed_texts == []
@pytest.mark.asyncio
async def test_transcription_exception_stops_recorder(self) -> None:
import asyncio
class CrashingTranscribeClient:
def __init__(self, provider=None, model=None) -> None:
pass
async def transcribe(self, audio_stream):
raise RuntimeError("network error")
yield # makes this an async generator
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=CrashingTranscribeClient(),
)
manager.start_recording()
assert recorder.is_recording
await asyncio.sleep(0)
assert manager.transcribe_state == TranscribeState.IDLE
assert not recorder.is_recording