v2.5.0 (#495)
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>
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
451
tests/audio_recorder/test_audio_recorder.py
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
319
tests/cli/textual_ui/test_streaming_message_buffer.py
Normal 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"
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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() == ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
115
tests/core/test_transcribe_config.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
136
tests/core/tools/builtins/test_read_file.py
Normal 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()
|
||||
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type </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)"> for more 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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                                                                                                             </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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type </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)"> for more 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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-r1" x="48.8" y="776.4" textLength="1390.8" clip-path="url(#terminal-line-31)">hello                                                                                                             </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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type </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)"> for more 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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-r1" x="48.8" y="776.4" textLength="1390.8" clip-path="url(#terminal-line-31)">hello                                                                                                             </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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </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)"> for more 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'm ready to help 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type </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)"> for more 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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 world from voice                                                                                            </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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type </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)"> for more 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 mode 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type </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)"> for more 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 mode enabled. Press ctrl+r to start 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
148
tests/snapshots/test_ui_snapshot_voice_mode.py
Normal 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,
|
||||
)
|
||||
65
tests/stubs/fake_audio_recorder.py
Normal 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
|
||||
22
tests/stubs/fake_transcribe_client.py
Normal 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
|
||||
74
tests/stubs/fake_voice_manager.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 == ""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
||||
|
|
|
|||
157
tests/transcribe/test_transcribe_client.py
Normal 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)
|
||||
0
tests/voice_manager/__init__.py
Normal file
394
tests/voice_manager/test_voice_manager.py
Normal 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
|
||||