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

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{
"version": "2.4.2",
"version": "2.5.0",
"configurations": [
{
"name": "ACP Server",

View file

@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.5.0] - 2026-03-16
### Added
- Dedicated theorem proving agent powered by leanstral, setup with /leanstall
- More advanced AGENTS.md support:
- AGENTS.md in ~/.vibe/ folder for user-level agent instructions
- AGENTS.md for subfolders and in parent folders
- Mistral Code API key info displayed in CLI banner
- Voice mode with real-time transcription support
- Parallel tool execution for improved performance
- Structured ACP error classes for better error handling
### Changed
- Bash allowlist/denylist now active on Windows
- Auto-completion relevance improved with better filename and path matching
- History navigation no longer filters by prefix
- Updated to Mistral SDK v2 import structure
- Removed `find` from bash default allowlist to prevent -exec abuse
### Fixed
- Improved scrolling performance
- Web search tool now infers server URL from provider config
## [2.4.2] - 2026-03-12
### Added

View file

@ -35,6 +35,7 @@ curl -LsSf https://mistral.ai/vibe/install.sh | bash
**Windows**
First, install uv
```bash
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
@ -65,6 +66,7 @@ pip install mistral-vibe
- [Interactive Mode](#interactive-mode)
- [Trust Folder System](#trust-folder-system)
- [Programmatic Mode](#programmatic-mode)
- [Voice Mode](#voice-mode)
- [Slash Commands](#slash-commands)
- [Built-in Slash Commands](#built-in-slash-commands)
- [Custom Slash Commands via Skills](#custom-slash-commands-via-skills)
@ -184,7 +186,6 @@ Most modern terminals should work, but older or minimal terminal emulators may h
```
3. If this is your first time running Vibe, it will:
- Create a default configuration file at `~/.vibe/config.toml`
- Prompt you to enter your API key if it's not already configured
- Save your API key to `~/.vibe/.env` for future use
@ -263,6 +264,30 @@ Example:
vibe --prompt "Analyze the codebase" --max-turns 5 --max-price 1.0 --output json
```
## Voice Mode
> [!WARNING]
> Voice mode is experimental and may change in future releases.
Voice mode allows you to dictate input using your microphone instead of typing.
### Activating Voice Mode
Toggle voice mode on or off with the `/voice` slash command:
```
> /voice
```
### Recording Shortcuts
| Shortcut | Action |
| -------- | ---------------- |
| `Ctrl+R` | Start recording |
| Any key | Stop recording |
| `Escape` | Cancel recording |
| `Ctrl+C` | Cancel recording |
## Slash Commands
Use slash commands for meta-actions and configuration changes during a session.

View file

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

View file

@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.4.2"
version = "2.5.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@ -38,7 +38,7 @@ dependencies = [
"keyring>=25.6.0",
"markdownify>=1.2.2",
"mcp>=1.14.0",
"mistralai==1.12.4",
"mistralai==2.0.0",
"packaging>=24.1",
"pexpect>=4.9.0",
"pydantic>=2.12.4",
@ -48,12 +48,14 @@ dependencies = [
"pyyaml>=6.0.0",
"requests>=2.20.0",
"rich>=14.0.0",
"textual>=7.4.0",
"sounddevice>=0.5.1",
"textual>=8.1.1",
"textual-speedups>=0.2.1",
"tomli-w>=1.2.0",
"tree-sitter>=0.25.2",
"tree-sitter-bash>=0.25.1",
"watchfiles>=1.1.1",
"websockets>=13.0",
"zstandard>=0.25.0",
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

After

Width:  |  Height:  |  Size: 13 KiB

View file

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

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

After

Width:  |  Height:  |  Size: 13 KiB

View file

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

After

Width:  |  Height:  |  Size: 13 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View file

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

180
uv.lock generated
View file

@ -398,18 +398,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.72.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@ -507,15 +495,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "invoke"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@ -779,7 +758,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.4.2"
version = "2.5.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@ -803,12 +782,14 @@ dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
{ name = "sounddevice" },
{ name = "textual" },
{ name = "textual-speedups" },
{ name = "tomli-w" },
{ name = "tree-sitter" },
{ name = "tree-sitter-bash" },
{ name = "watchfiles" },
{ name = "websockets" },
{ name = "zstandard" },
]
@ -845,7 +826,7 @@ requires-dist = [
{ name = "keyring", specifier = ">=25.6.0" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" },
{ name = "mistralai", specifier = "==1.12.4" },
{ name = "mistralai", specifier = "==2.0.0" },
{ name = "packaging", specifier = ">=24.1" },
{ name = "pexpect", specifier = ">=4.9.0" },
{ name = "pydantic", specifier = ">=2.12.4" },
@ -855,12 +836,14 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "requests", specifier = ">=2.20.0" },
{ name = "rich", specifier = ">=14.0.0" },
{ name = "textual", specifier = ">=7.4.0" },
{ name = "sounddevice", specifier = ">=0.5.1" },
{ name = "textual", specifier = ">=8.1.1" },
{ name = "textual-speedups", specifier = ">=0.2.1" },
{ name = "tomli-w", specifier = ">=1.2.0" },
{ name = "tree-sitter", specifier = ">=0.25.2" },
{ name = "tree-sitter-bash", specifier = ">=0.25.1" },
{ name = "watchfiles", specifier = ">=1.1.1" },
{ name = "websockets", specifier = ">=13.0" },
{ name = "zstandard", specifier = ">=0.25.0" },
]
@ -884,23 +867,20 @@ dev = [
[[package]]
name = "mistralai"
version = "1.12.4"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eval-type-backport" },
{ name = "httpx" },
{ name = "invoke" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/12/c3476c53e907255b5f485f085ba50dd9a84b40fe662e9a888d6ded26fa7b/mistralai-1.12.4.tar.gz", hash = "sha256:e52b53bab58025dcd208eeac13e3c3df5778d4112eeca1f08124096c7738929f", size = 243129, upload-time = "2026-02-20T17:55:13.73Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/5c/22fd7d1ec7e333f83dc5e2d0b176952a5d9a1f08519898c55616c92a81d8/mistralai-2.0.0.tar.gz", hash = "sha256:acb7937a53119ece67f4978809d4cf630fbf54b4dfe85c0eeae778ac40850fab", size = 317705, upload-time = "2026-03-10T17:12:48.616Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl", hash = "sha256:7b69fcbc306436491ad3377fbdead527c9f3a0ce145ec029bf04c6308ff2cca6", size = 509321, upload-time = "2026-02-20T17:55:15.27Z" },
{ url = "https://files.pythonhosted.org/packages/b8/95/1587d555837bf635db28e2acee366cc47edc473cd3155515be14acced91b/mistralai-2.0.0-py3-none-any.whl", hash = "sha256:e551fc36d60d4c969140e37f10eab04986480e487f357c900da05d740b9a0baf", size = 709642, upload-time = "2026-03-10T17:12:50.104Z" },
]
[[package]]
@ -968,62 +948,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.60b1"
@ -1101,21 +1025,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]]
name = "protobuf"
version = "6.33.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"
@ -1759,6 +1668,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
]
[[package]]
name = "sounddevice"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" },
{ url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" },
{ url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.1"
@ -1808,7 +1733,7 @@ wheels = [
[[package]]
name = "textual"
version = "8.0.0"
version = "8.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
@ -1818,9 +1743,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/08/1e1f705825359590ddfaeda57653bd518c4ff7a96bb2c3239ba1b6fc4c51/textual-8.0.0.tar.gz", hash = "sha256:ce48f83a3d686c0fac0e80bf9136e1f8851c653aa6a4502e43293a151df18809", size = 1595895, upload-time = "2026-02-16T17:12:14.215Z" }
sdist = { url = "https://files.pythonhosted.org/packages/72/23/8c709655c5f2208ee82ab81b8104802421865535c278a7649b842b129db1/textual-8.1.1.tar.gz", hash = "sha256:eef0256a6131f06a20ad7576412138c1f30f92ddeedd055953c08d97044bc317", size = 1843002, upload-time = "2026-03-10T10:01:38.493Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" },
{ url = "https://files.pythonhosted.org/packages/50/21/421b02bf5943172b7a9320712a5e0d74a02a8f7597284e3f8b5b06c70b8d/textual-8.1.1-py3-none-any.whl", hash = "sha256:6712f96e335cd782e76193dee16b9c8875fe0699d923bc8d3f1228fd23e773a6", size = 719598, upload-time = "2026-03-10T10:01:48.318Z" },
]
[[package]]
@ -2122,6 +2047,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.4.2"
__version__ = "2.5.0"

View file

@ -15,7 +15,6 @@ from acp import (
LoadSessionResponse,
NewSessionResponse,
PromptResponse,
RequestError,
SetSessionModelResponse,
SetSessionModeResponse,
run_agent,
@ -55,6 +54,17 @@ from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer
from vibe.acp.exceptions import (
ConfigurationError,
ConversationLimitError,
InternalError,
InvalidRequestError,
NotImplementedMethodError,
RateLimitError,
SessionLoadError,
SessionNotFoundError,
UnauthenticatedError,
)
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import (
tool_call_session_update,
@ -93,13 +103,14 @@ from vibe.core.proxy_setup import (
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
EntrypointMetadata,
LLMMessage,
RateLimitError as CoreRateLimitError,
ReasoningEvent,
Role,
ToolCallEvent,
@ -107,7 +118,11 @@ from vibe.core.types import (
ToolStreamEvent,
UserMessageEvent,
)
from vibe.core.utils import CancellationReason, get_user_cancellation_message
from vibe.core.utils import (
CancellationReason,
ConversationLimitException,
get_user_cancellation_message,
)
class AcpSessionLoop(BaseModel):
@ -201,7 +216,7 @@ class VibeAcpAgentLoop(AcpAgent):
async def authenticate(
self, method_id: str, **kwargs: Any
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
raise NotImplementedMethodError("authenticate")
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
return EntrypointMetadata(
@ -219,9 +234,9 @@ class VibeAcpAgentLoop(AcpAgent):
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a session"
}) from e
raise UnauthenticatedError.from_missing_api_key(e) from e
except Exception as e:
raise ConfigurationError(str(e)) from e
async def _create_acp_session(
self, session_id: str, agent_loop: AgentLoop
@ -295,7 +310,7 @@ class VibeAcpAgentLoop(AcpAgent):
for override in overrides
]
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
def _create_approval_callback(self, session_id: str) -> ApprovalCallback:
session = self._get_session(session_id)
def _handle_permission_selection(
@ -347,7 +362,7 @@ class VibeAcpAgentLoop(AcpAgent):
def _get_session(self, session_id: str) -> AcpSessionLoop:
if session_id not in self.sessions:
raise RequestError.invalid_params({"session": "Not found"})
raise SessionNotFoundError(session_id)
return self.sessions[session_id]
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
@ -395,7 +410,17 @@ class VibeAcpAgentLoop(AcpAgent):
hint="KEY value to set, KEY to unset, or empty for help"
)
),
)
),
AvailableCommand(
name="leanstall",
description="Install the Lean 4 agent (leanstral)",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
AvailableCommand(
name="unleanstall",
description="Uninstall the Lean 4 agent",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
]
update = update_available_commands(commands)
@ -429,6 +454,60 @@ class VibeAcpAgentLoop(AcpAgent):
)
return PromptResponse(stop_reason="end_turn")
async def _handle_leanstall_command(self, session_id: str) -> PromptResponse:
session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents)
if "lean" in current:
message = "Lean agent is already installed."
else:
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
)
message = (
"Lean agent installed. Start a new session to switch to Lean mode."
)
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
),
)
return PromptResponse(stop_reason="end_turn")
async def _handle_unleanstall_command(self, session_id: str) -> PromptResponse:
session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents)
if "lean" not in current:
message = "Lean agent is not installed."
else:
VibeConfig.save_updates({
"installed_agents": [a for a in current if a != "lean"]
})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
)
message = "Lean agent uninstalled."
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
),
)
return PromptResponse(stop_reason="end_turn")
@override
async def load_session(
self,
@ -446,16 +525,12 @@ class VibeAcpAgentLoop(AcpAgent):
session_id, config.session_logging
)
if session_dir is None:
raise RequestError.invalid_params({
"session_id": f"Session not found: {session_id}"
})
raise SessionNotFoundError(session_id)
try:
loaded_messages, _ = SessionLoader.load_session(session_dir)
except ValueError as e:
raise RequestError.invalid_params({
"session_id": f"Failed to load session: {e}"
}) from e
except Exception as e:
raise SessionLoadError(session_id, str(e)) from e
agent_loop = AgentLoop(
config=config,
@ -605,7 +680,7 @@ class VibeAcpAgentLoop(AcpAgent):
session = self._get_session(session_id)
if session.task is not None:
raise RuntimeError(
raise InvalidRequestError(
"Concurrent prompts are not supported yet, wait for agent loop to finish"
)
@ -614,6 +689,12 @@ class VibeAcpAgentLoop(AcpAgent):
if text_prompt.strip().lower().startswith("/proxy-setup"):
return await self._handle_proxy_setup_command(session_id, text_prompt)
if text_prompt.strip().lower().startswith("/unleanstall"):
return await self._handle_unleanstall_command(session_id)
if text_prompt.strip().lower().startswith("/leanstall"):
return await self._handle_leanstall_command(session_id)
temp_user_message_id: str | None = kwargs.get("messageId")
async def agent_loop_task() -> None:
@ -629,16 +710,14 @@ class VibeAcpAgentLoop(AcpAgent):
except asyncio.CancelledError:
return PromptResponse(stop_reason="cancelled")
except Exception as e:
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=f"Error: {e!s}"),
),
)
except CoreRateLimitError as e:
raise RateLimitError.from_core(e) from e
return PromptResponse(stop_reason="refusal")
except ConversationLimitException as e:
raise ConversationLimitError(str(e)) from e
except Exception as e:
raise InternalError(str(e)) from e
finally:
session.task = None
@ -687,7 +766,9 @@ class VibeAcpAgentLoop(AcpAgent):
block_prompt = "\n".join(parts)
text_prompt = f"{text_prompt}{separator}{block_prompt}"
case _:
raise ValueError(f"Unsupported content block type: {block.type}")
raise InvalidRequestError(
f"We currently don't support {block.type} content blocks"
)
return text_prompt
async def _run_agent_loop(
@ -775,7 +856,7 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ForkSessionResponse:
raise NotImplementedError()
raise NotImplementedMethodError("fork_session")
@override
async def resume_session(
@ -785,15 +866,15 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ResumeSessionResponse:
raise NotImplementedError()
raise NotImplementedMethodError("resume_session")
@override
async def ext_method(self, method: str, params: dict) -> dict:
raise NotImplementedError()
raise NotImplementedMethodError("ext_method")
@override
async def ext_notification(self, method: str, params: dict) -> None:
raise NotImplementedError()
raise NotImplementedMethodError("ext_notification")
@override
def on_connect(self, conn: Client) -> None:

121
vibe/acp/exceptions.py Normal file
View file

@ -0,0 +1,121 @@
"""Structured ACP error classes for the Vibe agent.
Error codes follow JSON-RPC 2.0 (https://www.jsonrpc.org/specification#error_object)
and ACP error handling (https://agentclientprotocol.com/protocol/overview#error-handling):
-32700 Parse error (JSON-RPC standard)
-32600 Invalid request (JSON-RPC standard)
-32601 Method not found (JSON-RPC standard)
-32602 Invalid params (JSON-RPC standard)
-32603 Internal error (JSON-RPC standard)
-32000 to -32099 Server errors (JSON-RPC implementation-defined)
-31xxx Application errors (Vibe-specific, outside reserved range)
"""
from __future__ import annotations
from typing import Any
from acp import RequestError
from vibe.core.config import MissingAPIKeyError
from vibe.core.types import RateLimitError as CoreRateLimitError
# JSON-RPC 2.0 standard codes
UNAUTHENTICATED = -32000
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603
# Vibe application codes (outside JSON-RPC reserved range)
RATE_LIMITED = -31001
CONFIGURATION_ERROR = -31002
CONVERSATION_LIMIT = -31003
class VibeRequestError(RequestError):
code: int
def __init__(self, message: str, data: dict[str, Any] | None = None) -> None:
super().__init__(self.code, message, data)
class UnauthenticatedError(VibeRequestError):
code = UNAUTHENTICATED
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
@classmethod
def from_missing_api_key(cls, exc: MissingAPIKeyError) -> UnauthenticatedError:
return cls(f"Missing API key for {exc.provider_name} provider.")
class NotImplementedMethodError(VibeRequestError):
code = METHOD_NOT_FOUND
def __init__(self, method: str) -> None:
super().__init__(
message=f"Method not implemented: {method}", data={"method": method}
)
class InvalidRequestError(VibeRequestError):
code = INVALID_PARAMS
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
class SessionNotFoundError(VibeRequestError):
code = INVALID_PARAMS
def __init__(self, session_id: str) -> None:
super().__init__(
message=f"Session not found: {session_id}", data={"session_id": session_id}
)
class SessionLoadError(VibeRequestError):
code = INVALID_PARAMS
def __init__(self, session_id: str, detail: str) -> None:
super().__init__(
message=f"Failed to load session {session_id}: {detail}",
data={"session_id": session_id},
)
class RateLimitError(VibeRequestError):
code = RATE_LIMITED
def __init__(self, provider: str, model: str) -> None:
super().__init__(
message=f"Rate limit exceeded for {provider} (model: {model}).",
data={"provider": provider, "model": model},
)
@classmethod
def from_core(cls, exc: CoreRateLimitError) -> RateLimitError:
return cls(exc.provider, exc.model)
class ConfigurationError(VibeRequestError):
code = CONFIGURATION_ERROR
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
class ConversationLimitError(VibeRequestError):
code = CONVERSATION_LIMIT
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
class InternalError(VibeRequestError):
code = INTERNAL_ERROR
def __init__(self, detail: str) -> None:
super().__init__(message=detail or "Internal error")

View file

@ -4,18 +4,19 @@ from pathlib import Path
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool,
ReadFileArgs,
ReadFileResult,
ReadFileState,
_ReadResult,
)
ReadFileResult = ReadFileResult
class AcpReadFileState(BaseToolState, AcpToolState):
class AcpReadFileState(ReadFileState, AcpToolState):
pass

View file

@ -57,11 +57,14 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
if is_user_cancellation_event(event):
tool_status = "failed"
raw_output = (
TaggedText.from_string(event.skip_reason).message
if event.skip_reason
else None
)
if event.skip_reason:
raw_output = TaggedText.from_string(event.skip_reason).message
elif event.error:
raw_output = TaggedText.from_string(event.error).message
elif event.result:
raw_output = event.result.model_dump_json()
else:
raw_output = None
elif event.result:
tool_status = "completed"
raw_output = event.result.model_dump_json()

View file

@ -77,6 +77,21 @@ class CommandRegistry:
description="Browse and resume past sessions",
handler="_show_session_picker",
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Toggle voice mode on/off",
handler="_toggle_voice_mode",
),
"leanstall": Command(
aliases=frozenset(["/leanstall"]),
description="Install the Lean 4 agent (leanstral)",
handler="_install_lean",
),
"unleanstall": Command(
aliases=frozenset(["/unleanstall"]),
description="Uninstall the Lean 4 agent",
handler="_uninstall_lean",
),
}
for command in excluded_commands:

View file

@ -58,7 +58,7 @@ class HistoryManager:
self._save_history()
self.reset_navigation()
def get_previous(self, current_input: str, prefix: str = "") -> str | None:
def get_previous(self, current_input: str) -> str | None:
if not self._entries:
return None
@ -66,21 +66,19 @@ class HistoryManager:
self._temp_input = current_input
self._current_index = len(self._entries)
for i in range(self._current_index - 1, -1, -1):
if self._entries[i].startswith(prefix):
self._current_index = i
return self._entries[i]
if self._current_index <= 0:
return None
return None
self._current_index -= 1
return self._entries[self._current_index]
def get_next(self, prefix: str = "") -> str | None:
def get_next(self) -> str | None:
if self._current_index == -1:
return None
for i in range(self._current_index + 1, len(self._entries)):
if self._entries[i].startswith(prefix):
self._current_index = i
return self._entries[i]
if self._current_index < len(self._entries) - 1:
self._current_index += 1
return self._entries[self._current_index]
result = self._temp_input
self.reset_navigation()

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from enum import StrEnum
import logging
from os import getenv
@ -19,6 +20,11 @@ UPGRADE_URL = CONSOLE_CLI_URL
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
class MistralCodePlanName(StrEnum):
FREE = "F"
ENTERPRISE = "E"
class PlanInfo:
plan_type: WhoAmIPlanType
plan_name: str
@ -51,6 +57,18 @@ class PlanInfo:
def is_chat_pro_plan(self) -> bool:
return self.plan_type == WhoAmIPlanType.CHAT
def is_free_mistral_code_plan(self) -> bool:
return (
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
and self.plan_name.upper() == MistralCodePlanName.FREE
)
def is_mistral_code_enterprise_plan(self) -> bool:
return (
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
and self.plan_name.upper() == MistralCodePlanName.ENTERPRISE
)
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
if not api_key:
@ -79,11 +97,14 @@ def plan_offer_cta(payload: PlanInfo | None) -> str | None:
return
if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_mistral_code_plan()
):
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
def plan_title(payload: PlanInfo | None) -> str | None:
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
if not payload:
return None
if payload.is_chat_pro_plan():
@ -92,4 +113,8 @@ def plan_title(payload: PlanInfo | None) -> str | None:
return "[API] Experiment plan"
if payload.is_paid_api_plan():
return "[API] Scale plan"
if payload.is_free_mistral_code_plan():
return "Mistral Code Free"
if payload.is_mistral_code_enterprise_plan():
return "Mistral Code Enterprise"
return None

View file

@ -11,6 +11,7 @@ from pydantic import TypeAdapter, ValidationError
class WhoAmIPlanType(StrEnum):
API = "API"
CHAT = "CHAT"
MISTRAL_CODE = "MISTRAL_CODE"
UNKNOWN = "UNKNOWN"
UNAUTHORIZED = "UNAUTHORIZED"

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
import gc
import os
from pathlib import Path
import signal
@ -87,8 +88,11 @@ from vibe.cli.update_notifier import (
should_show_whats_new,
)
from vibe.cli.update_notifier.update import do_update
from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import Backend, VibeConfig
from vibe.core.logger import logger
@ -112,6 +116,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
AgentStats,
ApprovalResponse,
@ -147,7 +152,11 @@ class ChatScroll(VerticalScroll):
@property
def is_at_bottom(self) -> bool:
return self.scroll_offset.y >= (self.max_scroll_y - 3)
return self.scroll_target_y >= (self.max_scroll_y - 3)
def _check_anchor(self) -> None:
if self._anchored and self._anchor_released and self.is_at_bottom:
self._anchor_released = False
def update_node_styles(self, animate: bool = True) -> None:
pass
@ -196,6 +205,7 @@ async def prune_oldest_children(
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
PAUSE_GC_ON_SCROLL: ClassVar[bool] = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "clear_quit", "Quit", show=False),
@ -222,10 +232,14 @@ class VibeApp(App): # noqa: PLR0904
current_version: str = CORE_VERSION,
plan_offer_gateway: WhoAmIGateway | None = None,
terminal_notifier: NotificationPort | None = None,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.agent_loop = agent_loop
self._voice_manager: VoiceManagerPort = (
voice_manager or self._make_default_voice_manager()
)
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
self,
get_enabled=lambda: self.config.enable_notifications,
@ -238,6 +252,7 @@ class VibeApp(App): # noqa: PLR0904
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
self._pending_question: asyncio.Future | None = None
self._user_interaction_lock = asyncio.Lock()
self.event_handler: EventHandler | None = None
@ -296,6 +311,7 @@ class VibeApp(App): # noqa: PLR0904
skill_entries_getter=self._get_skill_entries,
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
nuage_enabled=self.config.nuage_enabled,
voice_manager=self._voice_manager,
)
with Horizontal(id="bottom-bar"):
@ -346,6 +362,9 @@ class VibeApp(App): # noqa: PLR0904
if self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
gc.collect()
gc.freeze()
def _process_initial_prompt(self) -> None:
if self._teleport_on_start:
self.run_worker(
@ -402,8 +421,6 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
await self._switch_to_input_app()
async def on_approval_app_approval_granted_always_tool(
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
) -> None:
@ -414,8 +431,6 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
await self._switch_to_input_app()
async def on_approval_app_approval_rejected(
self, message: ApprovalApp.ApprovalRejected
) -> None:
@ -425,8 +440,6 @@ class VibeApp(App): # noqa: PLR0904
)
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
await self._switch_to_input_app()
if self._loading_widget and self._loading_widget.parent:
await self._remove_loading_widget()
@ -435,16 +448,11 @@ class VibeApp(App): # noqa: PLR0904
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
self._pending_question.set_result(result)
await self._switch_to_input_app()
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
await self._switch_to_input_app()
await self._interrupt_agent_loop()
async def _remove_loading_widget(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
@ -682,26 +690,32 @@ class VibeApp(App): # noqa: PLR0904
if self._is_tool_enabled_in_main_agent(tool):
return (ApprovalResponse.YES, None)
self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
result = await self._pending_approval
self._pending_approval = None
return result
async with self._user_interaction_lock:
self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
result = await self._pending_approval
return result
finally:
self._pending_approval = None
await self._switch_to_input_app()
async def _user_input_callback(self, args: BaseModel) -> BaseModel:
question_args = cast(AskUserQuestionArgs, args)
self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget):
await self._switch_to_question_app(question_args)
result = await self._pending_question
self._pending_question = None
return result
async with self._user_interaction_lock:
self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
with paused_timer(self._loading_widget):
await self._switch_to_question_app(question_args)
result = await self._pending_question
return result
finally:
self._pending_question = None
await self._switch_to_input_app()
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
@ -756,10 +770,11 @@ class VibeApp(App): # noqa: PLR0904
self._terminal_notifier.notify(NotificationContext.COMPLETE)
def _rate_limit_message(self) -> str:
upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
WhoAmIPlanType.API,
WhoAmIPlanType.UNAUTHORIZED,
}
upgrade_to_pro = self._plan_info and (
self._plan_info.plan_type
in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or self._plan_info.is_free_mistral_code_plan()
)
if upgrade_to_pro:
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
return "Rate limits exceeded. Please wait a moment before trying again."
@ -862,6 +877,16 @@ class VibeApp(App): # noqa: PLR0904
self._interrupt_requested = True
if self._pending_approval and not self._pending_approval.done():
feedback = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
if self._pending_question and not self._pending_question.done():
self._pending_question.set_result(
AskUserQuestionResult(answers=[], cancelled=True)
)
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
try:
@ -1035,6 +1060,28 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _install_lean(self) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" in current:
await self._mount_and_scroll(
UserCommandMessage("Lean agent is already installed.")
)
return
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
await self._reload_config()
async def _uninstall_lean(self) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" not in current:
await self._mount_and_scroll(
UserCommandMessage("Lean agent is not installed.")
)
return
VibeConfig.save_updates({
"installed_agents": [a for a in current if a != "lean"]
})
await self._reload_config()
async def _clear_history(self) -> None:
try:
self._reset_ui_state()
@ -1166,6 +1213,33 @@ class VibeApp(App): # noqa: PLR0904
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
def _make_default_voice_manager(self) -> VoiceManager:
try:
model = self.config.get_active_transcribe_model()
provider = self.config.get_transcribe_provider_for_model(model)
transcribe_client = make_transcribe_client(provider, model)
except (ValueError, KeyError) as exc:
logger.error(
"Failed to initialize transcription, check transcribe model configuration",
exc_info=exc,
)
transcribe_client = None
return VoiceManager(
lambda: self.config,
audio_recorder=AudioRecorder(),
transcribe_client=transcribe_client,
)
async def _toggle_voice_mode(self) -> None:
result = self._voice_manager.toggle_voice_mode()
self.agent_loop.refresh_config()
if result.enabled:
msg = "Voice mode enabled. Press ctrl+r to start recording."
else:
msg = "Voice mode disabled."
await self._mount_and_scroll(UserCommandMessage(msg))
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
@ -1291,7 +1365,11 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
def action_interrupt(self) -> None:
def action_interrupt(self) -> None: # noqa: PLR0911
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
self._voice_manager.cancel_recording()
return
current_time = time.monotonic()
if self._current_bottom_app == BottomApp.Config:
@ -1424,6 +1502,7 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container
and self._switch_agent_generation == my_gen
):
self.call_from_thread(self._refresh_banner)
self.call_from_thread(
setattr, self._chat_input_container, "switching_mode", False
)

View file

@ -86,6 +86,7 @@ TextArea > .text-area--cursor {
#completion-popup {
width: 100%;
padding: 1;
padding-left: 3;
color: ansi_default;
}
@ -112,6 +113,11 @@ TextArea > .text-area--cursor {
border: solid ansi_red;
border-title-color: ansi_red;
}
&.border-recording {
border: solid $mistral_orange;
border-title-color: $mistral_orange;
}
}
#input-body {
@ -119,7 +125,8 @@ TextArea > .text-area--cursor {
}
#prompt,
#prompt-spinner {
#prompt-spinner,
#recording-indicator {
width: auto;
background: transparent;
color: $mistral_orange;
@ -137,6 +144,10 @@ TextArea > .text-area--cursor {
border: none;
padding: 0;
scrollbar-visibility: hidden;
&.recording {
color: ansi_bright_black;
}
}
ToastRack {

View file

@ -82,6 +82,7 @@ class EventHandler:
skip_reason=TaggedText.from_string(event.skip_reason).message
if event.skip_reason
else None,
cancelled=event.cancelled,
duration=event.duration,
tool_call_id=event.tool_call_id,
)

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
__all__ = ["RecordingIndicator"]

View file

@ -0,0 +1,79 @@
from __future__ import annotations
from textual.timer import Timer
from textual.widgets import Static
from vibe.cli.voice_manager.voice_manager_port import (
TranscribeState,
VoiceManagerListener,
VoiceManagerPort,
)
PEAK_BLOCKS = "▁▂▃▄▅▆▇█"
FILL_BLOCKS = "▏▎▍▌▋▊▉█"
PEAK_POLL_INTERVAL = 0.05
class RecordingIndicator(VoiceManagerListener, Static):
def __init__(self, voice_manager: VoiceManagerPort) -> None:
super().__init__(PEAK_BLOCKS[0], id="recording-indicator")
self._voice_manager = voice_manager
self._peak_timer: Timer | None = None
self._flushing_animation_timer: Timer | None = None
def on_mount(self) -> None:
self._voice_manager.add_listener(self)
if self._voice_manager.transcribe_state == TranscribeState.RECORDING:
self._start_peak_polling()
def on_unmount(self) -> None:
self._voice_manager.remove_listener(self)
self._stop_peak_polling()
self._stop_flushing_animation_timer()
def on_transcribe_state_change(self, state: TranscribeState) -> None:
match state:
case TranscribeState.RECORDING:
self._start_peak_polling()
case TranscribeState.FLUSHING:
self._stop_peak_polling()
self._start_flushing_animation_timer()
case TranscribeState.IDLE:
self._stop_peak_polling()
self._stop_flushing_animation_timer()
def _start_peak_polling(self) -> None:
self._peak_timer = self.set_interval(
PEAK_POLL_INTERVAL, self._poll_peak, pause=False
)
def _poll_peak(self) -> None:
if self._voice_manager.transcribe_state != TranscribeState.RECORDING:
return
index = min(
int(self._voice_manager.peak * len(PEAK_BLOCKS)), len(PEAK_BLOCKS) - 1
)
self.update(PEAK_BLOCKS[index])
def _stop_peak_polling(self) -> None:
if self._peak_timer:
self._peak_timer.stop()
self._peak_timer = None
def _start_flushing_animation_timer(self) -> None:
self._processing_index = 0
self.update(FILL_BLOCKS[0])
self._flushing_animation_timer = self.set_interval(
0.1, self._advance_flushing_animation
)
def _advance_flushing_animation(self) -> None:
if self._voice_manager.transcribe_state != TranscribeState.FLUSHING:
return
self._processing_index = (self._processing_index + 1) % len(FILL_BLOCKS)
self.update(FILL_BLOCKS[self._processing_index])
def _stop_flushing_animation_timer(self) -> None:
if self._flushing_animation_timer:
self._flushing_animation_timer.stop()
self._flushing_animation_timer = None

View file

@ -11,9 +11,16 @@ from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
from vibe.cli.voice_manager.voice_manager_port import (
TranscribeState,
VoiceManagerListener,
VoiceManagerPort,
)
from vibe.core.logger import logger
class _PromptSpinner(SpinnerMixin, Static):
@ -29,7 +36,7 @@ class _PromptSpinner(SpinnerMixin, Static):
self.start_spinner_timer()
class ChatInputBody(Widget):
class ChatInputBody(VoiceManagerListener, Widget):
class Submitted(Message):
def __init__(self, value: str) -> None:
self.value = value
@ -39,6 +46,7 @@ class ChatInputBody(Widget):
self,
history_file: Path | None = None,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -46,6 +54,8 @@ class ChatInputBody(Widget):
self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled
self._switching_mode = False
self._voice_manager = voice_manager
self._recording_indicator: RecordingIndicator | None = None
if history_file:
self.history = HistoryManager(history_file)
@ -60,13 +70,21 @@ class ChatInputBody(Widget):
yield self.prompt_widget
self.input_widget = ChatTextArea(
id="input", nuage_enabled=self._nuage_enabled
id="input",
nuage_enabled=self._nuage_enabled,
voice_manager=self._voice_manager,
)
yield self.input_widget
def on_mount(self) -> None:
if self.input_widget:
self.input_widget.focus()
if self._voice_manager:
self._voice_manager.add_listener(self)
def on_unmount(self) -> None:
if self._voice_manager:
self._voice_manager.remove_listener(self)
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
if text.startswith("!"):
@ -103,7 +121,6 @@ class ChatInputBody(Widget):
cursor_pos = (0, col)
self.input_widget.move_cursor(cursor_pos)
self.input_widget._last_cursor_col = col
self.input_widget._cursor_pos_after_load = cursor_pos
self.input_widget._cursor_moved_since_load = False
@ -111,7 +128,7 @@ class ChatInputBody(Widget):
self._notify_completion_reset()
def on_chat_text_area_history_previous(
self, event: ChatTextArea.HistoryPrevious
self, _event: ChatTextArea.HistoryPrevious
) -> None:
if not self.history or not self.input_widget:
return
@ -119,52 +136,25 @@ class ChatInputBody(Widget):
if self.history._current_index == -1:
self.input_widget._original_text = self.input_widget.text
if (
self.history._current_index != -1
and self.input_widget._last_used_prefix is not None
and self.input_widget._last_used_prefix != event.prefix
):
self.history.reset_navigation()
self.input_widget._last_used_prefix = event.prefix
previous = self.history.get_previous(
self.input_widget._original_text, prefix=event.prefix
)
previous = self.history.get_previous(self.input_widget._original_text)
if previous is not None:
self._load_history_entry(previous)
def on_chat_text_area_history_next(self, event: ChatTextArea.HistoryNext) -> None:
def on_chat_text_area_history_next(self, _event: ChatTextArea.HistoryNext) -> None:
if not self.history or not self.input_widget:
return
if self.history._current_index == -1:
return
if (
self.input_widget._last_used_prefix is not None
and self.input_widget._last_used_prefix != event.prefix
):
self.history.reset_navigation()
next_entry = self.history.get_next()
if next_entry is not None:
self._load_history_entry(next_entry)
self.input_widget._last_used_prefix = event.prefix
has_next = any(
self.history._entries[i].startswith(event.prefix)
for i in range(self.history._current_index + 1, len(self.history._entries))
)
original_matches = self.input_widget._original_text.startswith(event.prefix)
if has_next or original_matches:
next_entry = self.history.get_next(prefix=event.prefix)
if next_entry is not None:
cursor_col = (
len(event.prefix) if self.history._current_index == -1 else None
)
self._load_history_entry(next_entry, cursor_col=cursor_col)
def on_chat_text_area_history_reset(self, event: ChatTextArea.HistoryReset) -> None:
def on_chat_text_area_history_reset(
self, _event: ChatTextArea.HistoryReset
) -> None:
if self.history:
self.history.reset_navigation()
if self.input_widget:
@ -250,3 +240,68 @@ class ChatInputBody(Widget):
if cursor_offset is not None:
self.input_widget.set_cursor_offset(max(0, min(cursor_offset, len(text))))
def on_transcribe_state_change(self, state: TranscribeState) -> None:
if state == TranscribeState.RECORDING:
self._start_recording_ui()
elif state == TranscribeState.IDLE:
self._stop_recording_ui()
def on_transcribe_text(self, text: str) -> None:
if not self.input_widget:
return
self.input_widget.insert(text)
def _start_recording_ui(self) -> None:
if not self._voice_manager:
return
try:
self.screen.get_widget_by_id("input-box").add_class("border-recording")
if self.input_widget:
self.input_widget.cursor_blink = False
self.input_widget.add_class("recording")
if self.prompt_widget:
self.prompt_widget.display = False
self._recording_indicator = RecordingIndicator(self._voice_manager)
self.query_one(Horizontal).mount(self._recording_indicator, before=0)
except Exception as e:
logger.error("Failed to start recording UI", exc_info=e)
self._reset_recording_ui()
def _stop_recording_ui(self) -> None:
try:
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
if self.input_widget:
self.input_widget.cursor_blink = True
self.input_widget.remove_class("recording")
if self.prompt_widget:
self.prompt_widget.display = True
self._update_prompt()
if self._recording_indicator:
self._recording_indicator.remove()
self._recording_indicator = None
except Exception as e:
logger.error("Failed to stop recording UI", exc_info=e)
self._reset_recording_ui()
def _reset_recording_ui(self) -> None:
try:
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
except Exception:
pass
if self.input_widget:
self.input_widget.cursor_blink = True
self.input_widget.remove_class("recording")
if self.prompt_widget:
self.prompt_widget.display = True
self._update_prompt()
if self._recording_indicator:
try:
self._recording_indicator.remove()
except Exception:
pass
self._recording_indicator = None

View file

@ -27,7 +27,7 @@ class CompletionPopup(Static):
label_style = "bold reverse" if idx == selected else "bold"
description_style = "italic" if idx == selected else "dim"
text.append(label, style=label_style)
text.append(self._display_label(label), style=label_style)
if description:
text.append(" ")
text.append(description, style=description_style)
@ -41,3 +41,8 @@ class CompletionPopup(Static):
def show(self) -> None:
self.styles.display = "block"
def _display_label(self, label: str) -> str:
if label.startswith("@"):
return label[1:]
return label

View file

@ -17,6 +17,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
from vibe.core.agents import AgentSafety
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
@ -44,6 +45,7 @@ class ChatInputContainer(Vertical):
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -56,6 +58,7 @@ class ChatInputContainer(Vertical):
file_watcher_for_autocomplete_getter
)
self._nuage_enabled = nuage_enabled
self._voice_manager = voice_manager
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
@ -90,6 +93,7 @@ class ChatInputContainer(Vertical):
history_file=self._history_file,
id="input-body",
nuage_enabled=self._nuage_enabled,
voice_manager=self._voice_manager,
)
yield self._body

View file

@ -13,6 +13,11 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
TranscribeState,
VoiceManagerPort,
)
InputMode = Literal["!", "/", ">", "&"]
@ -37,14 +42,10 @@ class ChatTextArea(TextArea):
super().__init__()
class HistoryPrevious(Message):
def __init__(self, prefix: str) -> None:
self.prefix = prefix
super().__init__()
pass
class HistoryNext(Message):
def __init__(self, prefix: str) -> None:
self.prefix = prefix
super().__init__()
pass
class HistoryReset(Message):
"""Message sent when history navigation should be reset."""
@ -56,20 +57,23 @@ class ChatTextArea(TextArea):
self.mode = mode
super().__init__()
def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
def __init__(
self,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._nuage_enabled = nuage_enabled
self._input_mode: InputMode = self.DEFAULT_MODE
self._history_prefix: str | None = None
self._last_text = ""
self._navigating_history = False
self._last_cursor_col: int = 0
self._last_used_prefix: str | None = None
self._original_text: str = ""
self._cursor_pos_after_load: tuple[int, int] | None = None
self._cursor_moved_since_load: bool = False
self._completion_manager: MultiCompletionManager | None = None
self._app_has_focus: bool = True
self._voice_manager = voice_manager
def on_blur(self, event: events.Blur) -> None:
if self._app_has_focus:
@ -100,7 +104,6 @@ class ChatTextArea(TextArea):
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not self._navigating_history and self.text != self._last_text:
self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False
@ -114,11 +117,6 @@ class ChatTextArea(TextArea):
self.get_full_text(), self._get_full_cursor_offset()
)
def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
self._history_prefix = None
if clear_last_used:
self._last_used_prefix = None
def _mark_cursor_moved_if_needed(self) -> None:
if (
self._cursor_pos_after_load is not None
@ -126,20 +124,8 @@ class ChatTextArea(TextArea):
and self.cursor_location != self._cursor_pos_after_load
):
self._cursor_moved_since_load = True
self._reset_prefix(clear_last_used=False)
def _get_prefix_up_to_cursor(self) -> str:
cursor_row, cursor_col = self.cursor_location
lines = self.text.split("\n")
if cursor_row < len(lines):
visible_prefix = lines[cursor_row][:cursor_col]
if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
return self._input_mode + visible_prefix
return visible_prefix
return ""
def _handle_history_up(self) -> bool:
_, cursor_col = self.cursor_location
history_loaded_and_cursor_unmoved = (
self._cursor_pos_after_load is not None
and not self._cursor_moved_since_load
@ -150,63 +136,61 @@ class ChatTextArea(TextArea):
)
if should_intercept:
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
self._reset_prefix()
self._last_cursor_col = 0
if self._history_prefix is None:
self._history_prefix = self._get_prefix_up_to_cursor()
self._navigating_history = True
self.post_message(self.HistoryPrevious(self._history_prefix))
self.post_message(self.HistoryPrevious())
return True
return False
def _is_on_loaded_history_entry(self) -> bool:
return self._cursor_pos_after_load is not None
def _has_history_prefix(self) -> bool:
return self._history_prefix is not None
def _has_history_navigation_context(self) -> bool:
return self._is_on_loaded_history_entry() or self._has_history_prefix()
def _should_intercept_history_down(self) -> bool:
history_loaded_and_cursor_unmoved = (
self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
)
if history_loaded_and_cursor_unmoved and self._has_history_prefix():
if self._is_on_loaded_history_entry() and not self._cursor_moved_since_load:
return True
if not self.navigator.is_last_wrapped_line(self.cursor_location):
return False
return self._has_history_navigation_context()
return self._is_on_loaded_history_entry()
def _handle_history_down(self) -> bool:
_, cursor_col = self.cursor_location
if not self._should_intercept_history_down():
return False
navigating_loaded_history = self._is_on_loaded_history_entry()
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
if not navigating_loaded_history:
self._reset_prefix()
self._last_cursor_col = 0
if self._history_prefix is None:
if navigating_loaded_history and self._last_used_prefix is not None:
self._history_prefix = self._last_used_prefix
else:
self._history_prefix = self._get_prefix_up_to_cursor()
self._navigating_history = True
self.post_message(self.HistoryNext(self._history_prefix))
self.post_message(self.HistoryNext())
return True
async def _handle_voice_key(self, event: events.Key) -> bool:
if not self._voice_manager:
return False
# Handle key pressed during audio recording
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
event.prevent_default()
event.stop()
if event.key == "ctrl+c": # Escape is handled in app.py
self._voice_manager.cancel_recording()
elif self._voice_manager.transcribe_state == TranscribeState.RECORDING:
await self._voice_manager.stop_recording()
return True
# Handle audio record keybind
if self._voice_manager.is_enabled and event.key == "ctrl+r":
event.prevent_default()
event.stop()
try:
self._voice_manager.start_recording()
except RecordingStartError as e:
self.notify(str(e), severity="warning")
return True
return False
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
if await self._handle_voice_key(event):
return
self._mark_cursor_moved_if_needed()
manager = self._completion_manager
@ -223,7 +207,6 @@ class ChatTextArea(TextArea):
event.stop()
value = self.get_full_text().strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
return
@ -232,7 +215,6 @@ class ChatTextArea(TextArea):
event.stop()
value = self.get_full_text().strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
return
@ -323,7 +305,6 @@ class ChatTextArea(TextArea):
self.move_cursor((last_row, len(lines[last_row])))
def reset_history_state(self) -> None:
self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False

View file

@ -1,6 +1,9 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from vibe.cli.textual_ui.app import ChatScroll
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
@ -67,6 +70,7 @@ class StreamingMessageBase(Static):
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
self._content_initialized = False
self._to_write_buffer = ""
def _get_markdown(self) -> Markdown:
if self._markdown is None:
@ -80,23 +84,46 @@ class StreamingMessageBase(Static):
self._stream = Markdown.get_stream(self._get_markdown())
return self._stream
def _is_chat_at_bottom(self) -> bool:
try:
chat = cast("ChatScroll", self.app.query_one("#chat"))
return chat.is_at_bottom
except Exception:
return True
async def append_content(self, content: str) -> None:
if not content:
return
self._content += content
if self._should_write_content():
if not self._should_write_content():
return
if self._is_chat_at_bottom():
to_write = self._to_write_buffer + content
self._to_write_buffer = ""
stream = self._ensure_stream()
await stream.write(content)
await stream.write(to_write)
return
self._to_write_buffer += content
async def write_initial_content(self) -> None:
if self._content_initialized:
return
self._content_initialized = True
if self._content and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._content)
self._to_write_buffer = ""
async def stop_stream(self) -> None:
if self._to_write_buffer and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._to_write_buffer)
self._to_write_buffer = ""
if self._stream is None:
return
@ -187,6 +214,7 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
await self._markdown.update("")
stream = self._ensure_stream()
await stream.write(self._content)
self._to_write_buffer = ""
class UserCommandMessage(Static):

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from vibe.cli.voice_manager.voice_manager import VoiceManager
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
VoiceManagerPort,
VoiceToggleResult,
)
__all__ = [
"RecordingStartError",
"VoiceManager",
"VoiceManagerPort",
"VoiceToggleResult",
]

View file

@ -0,0 +1,186 @@
from __future__ import annotations
from asyncio import CancelledError, Task, create_task, wait_for
from collections.abc import Callable
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
TranscribeState,
VoiceManagerListener,
VoiceToggleResult,
)
from vibe.core.audio_recorder import AudioRecorderPort
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
NoAudioInputDeviceError,
RecordingMode,
)
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.transcribe.transcribe_client_port import (
TranscribeClientPort,
TranscribeDone,
TranscribeError,
TranscribeSessionCreated,
TranscribeTextDelta,
)
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
class VoiceManager:
def __init__(
self,
config_getter: Callable[[], VibeConfig],
audio_recorder: AudioRecorderPort,
transcribe_client: TranscribeClientPort | None,
) -> None:
self._config_getter = config_getter
self._audio_recorder = audio_recorder
self._transcribe_client = transcribe_client
self._transcribe_state = TranscribeState.IDLE
self._transcribe_task: Task[None] | None = None
self._listeners: list[VoiceManagerListener] = []
@property
def is_enabled(self) -> bool:
return self._config_getter().voice_mode_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:
new_state = not self.is_enabled
if not new_state:
self.cancel_recording()
VibeConfig.save_updates({"voice_mode_enabled": new_state})
for listener in self._listeners:
try:
listener.on_voice_mode_change(new_state)
except Exception:
logger.error("Listener raised during voice mode change", exc_info=True)
return VoiceToggleResult(enabled=new_state)
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None:
if self._transcribe_state != TranscribeState.IDLE:
return
if self._transcribe_client is None:
logger.warning(
"Failed to start recording as the transcribe client is missing"
)
raise RecordingStartError("Transcribe client is not available")
model = self._config_getter().get_active_transcribe_model()
try:
self._audio_recorder.start(mode, sample_rate=model.sample_rate)
except AlreadyRecordingError:
raise RecordingStartError("Recording is already in progress")
except AudioBackendUnavailableError:
raise RecordingStartError("Audio backend is unavailable")
except NoAudioInputDeviceError:
raise RecordingStartError("No audio input device found")
self._set_state(TranscribeState.RECORDING)
self._transcribe_task = create_task(self._run_transcription())
async def stop_recording(self) -> None:
if self._transcribe_state != TranscribeState.RECORDING:
return
should_flush_queue = self._audio_recorder.mode == RecordingMode.STREAM
if should_flush_queue:
self._set_state(TranscribeState.FLUSHING)
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
if self._transcribe_task is not None:
try:
await wait_for(
self._transcribe_task, timeout=TRANSCRIPTION_DRAIN_TIMEOUT
)
except TimeoutError:
logger.warning("Transcription task timed out, cancelling")
self._transcribe_task.cancel()
except CancelledError:
pass
self._transcribe_task = None
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
def cancel_recording(self) -> None:
if self._transcribe_state == TranscribeState.IDLE:
return
self._audio_recorder.cancel()
if self._transcribe_task is not None:
self._transcribe_task.cancel()
self._transcribe_task = None
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
async def _run_transcription(self) -> None:
if self._transcribe_client is None:
return
try:
audio_stream = self._audio_recorder.audio_stream()
async for event in self._transcribe_client.transcribe(audio_stream):
match event:
case TranscribeTextDelta(text=text):
for listener in self._listeners:
try:
listener.on_transcribe_text(text)
except Exception:
logger.error(
"Listener raised during transcribe text",
exc_info=True,
)
case TranscribeDone():
pass
case TranscribeError(message=msg):
raise RuntimeError(msg)
case TranscribeSessionCreated():
pass
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
except CancelledError:
raise
except Exception as exc:
logger.error("Transcription failed", exc_info=exc)
self._audio_recorder.cancel()
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
def _set_state(self, state: TranscribeState) -> None:
if self._transcribe_state == state:
return
self._transcribe_state = state
for listener in self._listeners:
try:
listener.on_transcribe_state_change(state)
except Exception:
logger.error("Listener raised during state change", exc_info=True)

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
from vibe.core.audio_recorder.audio_recorder_port import RecordingMode
class TranscribeState(StrEnum):
IDLE = auto()
RECORDING = auto()
FLUSHING = auto()
@dataclass(frozen=True, slots=True)
class VoiceToggleResult:
enabled: bool
class RecordingStartError(Exception):
pass
class VoiceManagerListener:
def on_transcribe_state_change(self, state: TranscribeState) -> None:
pass
def on_voice_mode_change(self, enabled: bool) -> None:
pass
def on_transcribe_text(self, text: str) -> None:
pass
class VoiceManagerPort(Protocol):
@property
def is_enabled(self) -> bool: ...
@property
def transcribe_state(self) -> TranscribeState: ...
@property
def peak(self) -> float: ...
def toggle_voice_mode(self) -> VoiceToggleResult: ...
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None: ...
async def stop_recording(self) -> None: ...
def cancel_recording(self) -> None: ...
def add_listener(self, listener: VoiceManagerListener) -> None: ...
def remove_listener(self, listener: VoiceManagerListener) -> None: ...

View file

@ -2,13 +2,14 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
import contextlib
from enum import StrEnum, auto
from http import HTTPStatus
import json
from pathlib import Path
from threading import Thread
import time
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import TYPE_CHECKING, Any, Literal
from uuid import uuid4
from pydantic import BaseModel
@ -16,7 +17,7 @@ from pydantic import BaseModel
from vibe.cli.terminal_setup import detect_terminal
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import Backend, ProviderConfig, VibeConfig
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.format import (
@ -66,7 +67,6 @@ from vibe.core.types import (
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
BaseEvent,
CompactEndEvent,
CompactStartEvent,
@ -78,7 +78,6 @@ from vibe.core.types import (
RateLimitError,
ReasoningEvent,
Role,
SyncApprovalCallback,
ToolCall,
ToolCallEvent,
ToolResultEvent,
@ -87,6 +86,7 @@ from vibe.core.types import (
UserMessageEvent,
)
from vibe.core.utils import (
CANCELLATION_TAG,
TOOL_ERROR_TAG,
VIBE_STOP_EVENT_TAG,
CancellationReason,
@ -217,6 +217,10 @@ class AgentLoop:
def agent_profile(self) -> AgentProfile:
return self.agent_manager.active_profile
@property
def base_config(self) -> VibeConfig:
return self._base_config
@property
def config(self) -> VibeConfig:
return self.agent_manager.config
@ -239,6 +243,10 @@ class AgentLoop:
self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name)
def refresh_config(self) -> None:
self._base_config = VibeConfig.load()
self.agent_manager.invalidate_config()
def emit_new_session_telemetry(self) -> None:
entrypoint = (
self.entrypoint_metadata.agent_entrypoint
@ -348,15 +356,9 @@ class AgentLoop:
if self._max_price is not None:
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
active_model = self.config.get_active_model()
if active_model.auto_compact_threshold > 0:
self.middleware_pipeline.add(
AutoCompactMiddleware(active_model.auto_compact_threshold)
)
if self.config.context_warnings:
self.middleware_pipeline.add(
ContextWarningMiddleware(0.5, active_model.auto_compact_threshold)
)
self.middleware_pipeline.add(AutoCompactMiddleware())
if self.config.context_warnings:
self.middleware_pipeline.add(ContextWarningMiddleware(0.5))
self.middleware_pipeline.add(
ReadOnlyAgentMiddleware(
@ -425,6 +427,10 @@ class AgentLoop:
messages=self.messages, stats=self.stats, config=self.config
)
def _build_metadata(self) -> dict[str, str]:
base = self.entrypoint_metadata.model_dump() if self.entrypoint_metadata else {}
return base | {"session_id": self.session_id}
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
headers: dict[str, str] = {
"user-agent": get_user_agent(provider.backend),
@ -577,39 +583,35 @@ class AgentLoop:
tool_instance = self.tool_manager.get(tool_call.tool_name)
except Exception as exc:
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, error_msg, "failure")
yield self._tool_failure_event(tool_call, error_msg)
return
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
return
self.stats.tool_calls_agreed += 1
decision: ToolDecision | None = None
try:
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
cancelled=f"<{CANCELLATION_TAG}>" in skip_reason,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
return
self.stats.tool_calls_agreed += 1
start_time = time.perf_counter()
result_model = None
async for item in tool_instance.invoke(
@ -637,6 +639,9 @@ class AgentLoop:
result_dict = result_model.model_dump()
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
extra = tool_instance.get_result_extra(result_model)
if extra:
text += "\n\n" + extra
self._handle_tool_response(
tool_call, text, "success", decision, result_dict
)
@ -644,6 +649,7 @@ class AgentLoop:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
cancelled=getattr(result_model, "cancelled", False),
duration=duration,
tool_call_id=tool_call.call_id,
)
@ -653,35 +659,27 @@ class AgentLoop:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, cancel, "failure", decision)
self.stats.tool_calls_failed += 1
yield self._tool_failure_event(tool_call, cancel, decision, cancelled=True)
raise
except (ToolError, ToolPermissionError) as exc:
except Exception as exc:
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
self.stats.tool_calls_agreed -= 1
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
self._handle_tool_response(tool_call, error_msg, "failure", decision)
yield self._tool_failure_event(tool_call, error_msg, decision)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
async for event in self._emit_failed_tool_events(resolved.failed_calls):
yield event
if not resolved.tool_calls:
return
for tool_call in resolved.tool_calls:
yield ToolCallEvent(
tool_name=tool_call.tool_name,
@ -689,8 +687,62 @@ class AgentLoop:
args=tool_call.validated_args,
tool_call_id=tool_call.call_id,
)
async for event in self._process_one_tool_call(tool_call):
async for event in self._run_tools_concurrently(resolved.tool_calls):
yield event
async def _execute_tool_to_queue(
self,
tc: ResolvedToolCall,
queue: asyncio.Queue[ToolCallEvent | ToolResultEvent | ToolStreamEvent | None],
) -> None:
"""Run a single tool call, sending events to the queue."""
async for event in self._process_one_tool_call(tc):
await queue.put(event)
async def _run_tools_concurrently(
self, tool_calls: list[ResolvedToolCall]
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
"""Execute multiple tool calls concurrently, yielding events as they arrive."""
queue: asyncio.Queue[
ToolCallEvent | ToolResultEvent | ToolStreamEvent | None
] = asyncio.Queue()
tasks = [
asyncio.create_task(self._execute_tool_to_queue(tc, queue))
for tc in tool_calls
]
async def _signal_when_all_done() -> None:
try:
await asyncio.gather(*tasks, return_exceptions=True)
finally:
await queue.put(None)
monitor = asyncio.create_task(_signal_when_all_done())
try:
while True:
event = await queue.get()
if event is None:
break
yield event
except GeneratorExit:
for t in tasks:
if not t.done():
t.cancel()
raise
except asyncio.CancelledError:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
finally:
if not monitor.done():
monitor.cancel()
with contextlib.suppress(asyncio.CancelledError):
await monitor
def _handle_tool_response(
self,
@ -714,8 +766,27 @@ class AgentLoop:
result=result,
)
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
active_model = self.config.get_active_model()
def _tool_failure_event(
self,
tool_call: ResolvedToolCall,
error_msg: str,
decision: ToolDecision | None = None,
cancelled: bool = False,
) -> ToolResultEvent:
"""Create a ToolResultEvent for a failed tool and record the failure."""
self._handle_tool_response(tool_call, error_msg, "failure", decision)
return ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
cancelled=cancelled,
tool_call_id=tool_call.call_id,
)
async def _chat(
self, max_tokens: int | None = None, model_override: ModelConfig | None = None
) -> LLMChunk:
active_model = model_override or self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
available_tools = self.format_handler.get_available_tools(self.tool_manager)
@ -731,9 +802,7 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
metadata=self._build_metadata(),
)
end_time = time.perf_counter()
@ -777,9 +846,7 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
metadata=self._build_metadata(),
):
processed_message = self.format_handler.process_api_response_message(
chunk.message
@ -855,26 +922,17 @@ class AgentLoop:
approval_type=ToolPermission.ASK,
feedback="Tool execution not permitted.",
)
if asyncio.iscoroutinefunction(self.approval_callback):
async_callback = cast(AsyncApprovalCallback, self.approval_callback)
response, feedback = await async_callback(tool_name, args, tool_call_id)
else:
sync_callback = cast(SyncApprovalCallback, self.approval_callback)
response, feedback = sync_callback(tool_name, args, tool_call_id)
response, feedback = await self.approval_callback(tool_name, args, tool_call_id)
match response:
case ApprovalResponse.YES:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
case ApprovalResponse.NO:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
verdict = ToolExecutionResponse.EXECUTE
case _:
verdict = ToolExecutionResponse.SKIP
return ToolDecision(
verdict=verdict, approval_type=ToolPermission.ASK, feedback=feedback
)
def _clean_message_history(self) -> None:
ACCEPTABLE_HISTORY_SIZE = 2
@ -892,17 +950,19 @@ class AgentLoop:
expected_responses = len(msg.tool_calls)
if expected_responses > 0:
actual_responses = 0
responded_ids: set[str] = set()
j = i + 1
while j < len(self.messages) and self.messages[j].role == "tool":
actual_responses += 1
if self.messages[j].tool_call_id:
responded_ids.add(self.messages[j].tool_call_id)
j += 1
if actual_responses < expected_responses:
insertion_point = i + 1 + actual_responses
if len(responded_ids) < expected_responses:
insertion_point = j
for call_idx in range(actual_responses, expected_responses):
tool_call_data = msg.tool_calls[call_idx]
for tool_call_data in msg.tool_calls:
if (tool_call_data.id or "") in responded_ids:
continue
empty_response = LLMMessage(
role=Role.tool,
@ -990,7 +1050,9 @@ class AgentLoop:
self.messages.append(
LLMMessage(role=Role.user, content=summary_request)
)
summary_result = await self._chat()
summary_result = await self._chat(
model_override=self.config.get_compaction_model()
)
if summary_result.usage is None:
raise AgentLoopLLMResponseError(
@ -1010,9 +1072,7 @@ class AgentLoop:
messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
metadata=self._build_metadata(),
)
self.stats.context_tokens = actual_context_tokens

View file

@ -50,19 +50,25 @@ class AgentManager:
@property
def available_agents(self) -> dict[str, AgentProfile]:
installed = self._config.installed_agents
base = {
name: profile
for name, profile in self._available.items()
if not profile.install_required or name in installed
}
if self._config.enabled_agents:
return {
name: profile
for name, profile in self._available.items()
for name, profile in base.items()
if name_matches(name, self._config.enabled_agents)
}
if self._config.disabled_agents:
return {
name: profile
for name, profile in self._available.items()
for name, profile in base.items()
if not name_matches(name, self._config.disabled_agents)
}
return dict(self._available)
return base
@property
def config(self) -> VibeConfig:

View file

@ -41,6 +41,7 @@ class BuiltinAgentName(StrEnum):
ACCEPT_EDITS = "accept-edits"
AUTO_APPROVE = "auto-approve"
EXPLORE = "explore"
LEAN = "lean"
@dataclass(frozen=True)
@ -51,6 +52,7 @@ class AgentProfile:
safety: AgentSafety
agent_type: AgentType = AgentType.AGENT
overrides: dict[str, Any] = field(default_factory=dict)
install_required: bool = False
def apply_to_config(self, base: VibeConfig) -> VibeConfig:
from vibe.core.config import VibeConfig as VC
@ -134,10 +136,51 @@ EXPLORE = AgentProfile(
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
)
LEAN = AgentProfile(
name=BuiltinAgentName.LEAN,
display_name="Lean",
description="Specialized mode for Lean 4 code analysis, proof assistance, and theorem proving",
safety=AgentSafety.NEUTRAL,
agent_type=AgentType.AGENT,
install_required=True,
overrides={
"system_prompt_id": "lean",
"active_model": "leanstral",
"providers": [
{
"name": "mistral-testing",
"api_base": "https://api.mistral.ai/v1",
"api_key_env_var": "MISTRAL_API_KEY",
"api_style": "reasoning",
"backend": "generic",
}
],
"models": [
{
"name": "labs-leanstral-2603",
"provider": "mistral-testing",
"alias": "leanstral",
"thinking": "high",
"temperature": 1.0,
"auto_compact_threshold": 168_000,
}
],
"compaction_model": {
"name": "mistral-vibe-cli-latest",
"provider": "mistral-testing",
"alias": "devstral-compact",
"temperature": 0.2,
"thinking": "off",
},
"tools": {"bash": {"default_timeout": 1200}},
},
)
BUILTIN_AGENTS: dict[str, AgentProfile] = {
BuiltinAgentName.DEFAULT: DEFAULT,
BuiltinAgentName.PLAN: PLAN,
BuiltinAgentName.ACCEPT_EDITS: ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE: AUTO_APPROVE,
BuiltinAgentName.EXPLORE: EXPLORE,
BuiltinAgentName.LEAN: LEAN,
}

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from vibe.core.audio_recorder.audio_recorder import AudioRecorder
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
AudioRecorderPort,
AudioRecording,
IncompatibleSampleRateError,
NoAudioInputDeviceError,
RecordingMode,
)
__all__ = [
"AlreadyRecordingError",
"AudioBackendUnavailableError",
"AudioRecorder",
"AudioRecorderPort",
"AudioRecording",
"IncompatibleSampleRateError",
"NoAudioInputDeviceError",
"RecordingMode",
]

View file

@ -0,0 +1,286 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
import io
import struct
import threading
import time
from typing import TYPE_CHECKING
import wave
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
AudioRecording,
IncompatibleSampleRateError,
NoAudioInputDeviceError,
RecordingMode,
)
from vibe.core.logger import logger
try:
import sounddevice as sd
if TYPE_CHECKING:
from sounddevice import CallbackFlags, RawInputStream
except OSError:
sd = None # type: ignore[assignment]
DEFAULT_SAMPLE_RATE = 48_000
DEFAULT_CHANNELS = 1
DTYPE = "int16"
DEFAULT_BLOCKSIZE = 4096
DEFAULT_SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
INT16_ABS_MAX = 2**15 - 1
DRAIN_TIMEOUT = 5.0
DEFAULT_MAX_DURATION = 300.0 # 5 min
class AudioRecorder:
"""Records audio from the default microphone using sounddevice.
Supports both buffer mode (stop returns WAV bytes) and streaming
mode (async generator yields chunks).
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._mode: RecordingMode = RecordingMode.BUFFER
self._stream: RawInputStream | None = None
self._frames: list[bytes] = []
self._peak: float = 0.0
self._recording: bool = False
self._start_time: float = 0.0
self._loop: asyncio.AbstractEventLoop | None = None
self._audio_queue: asyncio.Queue[bytes | None] | None = None
self._audio_queue_drained: threading.Event | None = None
self._max_duration_timer: threading.Timer | None = None
self._on_expire: Callable[[AudioRecording], object] | None = None
@property
def is_recording(self) -> bool:
return self._recording
@property
def mode(self) -> RecordingMode:
return self._mode
@property
def peak(self) -> float:
"""Current audio peak level normalized to [0.0, 1.0], updated per audio block."""
return self._peak
def start(
self,
mode: RecordingMode,
*,
sample_rate: int = DEFAULT_SAMPLE_RATE,
channels: int = DEFAULT_CHANNELS,
max_duration: float = DEFAULT_MAX_DURATION,
on_expire: Callable[[AudioRecording], object] | None = None,
) -> None:
with self._lock:
if self._recording:
raise AlreadyRecordingError("Already recording")
if not sd:
error_message = "sounddevice is not available, audio recording disabled"
logger.error(error_message)
raise AudioBackendUnavailableError(error_message)
try:
sample_rate = self._guard_audio_input(sample_rate, channels)
except NoAudioInputDeviceError as exc:
logger.error("No audio input device available, recording disabled")
raise exc
except IncompatibleSampleRateError as exc:
logger.warning(
"Requested sample rate %d Hz not supported, falling back to %d Hz",
sample_rate,
exc.fallback_sample_rate,
)
sample_rate = exc.fallback_sample_rate
self._mode = mode
self._sample_rate = sample_rate
self._channels = channels
self._peak = 0.0
self._start_time = time.monotonic()
self._frames = []
if mode == RecordingMode.BUFFER:
self._audio_queue = None
self._loop = None
self._audio_queue_drained = None
else:
self._audio_queue_drained = threading.Event()
try:
self._loop = asyncio.get_running_loop()
self._audio_queue = asyncio.Queue()
except RuntimeError:
self._loop = None
self._audio_queue = None
self._stream = sd.RawInputStream(
samplerate=self._sample_rate,
channels=self._channels,
dtype=DTYPE,
blocksize=DEFAULT_BLOCKSIZE,
callback=self._audio_callback,
)
self._stream.start()
self._recording = True
self._on_expire = on_expire
self._start_max_duration_timer(max_duration)
def stop(self, *, wait_for_queue_drained: bool = True) -> AudioRecording:
with self._lock:
if not self._recording or self._stream is None:
return AudioRecording(data=b"", duration=0.0)
self._reset_max_duration_timer()
self._stop_stream()
self._recording = False
duration = time.monotonic() - self._start_time
if self._mode == RecordingMode.BUFFER:
wav_data = self._encode_wav()
self._frames = []
return AudioRecording(data=wav_data, duration=duration)
loop = self._loop
self._push_sentinel()
try:
on_event_loop = asyncio.get_running_loop() is loop
except RuntimeError:
on_event_loop = False
if (
wait_for_queue_drained
and self._audio_queue_drained is not None
and not on_event_loop
):
self._audio_queue_drained.wait(timeout=DRAIN_TIMEOUT)
return AudioRecording(data=b"", duration=duration)
def cancel(self) -> None:
with self._lock:
if not self._recording or self._stream is None:
return
self._reset_max_duration_timer()
self._stop_stream()
self._recording = False
if self._mode == RecordingMode.BUFFER:
self._frames = []
else:
self._push_sentinel()
async def audio_stream(self) -> AsyncGenerator[bytes, None]:
queue = self._audio_queue
if queue is None:
return
audio_queue_drained = self._audio_queue_drained
try:
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
finally:
if audio_queue_drained is not None:
audio_queue_drained.set()
def _audio_callback(
self, indata: bytes, frames: int, time_info: object, status: CallbackFlags
) -> None:
if status:
logger.warning("Audio callback status: %s", status)
raw = bytes(indata)
n_samples = frames * self._channels
if n_samples > 0:
samples = struct.unpack(f"<{n_samples}h", raw)
self._peak = min(max(abs(s) for s in samples) / INT16_ABS_MAX, 1.0)
if self._mode == RecordingMode.BUFFER:
self._frames.append(raw)
if (
self._mode == RecordingMode.STREAM
and self._loop is not None
and self._audio_queue is not None
):
self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, raw)
def _stop_stream(self) -> None:
if self._stream is not None:
self._stream.stop()
self._stream.close()
self._stream = None
def _push_sentinel(self) -> None:
"""Push None to the audio queue to signal end-of-stream to the consumer."""
if self._loop is not None and self._audio_queue is not None:
self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, None)
self._audio_queue = None
self._loop = None
@staticmethod
def _guard_audio_input(sample_rate: int, channels: int) -> int:
if sd is None:
raise RuntimeError("sounddevice is not available")
try:
device_info = sd.query_devices(kind="input")
except Exception as exc:
raise NoAudioInputDeviceError("No audio input device available") from exc
try:
sd.check_input_settings(
samplerate=sample_rate, channels=channels, dtype=DTYPE
)
except sd.PortAudioError as exc:
fallback = int(device_info["default_samplerate"])
raise IncompatibleSampleRateError(
f"Requested sample rate {sample_rate} Hz is not supported by the default "
f"input device; device default is {fallback} Hz",
fallback_sample_rate=fallback,
) from exc
return sample_rate
def _encode_wav(self) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(self._channels)
wf.setsampwidth(DEFAULT_SAMPLE_WIDTH)
wf.setframerate(self._sample_rate)
wf.writeframes(b"".join(self._frames))
return buf.getvalue()
def _on_max_duration_expired(self) -> None:
result = self.stop()
if self._on_expire is not None:
self._on_expire(result)
def _start_max_duration_timer(self, max_duration: float) -> None:
if max_duration <= 0:
return
self._max_duration_timer = threading.Timer(
max_duration, self._on_max_duration_expired
)
self._max_duration_timer.daemon = True
self._max_duration_timer.start()
def _reset_max_duration_timer(self) -> None:
if self._max_duration_timer is None:
return
self._max_duration_timer.cancel()
self._max_duration_timer = None

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
class RecordingMode(StrEnum):
BUFFER = auto()
STREAM = auto()
@dataclass(frozen=True, slots=True)
class AudioRecording:
"""Result of a completed recording."""
data: bytes
duration: float
class AlreadyRecordingError(Exception):
pass
class AudioBackendUnavailableError(Exception):
pass
class NoAudioInputDeviceError(Exception):
pass
class IncompatibleSampleRateError(Exception):
def __init__(self, message: str, fallback_sample_rate: int) -> None:
super().__init__(message)
self.fallback_sample_rate = fallback_sample_rate
class AudioRecorderPort(Protocol):
@property
def is_recording(self) -> bool: ...
@property
def peak(self) -> float: ...
@property
def mode(self) -> RecordingMode: ...
def start(
self,
mode: RecordingMode,
*,
sample_rate: int = ...,
channels: int = ...,
max_duration: float = ...,
on_expire: Callable[[AudioRecording], object] | None = ...,
) -> None: ...
def stop(self, *, wait_for_queue_drained: bool = ...) -> AudioRecording: ...
def cancel(self) -> None: ...
def audio_stream(self) -> AsyncGenerator[bytes, None]: ...

View file

@ -5,6 +5,10 @@ from pathlib import Path
from typing import NamedTuple
from vibe.core.autocompletion.file_indexer import FileIndexer, IndexEntry
from vibe.core.autocompletion.file_indexer.store import (
ASCII_CODEPOINT_LIMIT,
build_ascii_mask,
)
from vibe.core.autocompletion.fuzzy import fuzzy_match
DEFAULT_MAX_ENTRIES_TO_PROCESS = 32000
@ -67,6 +71,18 @@ class CommandCompleter(Completer):
class PathCompleter(Completer):
class MatchRank(NamedTuple):
exact_directory: int
immediate_child_of_exact_path: int
exact_filename: int
preferred_stem_match: int
exact_stem: int
stem_prefix: int
name_prefix: int
extension_match: int
fuzzy_score: float
shallow_path: int
def __init__(
self,
max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS,
@ -82,6 +98,7 @@ class PathCompleter(Completer):
search_pattern: str
path_prefix: str
immediate_only: bool
search_pattern_ascii_mask: int | None
def _extract_partial(self, before_cursor: str) -> str | None:
if "@" not in before_cursor:
@ -97,11 +114,16 @@ class PathCompleter(Completer):
def _build_search_context(self, partial_path: str) -> _SearchContext:
suffix = partial_path.split("/")[-1]
search_pattern_ascii_mask = self._build_query_ascii_mask(partial_path)
if not partial_path:
# "@" => show top-level dir and files
return self._SearchContext(
search_pattern="", path_prefix="", suffix=suffix, immediate_only=True
search_pattern="",
path_prefix="",
suffix=suffix,
immediate_only=True,
search_pattern_ascii_mask=search_pattern_ascii_mask,
)
if partial_path.endswith("/"):
@ -111,6 +133,7 @@ class PathCompleter(Completer):
path_prefix=partial_path,
suffix=suffix,
immediate_only=True,
search_pattern_ascii_mask=search_pattern_ascii_mask,
)
return self._SearchContext(
@ -119,29 +142,40 @@ class PathCompleter(Completer):
path_prefix="",
suffix=suffix,
immediate_only=False,
search_pattern_ascii_mask=search_pattern_ascii_mask,
)
def _build_query_ascii_mask(self, pattern: str) -> int | None:
if any(ord(char) >= ASCII_CODEPOINT_LIMIT for char in pattern):
return None
return build_ascii_mask(pattern.lower())
def _is_immediate_child_of_prefix(self, path_str: str, prefix: str) -> bool:
prefix_without_slash = prefix.rstrip("/")
prefix_with_slash = f"{prefix_without_slash}/"
if path_str.startswith(prefix_with_slash):
after_prefix = path_str[len(prefix_with_slash) :]
else:
idx = path_str.find(prefix_with_slash)
if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
return False
after_prefix = path_str[idx + len(prefix_with_slash) :]
return bool(after_prefix) and "/" not in after_prefix
def _matches_prefix(self, entry: IndexEntry, context: _SearchContext) -> bool:
path_str = entry.rel
if context.path_prefix:
prefix_without_slash = context.path_prefix.rstrip("/")
prefix_with_slash = f"{prefix_without_slash}/"
if path_str == prefix_without_slash and entry.is_dir:
# do not suggest the dir itself (e.g. "@src/" => don't suggest "@src/")
return False
if path_str.startswith(prefix_with_slash):
after_prefix = path_str[len(prefix_with_slash) :]
else:
idx = path_str.find(prefix_with_slash)
if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
return False
after_prefix = path_str[idx + len(prefix_with_slash) :]
# only suggest files/dirs that are immediate children of the prefix
return bool(after_prefix) and "/" not in after_prefix
return self._is_immediate_child_of_prefix(path_str, context.path_prefix)
if context.immediate_only and "/" in path_str:
# when user just typed "@", only show top-level entries
@ -153,15 +187,72 @@ class PathCompleter(Completer):
def _is_visible(self, entry: IndexEntry, context: _SearchContext) -> bool:
return not (entry.name.startswith(".") and not context.suffix.startswith("."))
def _can_possibly_fuzzy_match(
self, entry: IndexEntry, context: _SearchContext
) -> bool:
if context.search_pattern_ascii_mask is None:
return True
return (
entry.ascii_mask & context.search_pattern_ascii_mask
) == context.search_pattern_ascii_mask
def _format_label(self, entry: IndexEntry) -> str:
suffix = "/" if entry.is_dir else ""
return f"@{entry.rel}{suffix}"
def _build_match_rank(
self, entry: IndexEntry, context: _SearchContext, fuzzy_score: float
) -> MatchRank:
query = context.suffix.lower()
if not query:
return self.MatchRank(
exact_directory=0,
immediate_child_of_exact_path=0,
exact_filename=0,
preferred_stem_match=0,
exact_stem=0,
stem_prefix=0,
name_prefix=0,
extension_match=0,
fuzzy_score=fuzzy_score,
shallow_path=-entry.rel.count("/"),
)
name = entry.name.lower()
rel = entry.rel.lower()
stem = Path(entry.name).stem.lower()
extension = Path(entry.name).suffix.lower()
query_extension = Path(query).suffix.lower()
query_stem = Path(query).stem.lower()
query_looks_like_filename = "." in query
query_looks_like_path = "/" in context.search_pattern
exact_directory = int(entry.is_dir and rel == context.search_pattern.lower())
immediate_child_of_exact_path = int(
query_looks_like_path
and self._is_immediate_child_of_prefix(rel, context.search_pattern.lower())
)
return self.MatchRank(
exact_directory=exact_directory,
immediate_child_of_exact_path=immediate_child_of_exact_path,
exact_filename=int(query_looks_like_filename and name == query),
preferred_stem_match=int(stem == query and extension != ".lock"),
exact_stem=int(
stem == query or (query_looks_like_filename and stem == query_stem)
),
stem_prefix=int(
stem.startswith(query_stem if query_looks_like_filename else query)
),
name_prefix=int(name.startswith(query)),
extension_match=int(bool(query_extension) and extension == query_extension),
fuzzy_score=fuzzy_score,
shallow_path=-entry.rel.count("/"),
)
def _score_matches(
self, entries: list[IndexEntry], context: _SearchContext
) -> list[tuple[str, float]]:
scored_matches: list[tuple[str, float]] = []
MAX_MATCHES = 50
) -> list[tuple[str, PathCompleter.MatchRank]]:
scored_matches: list[tuple[str, PathCompleter.MatchRank]] = []
for i, entry in enumerate(entries):
if i >= self._max_entries_to_process:
@ -176,24 +267,27 @@ class PathCompleter(Completer):
label = self._format_label(entry)
if not context.search_pattern:
scored_matches.append((label, 0.0))
rank = self._build_match_rank(entry, context, 0.0)
scored_matches.append((label, rank))
if len(scored_matches) >= self._target_matches:
break
continue
if not self._can_possibly_fuzzy_match(entry, context):
continue
match_result = fuzzy_match(
context.search_pattern, entry.rel, entry.rel_lower
)
if match_result.matched:
scored_matches.append((label, match_result.score))
if (
len(scored_matches) >= self._target_matches
and match_result.score > MAX_MATCHES
):
break
rank = self._build_match_rank(entry, context, match_result.score)
scored_matches.append((label, rank))
scored_matches.sort(key=lambda x: (-x[1], x[0]))
return scored_matches
# Sort alphabetically first, then by descending rank; Python's stable sort
# keeps the label order for entries with equal ranks.
scored_matches.sort(key=lambda x: x[0])
scored_matches.sort(key=lambda x: x[1], reverse=True)
return scored_matches[: self._target_matches]
def _collect_matches(self, text: str, cursor_pos: int) -> list[str]:
before_cursor = text[:cursor_pos]

View file

@ -8,6 +8,8 @@ from pathlib import Path
from vibe.core.autocompletion.file_indexer.ignore_rules import IgnoreRules
from vibe.core.autocompletion.file_indexer.watcher import Change
ASCII_CODEPOINT_LIMIT = 128
@dataclass(slots=True)
class FileIndexStats:
@ -22,6 +24,17 @@ class IndexEntry:
name: str
path: Path
is_dir: bool
ascii_mask: int
def build_ascii_mask(value: str) -> int:
mask = 0
for char in value:
codepoint = ord(char)
if codepoint >= ASCII_CODEPOINT_LIMIT:
continue
mask |= 1 << codepoint
return mask
class FileIndexStore:
@ -118,8 +131,14 @@ class FileIndexStore:
) -> IndexEntry | None:
if self._ignore_rules.should_ignore(rel_str, name, is_dir):
return None
rel_lower = rel_str.lower()
return IndexEntry(
rel=rel_str, rel_lower=rel_str.lower(), name=name, path=path, is_dir=is_dir
rel=rel_str,
rel_lower=rel_lower,
name=name,
path=path,
is_dir=is_dir,
ascii_mask=build_ascii_mask(rel_lower),
)
def _walk_directory(

View file

@ -4,6 +4,8 @@ from vibe.core.config._settings import (
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
DEFAULT_TRANSCRIBE_MODELS,
DEFAULT_TRANSCRIBE_PROVIDERS,
Backend,
MCPHttp,
MCPServer,
@ -16,6 +18,9 @@ from vibe.core.config._settings import (
ProviderConfig,
SessionLoggingConfig,
TomlFileSettingsSource,
TranscribeClient,
TranscribeModelConfig,
TranscribeProviderConfig,
VibeConfig,
load_dotenv_values,
)
@ -24,6 +29,8 @@ __all__ = [
"DEFAULT_MISTRAL_API_ENV_KEY",
"DEFAULT_MODELS",
"DEFAULT_PROVIDERS",
"DEFAULT_TRANSCRIBE_MODELS",
"DEFAULT_TRANSCRIBE_PROVIDERS",
"Backend",
"MCPHttp",
"MCPServer",
@ -36,6 +43,9 @@ __all__ = [
"ProviderConfig",
"SessionLoggingConfig",
"TomlFileSettingsSource",
"TranscribeClient",
"TranscribeModelConfig",
"TranscribeProviderConfig",
"VibeConfig",
"load_dotenv_values",
]

View file

@ -93,7 +93,6 @@ class ProjectContextConfig(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
default_commit_count: int = 5
max_doc_bytes: int = 32 * 1024
timeout_seconds: float = 2.0
@ -131,6 +130,17 @@ class ProviderConfig(BaseModel):
region: str = ""
class TranscribeClient(StrEnum):
MISTRAL = auto()
class TranscribeProviderConfig(BaseModel):
name: str
api_base: str = "wss://api.mistral.ai"
api_key_env_var: str = ""
client: TranscribeClient = TranscribeClient.MISTRAL
class _MCPBase(BaseModel):
name: str = Field(description="Short alias used to prefix tool names")
prompt: str | None = Field(
@ -229,6 +239,13 @@ MCPServer = Annotated[
]
def _default_alias_to_name(data: Any) -> Any:
if isinstance(data, dict):
if "alias" not in data or data["alias"] is None:
data["alias"] = data.get("name")
return data
class ModelConfig(BaseModel):
name: str
provider: str
@ -239,13 +256,19 @@ class ModelConfig(BaseModel):
thinking: Literal["off", "low", "medium", "high"] = "off"
auto_compact_threshold: int = 200_000
@model_validator(mode="before")
@classmethod
def _default_alias_to_name(cls, data: Any) -> Any:
if isinstance(data, dict):
if "alias" not in data or data["alias"] is None:
data["alias"] = data.get("name")
return data
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class TranscribeModelConfig(BaseModel):
name: str
provider: str
alias: str
sample_rate: int = 16000
encoding: Literal["pcm_s16le"] = "pcm_s16le"
language: str = "en"
target_streaming_delay_ms: int = 500
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
@ -289,6 +312,22 @@ DEFAULT_MODELS = [
),
]
DEFAULT_TRANSCRIBE_PROVIDERS = [
TranscribeProviderConfig(
name="mistral",
api_base="wss://api.mistral.ai",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
)
]
DEFAULT_TRANSCRIBE_MODELS = [
TranscribeModelConfig(
name="voxtral-mini-transcribe-realtime-2602",
provider="mistral",
alias="voxtral-realtime",
)
]
class VibeConfig(BaseSettings):
active_model: str = "devstral-2"
@ -298,6 +337,8 @@ class VibeConfig(BaseSettings):
file_watcher_for_autocomplete: bool = False
displayed_workdir: str = ""
context_warnings: bool = False
voice_mode_enabled: bool = False
active_transcribe_model: str = "voxtral-realtime"
auto_approve: bool = False
enable_telemetry: bool = True
system_prompt_id: str = "cli"
@ -323,6 +364,14 @@ class VibeConfig(BaseSettings):
default_factory=lambda: list(DEFAULT_PROVIDERS)
)
models: list[ModelConfig] = Field(default_factory=lambda: list(DEFAULT_MODELS))
compaction_model: ModelConfig | None = None
transcribe_providers: list[TranscribeProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS)
)
transcribe_models: list[TranscribeModelConfig] = Field(
default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)
)
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
@ -378,6 +427,12 @@ class VibeConfig(BaseSettings):
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
installed_agents: list[str] = Field(
default_factory=list,
description=(
"A list of opt-in builtin agent names that have been explicitly installed."
),
)
skill_paths: list[Path] = Field(
default_factory=list,
description=(
@ -405,6 +460,10 @@ class VibeConfig(BaseSettings):
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("exclude_none", True)
return super().model_dump(**kwargs)
@property
def nuage_api_key(self) -> str:
return os.getenv(self.nuage_api_key_env_var, "")
@ -437,6 +496,11 @@ class VibeConfig(BaseSettings):
f"Active model '{self.active_model}' not found in configuration."
)
def get_compaction_model(self) -> ModelConfig:
if self.compaction_model is not None:
return self.compaction_model
return self.get_active_model()
def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
for provider in self.providers:
if provider.name == model.provider:
@ -445,6 +509,24 @@ class VibeConfig(BaseSettings):
f"Provider '{model.provider}' for model '{model.name}' not found in configuration."
)
def get_active_transcribe_model(self) -> TranscribeModelConfig:
for model in self.transcribe_models:
if model.alias == self.active_transcribe_model:
return model
raise ValueError(
f"Active transcribe model '{self.active_transcribe_model}' not found in configuration."
)
def get_transcribe_provider_for_model(
self, model: TranscribeModelConfig
) -> TranscribeProviderConfig:
for provider in self.transcribe_providers:
if provider.name == model.provider:
return provider
raise ValueError(
f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration."
)
@classmethod
def settings_customise_sources(
cls,
@ -480,6 +562,24 @@ class VibeConfig(BaseSettings):
]
return self
@model_validator(mode="after")
def _check_compaction_model_provider(self) -> VibeConfig:
if self.compaction_model is None:
return self
compaction_provider = self.get_provider_for_model(self.compaction_model)
try:
active_provider = self.get_provider_for_model(self.get_active_model())
except ValueError:
return self
if active_provider.name != compaction_provider.name:
raise ValueError(
f"Compaction model '{self.compaction_model.alias}' uses provider "
f"'{compaction_provider.name}' but active model uses provider "
f"'{active_provider.name}'. They must share the same provider."
)
return self
@model_validator(mode="after")
def _check_api_key(self) -> VibeConfig:
try:
@ -534,6 +634,17 @@ class VibeConfig(BaseSettings):
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _validate_transcribe_model_uniqueness(self) -> VibeConfig:
seen_aliases: set[str] = set()
for model in self.transcribe_models:
if model.alias in seen_aliases:
raise ValueError(
f"Duplicate transcribe model alias found: '{model.alias}'. Aliases must be unique."
)
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _check_system_prompt(self) -> VibeConfig:
_ = self.system_prompt
@ -558,7 +669,13 @@ class VibeConfig(BaseSettings):
and isinstance(target.get(key), list)
and isinstance(value, list)
):
if key in {"providers", "models"}:
if key in {
"providers",
"models",
"transcribe_providers",
"transcribe_models",
"installed_agents",
}:
target[key] = value
else:
target[key] = list(set(value + target[key]))
@ -566,9 +683,7 @@ class VibeConfig(BaseSettings):
target[key] = value
deep_merge(current_config, updates)
cls.dump_config(
to_jsonable_python(current_config, exclude_none=True, fallback=str)
)
cls.dump_config(current_config)
@classmethod
def dump_config(cls, config: dict[str, Any]) -> None:
@ -578,11 +693,29 @@ class VibeConfig(BaseSettings):
target = mgr.config_file or mgr.user_config_file
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as f:
tomli_w.dump(config, f)
tomli_w.dump(to_jsonable_python(config, exclude_none=True, fallback=str), f)
@classmethod
def _migrate(cls) -> None:
pass
mgr = get_harness_files_manager()
if not mgr.persist_allowed:
return
file = mgr.config_file
if file is None:
return
try:
with file.open("rb") as f:
data = tomllib.load(f)
except (FileNotFoundError, tomllib.TOMLDecodeError, OSError):
return
bash_tools = data.get("tools", {}).get("bash", {})
allowlist = bash_tools.get("allowlist")
if allowlist is None or "find" not in allowlist:
return
allowlist.remove("find")
cls.dump_config(data)
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
@ -592,7 +725,7 @@ class VibeConfig(BaseSettings):
@classmethod
def create_default(cls) -> dict[str, Any]:
config = cls.model_construct()
config_dict = config.model_dump(mode="json", exclude_none=True)
config_dict = config.model_dump(mode="json")
from vibe.core.tools.manager import ToolManager

View file

@ -10,7 +10,7 @@ from vibe.core.config.harness_files._paths import (
GLOBAL_SKILLS_DIR,
GLOBAL_TOOLS_DIR,
)
from vibe.core.paths import AGENTS_MD_FILENAMES, VIBE_HOME, walk_local_config_dirs_all
from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
from vibe.core.trusted_folders import trusted_folders_manager
FileSource = Literal["user", "project"]
@ -110,17 +110,85 @@ class HarnessFilesManager:
d = GLOBAL_PROMPTS_DIR.path
return [d] if d.is_dir() else []
def load_project_doc(self, max_bytes: int) -> str:
def load_user_doc(self) -> str:
if "user" not in self.sources:
return ""
path = VIBE_HOME.path / AGENTS_MD_FILENAME
try:
content = path.read_text("utf-8", errors="ignore")
stripped = content.strip()
return stripped if stripped else ""
except (FileNotFoundError, OSError):
return ""
def _collect_agents_md(
self, start: Path, stop: Path, *, stop_inclusive: bool
) -> list[tuple[Path, str]]:
"""Walk up from start toward stop, collecting non-empty AGENTS.md files.
Returns ``(directory, content)`` pairs ordered outermost-first.
When ``stop_inclusive`` is True the stop directory is included in the
walk; when False the walk stops before reaching it.
"""
if not start.is_relative_to(stop):
return []
docs: list[tuple[Path, str]] = []
current = start
while True:
if current == stop and not stop_inclusive:
break
path = current / AGENTS_MD_FILENAME
try:
content = path.read_text("utf-8", errors="ignore")
stripped = content.strip()
if stripped:
docs.append((current, stripped))
except (FileNotFoundError, OSError):
pass
if current == stop:
break
parent = current.parent
if parent == current: # fs-root safety
break
current = parent
docs.reverse() # outermost first
return docs
def find_subdirectory_agents_md(self, file_path: Path) -> list[tuple[Path, str]]:
"""Find AGENTS.md files between file_path's parent and cwd (exclusive of cwd).
For lazy injection when reading files in subdirectories below cwd.
Returns (directory, content) pairs, outermost first.
Does not overlap with load_project_docs() which covers cwd and above.
"""
workdir = self.trusted_workdir
if workdir is None:
return ""
for name in AGENTS_MD_FILENAMES:
path = workdir / name
try:
return path.read_text("utf-8", errors="ignore")[:max_bytes]
except (FileNotFoundError, OSError):
continue
return ""
return []
cwd = workdir.resolve()
try:
resolved = file_path.resolve()
except (ValueError, OSError):
return []
if not resolved.is_relative_to(cwd):
return []
start = resolved if resolved.is_dir() else resolved.parent
return self._collect_agents_md(start, cwd, stop_inclusive=False)
def load_project_docs(self) -> list[tuple[Path, str]]:
"""Walk up from cwd to the trust root, collecting AGENTS.md files.
Returns ``(directory, content)`` pairs ordered outermost-first
(trust root first, cwd last). Later entries take priority.
"""
workdir = self.trusted_workdir
if workdir is None:
return []
cwd = workdir.resolve()
trust_root = trusted_folders_manager.find_trust_root(cwd)
if trust_root is None:
return []
return self._collect_agents_md(cwd, trust_root, stop_inclusive=True)
_manager: HarnessFilesManager | None = None

View file

@ -7,8 +7,28 @@ import types
from typing import TYPE_CHECKING, NamedTuple, cast
import httpx
import mistralai
from mistralai.utils.retries import BackoffStrategy, RetryConfig
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
from mistralai.client.models import (
AssistantMessage,
AssistantMessageContent,
ChatCompletionRequestMessage,
ChatCompletionStreamRequestToolChoice,
FileChunk,
Function,
FunctionCall as MistralFunctionCall,
FunctionName,
SystemMessage,
TextChunk,
ThinkChunk,
Tool,
ToolCall as MistralToolCall,
ToolChoice,
ToolChoiceEnum,
ToolMessage,
UserMessage,
)
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
@ -35,35 +55,33 @@ class ParsedContent(NamedTuple):
class MistralMapper:
def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
def prepare_message(self, msg: LLMMessage) -> ChatCompletionRequestMessage:
match msg.role:
case Role.system:
return mistralai.SystemMessage(role="system", content=msg.content or "")
return SystemMessage(role="system", content=msg.content or "")
case Role.user:
return mistralai.UserMessage(role="user", content=msg.content)
return UserMessage(role="user", content=msg.content)
case Role.assistant:
content: mistralai.AssistantMessageContent
content: AssistantMessageContent
if msg.reasoning_content:
content = [
mistralai.ThinkChunk(
ThinkChunk(
type="thinking",
thinking=[
mistralai.TextChunk(
type="text", text=msg.reasoning_content
)
TextChunk(type="text", text=msg.reasoning_content)
],
),
mistralai.TextChunk(type="text", text=msg.content or ""),
TextChunk(type="text", text=msg.content or ""),
]
else:
content = msg.content or ""
return mistralai.AssistantMessage(
return AssistantMessage(
role="assistant",
content=content,
tool_calls=[
mistralai.ToolCall(
function=mistralai.FunctionCall(
MistralToolCall(
function=MistralFunctionCall(
name=tc.function.name or "",
arguments=tc.function.arguments or "",
),
@ -75,17 +93,17 @@ class MistralMapper:
],
)
case Role.tool:
return mistralai.ToolMessage(
return ToolMessage(
role="tool",
content=msg.content,
tool_call_id=msg.tool_call_id,
name=msg.name,
)
def prepare_tool(self, tool: AvailableTool) -> mistralai.Tool:
return mistralai.Tool(
def prepare_tool(self, tool: AvailableTool) -> Tool:
return Tool(
type="function",
function=mistralai.Function(
function=Function(
name=tool.function.name,
description=tool.function.description,
parameters=tool.function.parameters,
@ -94,16 +112,15 @@ class MistralMapper:
def prepare_tool_choice(
self, tool_choice: StrToolChoice | AvailableTool
) -> mistralai.ChatCompletionStreamRequestToolChoice:
) -> ChatCompletionStreamRequestToolChoice:
if isinstance(tool_choice, str):
return cast(mistralai.ToolChoiceEnum, tool_choice)
return cast(ToolChoiceEnum, tool_choice)
return mistralai.ToolChoice(
type="function",
function=mistralai.FunctionName(name=tool_choice.function.name),
return ToolChoice(
type="function", function=FunctionName(name=tool_choice.function.name)
)
def _extract_thinking_text(self, chunk: mistralai.ThinkChunk) -> str:
def _extract_thinking_text(self, chunk: ThinkChunk) -> str:
thinking_content = getattr(chunk, "thinking", None)
if not thinking_content:
return ""
@ -115,27 +132,25 @@ class MistralMapper:
parts.append(inner)
return "".join(parts)
def parse_content(
self, content: mistralai.AssistantMessageContent
) -> ParsedContent:
def parse_content(self, content: AssistantMessageContent) -> ParsedContent:
if isinstance(content, str):
return ParsedContent(content=content, reasoning_content=None)
concat_content = ""
concat_reasoning = ""
for chunk in content:
if isinstance(chunk, mistralai.FileChunk):
if isinstance(chunk, FileChunk):
continue
if isinstance(chunk, mistralai.TextChunk):
if isinstance(chunk, TextChunk):
concat_content += chunk.text
elif isinstance(chunk, mistralai.ThinkChunk):
elif isinstance(chunk, ThinkChunk):
concat_reasoning += self._extract_thinking_text(chunk)
return ParsedContent(
content=concat_content,
reasoning_content=concat_reasoning if concat_reasoning else None,
)
def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
def parse_tool_calls(self, tool_calls: list[MistralToolCall]) -> list[ToolCall]:
return [
ToolCall(
id=tool_call.id,
@ -153,7 +168,7 @@ class MistralMapper:
class MistralBackend:
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
self._client: mistralai.Mistral | None = None
self._client: Mistral | None = None
self._provider = provider
self._mapper = MistralMapper()
self._api_key = (
@ -208,15 +223,15 @@ class MistralBackend:
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
)
def _create_mistral_client(self) -> mistralai.Mistral:
return mistralai.Mistral(
def _create_mistral_client(self) -> Mistral:
return Mistral(
api_key=self._api_key,
server_url=self._server_url,
timeout_ms=int(self._timeout * 1000),
retry_config=self._retry_config,
)
def _get_client(self) -> mistralai.Mistral:
def _get_client(self) -> Mistral:
if self._client is None:
self._client = self._create_mistral_client()
return self._client
@ -273,7 +288,7 @@ class MistralBackend:
),
)
except mistralai.SDKError as e:
except SDKError as e:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,
@ -351,7 +366,7 @@ class MistralBackend:
),
)
except mistralai.SDKError as e:
except SDKError as e:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,

View file

@ -106,6 +106,13 @@ class ReasoningAdapter(APIAdapter):
return payload
def _strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
if msg.role != Role.assistant or not msg.reasoning_content:
return msg
return msg.model_copy(
update={"reasoning_content": None, "reasoning_signature": None}
)
def prepare_request( # noqa: PLR0913
self,
*,
@ -121,6 +128,8 @@ class ReasoningAdapter(APIAdapter):
thinking: str = "off",
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
if thinking == "off":
merged_messages = [self._strip_reasoning(msg) for msg in merged_messages]
converted_messages = [self._convert_message(msg) for msg in merged_messages]
payload = self._build_payload(

View file

@ -79,16 +79,14 @@ class PriceLimitMiddleware:
class AutoCompactMiddleware:
def __init__(self, threshold: int) -> None:
self.threshold = threshold
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if context.stats.context_tokens >= self.threshold:
threshold = context.config.get_active_model().auto_compact_threshold
if threshold > 0 and context.stats.context_tokens >= threshold:
return MiddlewareResult(
action=MiddlewareAction.COMPACT,
metadata={
"old_tokens": context.stats.context_tokens,
"threshold": self.threshold,
"threshold": threshold,
},
)
return MiddlewareResult()
@ -98,19 +96,16 @@ class AutoCompactMiddleware:
class ContextWarningMiddleware:
def __init__(
self, threshold_percent: float = 0.5, max_context: int | None = None
) -> None:
def __init__(self, threshold_percent: float = 0.5) -> None:
self.threshold_percent = threshold_percent
self.max_context = max_context
self.has_warned = False
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if self.has_warned:
return MiddlewareResult()
max_context = self.max_context
if max_context is None:
max_context = context.config.get_active_model().auto_compact_threshold
if max_context <= 0:
return MiddlewareResult()
if context.stats.context_tokens >= max_context * self.threshold_percent:

View file

@ -13,10 +13,10 @@ from vibe.core.paths._vibe_home import (
VIBE_HOME,
GlobalPath,
)
from vibe.core.paths.conventions import AGENTS_MD_FILENAMES
from vibe.core.paths.conventions import AGENTS_MD_FILENAME
__all__ = [
"AGENTS_MD_FILENAMES",
"AGENTS_MD_FILENAME",
"DEFAULT_TOOL_DIR",
"GLOBAL_ENV_FILE",
"HISTORY_FILE",

View file

@ -1,3 +1,3 @@
from __future__ import annotations
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
AGENTS_MD_FILENAME = "AGENTS.md"

View file

@ -14,16 +14,18 @@ class Prompt(StrEnum):
return (_PROMPTS_DIR / self.value).with_suffix(".md")
def read(self) -> str:
return self.path.read_text(encoding="utf-8").strip()
return self.path.read_text(encoding="utf-8", errors="ignore").strip()
class SystemPrompt(Prompt):
CLI = auto()
EXPLORE = auto()
TESTS = auto()
LEAN = auto()
class UtilityPrompt(Prompt):
AGENTS_DOC = auto()
COMPACT = auto()
DANGEROUS_DIRECTORY = auto()
PROJECT_CONTEXT = auto()

View file

@ -0,0 +1,5 @@
Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written. When both user-level and project-level instructions are present, project instructions take priority over user instructions. When multiple project-level AGENTS.md files are present, instructions closer to the working directory take priority. Each AGENTS.md applies to its own directory and all of its descendants within the project.
$sections
IMPORTANT: this context may or may not be relevant to your tasks. You should act on these guidelines if they are relevant to your task.

View file

@ -1,5 +1,5 @@
directoryStructure: Project context scanning has been disabled because {reason}. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
directoryStructure: Project context scanning has been disabled because $reason. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
Absolute path: {abs_path}
Absolute path: $abs_path
gitStatus: Use git tools to check repository status if needed.

166
vibe/core/prompts/lean.md Normal file
View file

@ -0,0 +1,166 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
Use markdown when appropriate. Communicate clearly to the user.
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
1. Find the matching file in your skill directories.
2. Read it with `read_file`.
3. Follow its instructions step by step. You are the executor.
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
Phase 1 — Orient
Before ANY action:
Restate the goal in one line.
Determine the task type:
Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
Identify constraints: language, framework, test setup, and any user restrictions on scope.
When given a complex, multi-file architectural task: summarize your understanding and wait for user confirmation. For targeted tasks, including writing specific Lean proofs or single-file bug fixes, do not wait. Plan internally and execute immediately.
Phase 2 — Plan
State your plan before writing code:
List files to edit and the specific modifications per file.
Multi-file modifications: numbered checklist. Single-file fix: one-line plan.
No time estimates. Concrete actions only.
Phase 3 — Execute & Verify
Apply modifications, then confirm they work:
Edit one logical unit at a time.
After each unit, verify: run tests, or read back the file to confirm the edit landed.
Never claim completion without verification — a passing test, correct read-back, or successful build.
Lean Rules
Create a New Package or Project
Usually, use the mathlib4 dependency. Run `lake +leanprover-community/mathlib4:lean-toolchain new <your_project_name> math` to create a new project with mathlib4 as a dependency.
Otherwise run `lake init <your_project_name>`.
Add External Dependencies
You can add external dependencies by adding to lakefile.toml, for example:
```
[[require]]
name = "mathlib"
git = "https://github.com/leanprover-community/mathlib4.git"
```
Whenever you create a new package or add a new external dependency, run `lake exe cache get` to download cache for them. Do not build before downloading all the necessary dependencies. Never manually edit `lake-manifest.json`, use `lake` commands to update it.
Work incrementally and in blocks. Make a plan before you take on a big project.
Imports
Put imports at the beginning of a file.
Compile a Package or a File
Before compiling or building for the first time, check if external dependencies are in the cache. If not, run `lake exe cache get`.
Run `lake build` to check the entire repository's correctness or `lake build <file>` for one file. Check lakefile.toml for build targets. Prefer `lake build <file>` while developing, it is a lot faster. To check a standalone Lean file which not tracked by lake, such as a test file, use `lake env lean <file>`.
Tactics
Make use of the `grind` tactic when possible if using Lean version >= 4.22.0. It is very powerful.
Debug
View the current goal and proof state by inserting the `trace_state` tactic before the line in question.
Complete the Work
When tasked with writing code or a Lean proof, do not stop until you find the complete working solution. Do not leave incomplete code, stubs, or use sorry in Lean unless the user explicitly instructs you to.
Hard Rules
Don't be Lazy
When the user asks you to perform something, be laser-focused and do not settle for easier things.
Never Commit
Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves.
Respect User Constraints
"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
Don't Remove What Wasn't Asked
If user asks to fix X, do not rewrite, delete, or restructure Y.
Don't Assert — Verify
If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
Break Loops
If approach isn't working after 2 attempts at the same region, STOP:
Re-read the code and error output.
Identify why it failed, not just what failed.
Choose a fundamentally different strategy.
If stuck, ask the user one specific question.
Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
After creating test files that are not going to be used once the task is complete, remember to remove them.
Response Format
No Noise
No greetings, outros, hedging, puffery, or tool narration.
Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
Never use: "robust", "seamless", "elegant", "powerful", "flexible"
No unsolicited tutorials. Do not explain concepts the user clearly knows.
Structure First
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
For modification tasks:
file_path:line_number
langcode
Prefer Brevity
State only what's necessary to complete the task. Code + file reference > explanation.
If your response exceeds 300 words, remove explanations the user didn't request.
For investigate tasks:
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
request → auth.verify() → permissions.check() → handler
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
BAD: "The authentication flow works by first checking the token…"
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
Visual Formats
Before responding with structural data, choose the right format:
BAD: Bullet lists for hierarchy/tree
GOOD: ASCII tree (├──/└──)
BAD: Prose or bullet lists for comparisons/config/options
GOOD: Markdown table
BAD: Prose for Flows/pipelines
GOOD: → A → B → C diagrams
Interaction Design
After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
Good: "Apply this fix to the other 3 endpoints?"
Good: "Two approaches: (a) migration, (b) recreate table. Which?"
Bad: "Does this look good?", "Anything else?", "Let me know"
If unambiguous and complete, end with the result.
Length
Default to minimal prose. Your conversational text should be <100 words. However, this length restriction does NOT apply to code, scripts, or Lean proofs. Code and proofs must always be fully written out and functional, no matter how many lines they require.
Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
Code Modifications (Change tasks)
Read First, Edit Second
Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
Minimal, Focused Changes
Only modify what was requested. No extra features, abstractions, or speculative error handling.
Match existing style: indentation, naming, comment density, error handling.
When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
Security
Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
Code References
Cite as file_path:line_number.
Professional Conduct
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming.
Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
No over-the-top validation.
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.

View file

@ -1,4 +1,4 @@
Absolute path: {abs_path}
Absolute path: $abs_path
gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
{git_status}
$git_status

View file

@ -3,11 +3,13 @@ from __future__ import annotations
import html
import os
from pathlib import Path
from string import Template
import subprocess
import sys
from typing import TYPE_CHECKING
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.paths import VIBE_HOME
from vibe.core.prompts import UtilityPrompt
from vibe.core.utils import is_dangerous_directory, is_windows
@ -138,7 +140,9 @@ class ProjectContextProvider:
git_status = self.get_git_status()
template = UtilityPrompt.PROJECT_CONTEXT.read()
return template.format(abs_path=self.root_path, git_status=git_status)
return Template(template).safe_substitute(
abs_path=str(self.root_path), git_status=git_status
)
def _get_platform_name() -> str:
@ -275,7 +279,7 @@ def get_universal_system_prompt(
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:
template = UtilityPrompt.DANGEROUS_DIRECTORY.read()
context = template.format(
context = Template(template).safe_substitute(
reason=reason.lower(), abs_path=Path(".").resolve()
)
else:
@ -285,10 +289,25 @@ def get_universal_system_prompt(
sections.append(context)
project_doc = get_harness_files_manager().load_project_doc(
config.project_context.max_doc_bytes
)
if project_doc.strip():
sections.append(project_doc)
mgr = get_harness_files_manager()
user_doc = mgr.load_user_doc()
project_docs = mgr.load_project_docs()
doc_sections: list[str] = []
if user_doc.strip():
doc_sections.append(
f"## User instructions\n\nContents of {VIBE_HOME.path}/AGENTS.md (user-level instructions):\n\n{user_doc.strip()}"
)
if project_docs:
doc_sections.append("## Project instructions (checked into the codebase)")
for doc_dir, doc_content in project_docs:
doc_sections.append(
f"Contents of {doc_dir}/AGENTS.md:\n\n{doc_content.strip()}"
)
if doc_sections:
template = UtilityPrompt.AGENTS_DOC.read()
sections.append(
Template(template).safe_substitute(sections="\n\n".join(doc_sections))
)
return "\n\n".join(sections)

View file

@ -348,3 +348,12 @@ class BaseTool[
Override in subclasses for domain-specific rules (e.g. workdir checks).
"""
return None
def get_result_extra(self, result: ToolResult) -> str | None:
"""Optional extra context appended to the result text sent to the LLM.
Override in subclasses to inject contextual information alongside
tool results (e.g. directory-level instructions discovered during
file reads). The default returns ``None`` (no annotation).
"""
return None

View file

@ -116,7 +116,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
def _get_default_allowlist() -> list[str]:
common = ["echo", "find", "git diff", "git log", "git status", "tree", "whoami"]
common = ["echo", "git diff", "git log", "git status", "tree", "whoami"]
if is_windows():
return common + ["dir", "findstr", "more", "type", "ver", "where"]
@ -225,9 +225,6 @@ class Bash(
return "Running command"
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
if is_windows():
return None
command_parts = _extract_commands(args.command)
if not command_parts:
return None

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
import anyio
from pydantic import BaseModel, Field
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -18,6 +19,7 @@ from vibe.core.tools.base import (
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
@ -57,8 +59,12 @@ class ReadFileToolConfig(BaseToolConfig):
)
class ReadFileState(BaseToolState):
injected_agents_md: set[str] = Field(default_factory=set)
class ReadFile(
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, BaseToolState],
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
ToolUIData[ReadFileArgs, ReadFileResult],
):
description: ClassVar[str] = (
@ -89,6 +95,27 @@ class ReadFile(
config_permission=self.config.permission,
)
def get_result_extra(self, result: ReadFileResult) -> str | None:
try:
mgr = get_harness_files_manager()
except RuntimeError:
return None
docs = mgr.find_subdirectory_agents_md(Path(result.path))
new_docs = [
(d, c)
for d, c in docs
if str(d.resolve()) not in self.state.injected_agents_md
]
if not new_docs:
return None
for d, _ in new_docs:
self.state.injected_agents_md.add(str(d.resolve()))
sections = [
f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
for d, c in new_docs
]
return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n</{VIBE_WARNING_TAG}>"
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
self._validate_inputs(args)

View file

@ -4,7 +4,14 @@ from collections.abc import AsyncGenerator
import os
from typing import TYPE_CHECKING, ClassVar, final
import mistralai
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
from mistralai.client.models import (
ConversationResponse,
MessageOutputEntry,
TextChunk,
ToolReferenceChunk,
)
from pydantic import BaseModel, Field
from vibe.core.config import Backend
@ -67,7 +74,7 @@ class WebSearch(
if not api_key:
raise ToolError("MISTRAL_API_KEY environment variable not set.")
client = mistralai.Mistral(
client = Mistral(
api_key=api_key,
server_url=self._resolve_server_url(ctx),
timeout_ms=self.config.timeout * 1000,
@ -85,7 +92,7 @@ class WebSearch(
yield self._parse_response(response)
except mistralai.SDKError as exc:
except SDKError as exc:
raise ToolError(f"Mistral API error: {exc}") from exc
def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None:
@ -96,19 +103,17 @@ class WebSearch(
return get_server_url_from_api_base(provider.api_base)
return None
def _parse_response(
self, response: mistralai.ConversationResponse
) -> WebSearchResult:
def _parse_response(self, response: ConversationResponse) -> WebSearchResult:
text_parts: list[str] = []
sources: dict[str, WebSearchSource] = {}
for entry in response.outputs:
if not isinstance(entry, mistralai.MessageOutputEntry):
if not isinstance(entry, MessageOutputEntry):
continue
for chunk in entry.content:
if isinstance(chunk, mistralai.TextChunk):
if isinstance(chunk, TextChunk):
text_parts.append(chunk.text)
elif isinstance(chunk, mistralai.ToolReferenceChunk) and chunk.url:
elif isinstance(chunk, ToolReferenceChunk) and chunk.url:
if chunk.url not in sources:
sources[chunk.url] = WebSearchSource(
title=chunk.title, url=chunk.url

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from vibe.core.transcribe.factory import make_transcribe_client
from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
from vibe.core.transcribe.transcribe_client_port import (
TranscribeClientPort,
TranscribeDone,
TranscribeError,
TranscribeEvent,
TranscribeSessionCreated,
TranscribeTextDelta,
)
__all__ = [
"MistralTranscribeClient",
"TranscribeClientPort",
"TranscribeDone",
"TranscribeError",
"TranscribeEvent",
"TranscribeSessionCreated",
"TranscribeTextDelta",
"make_transcribe_client",
]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from vibe.core.config import (
TranscribeClient,
TranscribeModelConfig,
TranscribeProviderConfig,
)
from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
TRANSCRIBE_CLIENT_MAP: dict[TranscribeClient, type[TranscribeClientPort]] = {
TranscribeClient.MISTRAL: MistralTranscribeClient
}
def make_transcribe_client(
provider: TranscribeProviderConfig, model: TranscribeModelConfig
) -> TranscribeClientPort:
return TRANSCRIBE_CLIENT_MAP[provider.client](provider=provider, model=model)

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import os
from mistralai.client import Mistral
from mistralai.client.models import (
AudioFormat,
RealtimeTranscriptionError,
RealtimeTranscriptionSessionCreated,
TranscriptionStreamDone,
TranscriptionStreamTextDelta,
)
from mistralai.extra.realtime import UnknownRealtimeEvent
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
from vibe.core.transcribe.transcribe_client_port import (
TranscribeDone,
TranscribeError,
TranscribeEvent,
TranscribeSessionCreated,
TranscribeTextDelta,
)
class MistralTranscribeClient:
def __init__(
self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
) -> None:
self._api_key = os.getenv(provider.api_key_env_var, "")
self._server_url = provider.api_base
self._model_name = model.name
self._audio_format = AudioFormat(
encoding=model.encoding, sample_rate=model.sample_rate
)
self._target_streaming_delay_ms = model.target_streaming_delay_ms
self._client: Mistral | None = None
def _get_client(self) -> Mistral:
if self._client is None:
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
return self._client
async def transcribe(
self, audio_stream: AsyncIterator[bytes]
) -> AsyncIterator[TranscribeEvent]:
client = self._get_client()
async for event in client.audio.realtime.transcribe_stream(
audio_stream=audio_stream,
model=self._model_name,
audio_format=self._audio_format,
target_streaming_delay_ms=self._target_streaming_delay_ms,
):
if isinstance(event, RealtimeTranscriptionSessionCreated):
yield TranscribeSessionCreated()
elif isinstance(event, TranscriptionStreamTextDelta):
yield TranscribeTextDelta(text=event.text)
elif isinstance(event, TranscriptionStreamDone):
yield TranscribeDone()
elif isinstance(event, RealtimeTranscriptionError):
yield TranscribeError(message=str(event.error.message))
elif isinstance(event, UnknownRealtimeEvent):
continue

View file

@ -0,0 +1,42 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Protocol
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
@dataclass(frozen=True, slots=True)
class TranscribeSessionCreated:
pass
@dataclass(frozen=True, slots=True)
class TranscribeTextDelta:
text: str
@dataclass(frozen=True, slots=True)
class TranscribeDone:
pass
@dataclass(frozen=True, slots=True)
class TranscribeError:
message: str
TranscribeEvent = (
TranscribeSessionCreated | TranscribeTextDelta | TranscribeDone | TranscribeError
)
class TranscribeClientPort(Protocol):
def __init__(
self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
) -> None: ...
def transcribe(
self, audio_stream: AsyncIterator[bytes]
) -> AsyncIterator[TranscribeEvent]: ...

View file

@ -6,14 +6,14 @@ import tomllib
import tomli_w
from vibe.core.paths import (
AGENTS_MD_FILENAMES,
AGENTS_MD_FILENAME,
TRUSTED_FOLDERS_FILE,
walk_local_config_dirs_all,
)
def has_agents_md_file(path: Path) -> bool:
return any((path / name).exists() for name in AGENTS_MD_FILENAMES)
return (path / AGENTS_MD_FILENAME).exists()
def has_trustable_content(path: Path) -> bool:
@ -60,11 +60,35 @@ class TrustedFoldersManager:
pass
def is_trusted(self, path: Path) -> bool | None:
normalized = self._normalize_path(path)
if normalized in self._trusted:
return True
if normalized in self._untrusted:
return False
"""Check trust walking up from *path* to filesystem root.
The first ancestor (or *path* itself) found in either the trusted
or untrusted list wins. Returns ``None`` when no decision exists.
"""
current = Path(self._normalize_path(path))
while True:
s = str(current)
if s in self._trusted:
return True
if s in self._untrusted:
return False
parent = current.parent
if parent == current:
break
current = parent
return None
def find_trust_root(self, path: Path) -> Path | None:
"""Return the closest ancestor (or *path* itself) explicitly in the trusted list."""
current = Path(self._normalize_path(path))
while True:
s = str(current)
if s in self._trusted:
return current
parent = current.parent
if parent == current:
break
current = parent
return None
def add_trusted(self, path: Path) -> None:

Some files were not shown because too many files have changed in this diff Show more