Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Quentin <torroba.q@gmail.com>
Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-23 18:45:21 +01:00 committed by GitHub
parent 5103019b01
commit eb580209d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
180 changed files with 11136 additions and 1030 deletions

View file

@ -759,6 +759,42 @@ class TestToolCallStructure:
)
assert rejected_tool_call is not None
@pytest.mark.asyncio
async def test_permission_options_include_granular_labels_for_bash(
self, vibe_home_dir: Path
) -> None:
"""Bash 'npm install foo' should produce granular labels in permission options."""
custom_results = [
mock_llm_chunk(
tool_calls=[
ToolCall(
function=FunctionCall(
name="bash", arguments='{"command":"npm install foo"}'
),
type="function",
index=0,
)
]
),
mock_llm_chunk(content="Done"),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
permission_request = await start_session_with_request_permission(
process, "Run npm install foo"
)
assert permission_request.params is not None
# Verify "Allow always" option includes the pattern label
allow_always = next(
o
for o in permission_request.params.options
if o.option_id == ToolOption.ALLOW_ALWAYS
)
assert "npm install *" in allow_always.name
@pytest.mark.skip(reason="Long running tool call updates are not implemented yet")
@pytest.mark.asyncio
async def test_tool_call_in_progress_update_structure(

View file

@ -0,0 +1,254 @@
from __future__ import annotations
import asyncio
import asyncio.subprocess as aio_subprocess
import contextlib
import io
import os
from pathlib import Path
from typing import Any, cast
from acp import PROTOCOL_VERSION, Client, RequestError, connect_to_agent
from acp.schema import ClientCapabilities, Implementation
import pexpect
import pytest
from tests import TESTS_ROOT
from tests.e2e.common import ansi_tolerant_pattern
class _AcpSmokeClient(Client):
def on_connect(self, conn: Any) -> None:
pass
async def request_permission(self, *args: Any, **kwargs: Any) -> Any:
msg = "session/request_permission"
raise RequestError.method_not_found(msg)
async def write_text_file(self, *args: Any, **kwargs: Any) -> Any:
msg = "fs/write_text_file"
raise RequestError.method_not_found(msg)
async def read_text_file(self, *args: Any, **kwargs: Any) -> Any:
msg = "fs/read_text_file"
raise RequestError.method_not_found(msg)
async def create_terminal(self, *args: Any, **kwargs: Any) -> Any:
msg = "terminal/create"
raise RequestError.method_not_found(msg)
async def terminal_output(self, *args: Any, **kwargs: Any) -> Any:
msg = "terminal/output"
raise RequestError.method_not_found(msg)
async def release_terminal(self, *args: Any, **kwargs: Any) -> Any:
msg = "terminal/release"
raise RequestError.method_not_found(msg)
async def wait_for_terminal_exit(self, *args: Any, **kwargs: Any) -> Any:
msg = "terminal/wait_for_exit"
raise RequestError.method_not_found(msg)
async def kill_terminal(self, *args: Any, **kwargs: Any) -> Any:
msg = "terminal/kill"
raise RequestError.method_not_found(msg)
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
_ = params
raise RequestError.method_not_found(method)
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
_ = params
raise RequestError.method_not_found(method)
async def session_update(self, *_args: Any, **_kwargs: Any) -> None:
pass
@pytest.fixture
def vibe_home_dir(tmp_path: Path) -> Path:
return tmp_path / ".vibe"
async def _spawn_vibe_acp(env: dict[str, str]) -> asyncio.subprocess.Process:
return await asyncio.create_subprocess_exec(
"uv",
"run",
"vibe-acp",
stdin=aio_subprocess.PIPE,
stdout=aio_subprocess.PIPE,
stderr=aio_subprocess.PIPE,
cwd=TESTS_ROOT.parent,
env=env,
)
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
if proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
proc.terminate()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(proc.wait(), timeout=5)
if proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
proc.kill()
await proc.wait()
def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["VIBE_HOME"] = str(vibe_home_dir)
if include_api_key:
env["MISTRAL_API_KEY"] = "mock"
else:
env.pop("MISTRAL_API_KEY", None)
return env
def _build_client_capabilities(*, terminal_auth: bool = False) -> ClientCapabilities:
if not terminal_auth:
return ClientCapabilities()
return ClientCapabilities(field_meta={"terminal-auth": True})
async def _connect_and_initialize(
*, vibe_home_dir: Path, include_api_key: bool, terminal_auth: bool = False
) -> tuple[asyncio.subprocess.Process, Any, Any]:
env = _build_env(vibe_home_dir, include_api_key=include_api_key)
proc = await _spawn_vibe_acp(env)
try:
assert proc.stdin is not None
assert proc.stdout is not None
conn = connect_to_agent(_AcpSmokeClient(), proc.stdin, proc.stdout)
initialize_response = await asyncio.wait_for(
conn.initialize(
protocol_version=PROTOCOL_VERSION,
client_capabilities=_build_client_capabilities(
terminal_auth=terminal_auth
),
client_info=Implementation(
name="pytest-smoke", title="Pytest Smoke", version="0.0.0"
),
),
timeout=10,
)
except Exception:
await _terminate_process(proc)
raise
return proc, initialize_response, conn
@pytest.mark.asyncio
async def test_vibe_acp_initialize_and_new_session(vibe_home_dir: Path) -> None:
proc, initialize_response, conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=True
)
try:
assert initialize_response.protocol_version == PROTOCOL_VERSION
assert initialize_response.agent_info.name == "@mistralai/mistral-vibe"
assert initialize_response.agent_info.title == "Mistral Vibe"
session = await asyncio.wait_for(
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
)
assert session.session_id
finally:
await _terminate_process(proc)
@pytest.mark.asyncio
async def test_vibe_acp_bootstraps_default_files(vibe_home_dir: Path) -> None:
proc, _initialize_response, conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=True
)
try:
await asyncio.wait_for(
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
)
finally:
await _terminate_process(proc)
assert (vibe_home_dir / "config.toml").is_file()
assert (vibe_home_dir / "vibehistory").is_file()
@pytest.mark.asyncio
async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
vibe_home_dir: Path,
) -> None:
proc, initialize_response, conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=True, terminal_auth=True
)
try:
assert initialize_response.auth_methods is not None
assert len(initialize_response.auth_methods) == 1
auth_method = initialize_response.auth_methods[0]
assert auth_method.id == "vibe-setup"
assert auth_method.field_meta is not None
terminal_auth = auth_method.field_meta["terminal-auth"]
assert terminal_auth["label"] == "Mistral Vibe Setup"
assert terminal_auth["command"]
assert terminal_auth["args"]
finally:
await _terminate_process(proc)
@pytest.mark.timeout(15)
def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
vibe_home_dir: Path,
) -> None:
env = cast("os._Environ[str]", _build_env(vibe_home_dir, include_api_key=False))
env["TERM"] = "xterm-256color"
captured = io.StringIO()
child = pexpect.spawn(
"uv",
["run", "vibe-acp", "--setup"],
cwd=str(TESTS_ROOT.parent),
env=env,
encoding="utf-8",
timeout=10,
dimensions=(36, 120),
)
child.logfile_read = captured
try:
child.expect(ansi_tolerant_pattern("Welcome to Mistral Vibe"), timeout=10)
child.sendcontrol("c")
child.expect(pexpect.EOF, timeout=10)
finally:
if child.isalive():
child.terminate(force=True)
if not child.closed:
child.close()
output = captured.getvalue()
assert "Setup cancelled" in output
@pytest.mark.asyncio
async def test_vibe_acp_new_session_fails_without_api_key(vibe_home_dir: Path) -> None:
proc, _initialize_response, conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=False
)
try:
with pytest.raises(RequestError, match="Missing API key"):
await asyncio.wait_for(
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
)
finally:
await _terminate_process(proc)

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

View file

@ -100,13 +100,11 @@ class TestLoadSession:
assert response.config_options is not None
assert len(response.config_options) == 2
assert response.config_options[0].root.id == "mode"
assert response.config_options[0].root.category == "mode"
assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT
assert len(response.config_options[0].root.options) == 5
mode_option_values = {
opt.value for opt in response.config_options[0].root.options
}
assert response.config_options[0].id == "mode"
assert response.config_options[0].category == "mode"
assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT
assert len(response.config_options[0].options) == 5
mode_option_values = {opt.value for opt in response.config_options[0].options}
assert mode_option_values == {
BuiltinAgentName.DEFAULT,
BuiltinAgentName.CHAT,
@ -114,13 +112,11 @@ class TestLoadSession:
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
}
assert response.config_options[1].root.id == "model"
assert response.config_options[1].root.category == "model"
assert response.config_options[1].root.current_value == "devstral-latest"
assert len(response.config_options[1].root.options) == 2
model_option_values = {
opt.value for opt in response.config_options[1].root.options
}
assert response.config_options[1].id == "model"
assert response.config_options[1].category == "model"
assert response.config_options[1].current_value == "devstral-latest"
assert len(response.config_options[1].options) == 2
model_option_values = {opt.value for opt in response.config_options[1].options}
assert model_option_values == {"devstral-latest", "devstral-small"}
@pytest.mark.asyncio

View file

@ -103,11 +103,11 @@ class TestACPNewSession:
# Mode config option
mode_config = session_response.config_options[0]
assert mode_config.root.id == "mode"
assert mode_config.root.category == "mode"
assert mode_config.root.current_value == BuiltinAgentName.DEFAULT
assert len(mode_config.root.options) == 5
mode_option_values = {opt.value for opt in mode_config.root.options}
assert mode_config.id == "mode"
assert mode_config.category == "mode"
assert mode_config.current_value == BuiltinAgentName.DEFAULT
assert len(mode_config.options) == 5
mode_option_values = {opt.value for opt in mode_config.options}
assert mode_option_values == {
BuiltinAgentName.DEFAULT,
BuiltinAgentName.CHAT,
@ -118,11 +118,11 @@ class TestACPNewSession:
# Model config option
model_config = session_response.config_options[1]
assert model_config.root.id == "model"
assert model_config.root.category == "model"
assert model_config.root.current_value == "devstral-latest"
assert len(model_config.root.options) == 2
model_option_values = {opt.value for opt in model_config.root.options}
assert model_config.id == "model"
assert model_config.category == "model"
assert model_config.current_value == "devstral-latest"
assert len(model_config.options) == 2
model_option_values = {opt.value for opt in model_config.options}
assert model_option_values == {"devstral-latest", "devstral-small"}
@pytest.mark.skip(reason="TODO: Fix this test")

View file

@ -83,8 +83,8 @@ class TestACPSetConfigOptionMode:
# Verify config_options reflect the new state
mode_config = response.config_options[0]
assert mode_config.root.id == "mode"
assert mode_config.root.current_value == BuiltinAgentName.AUTO_APPROVE
assert mode_config.id == "mode"
assert mode_config.current_value == BuiltinAgentName.AUTO_APPROVE
@pytest.mark.asyncio
async def test_set_config_option_mode_to_plan(
@ -133,8 +133,8 @@ class TestACPSetConfigOptionMode:
) # Chat mode auto-approves read-only tools
mode_config = response.config_options[0]
assert mode_config.root.id == "mode"
assert mode_config.root.current_value == BuiltinAgentName.CHAT
assert mode_config.id == "mode"
assert mode_config.current_value == BuiltinAgentName.CHAT
@pytest.mark.asyncio
async def test_set_config_option_mode_invalid_returns_none(
@ -205,8 +205,8 @@ class TestACPSetConfigOptionModel:
# Verify config_options reflect the new state
model_config = response.config_options[1]
assert model_config.root.id == "model"
assert model_config.root.current_value == "devstral-small"
assert model_config.id == "model"
assert model_config.current_value == "devstral-small"
@pytest.mark.asyncio
async def test_set_config_option_model_invalid_returns_none(

View file

@ -0,0 +1,254 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from unittest.mock import patch
from acp.schema import TextContentBlock, UsageUpdate
import pytest
from tests.acp.conftest import _create_acp_agent
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
def _make_backend(prompt_tokens: int = 100, completion_tokens: int = 50) -> FakeBackend:
return FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(
prompt_tokens=prompt_tokens, completion_tokens=completion_tokens
),
)
)
def _make_acp_agent(backend: FakeBackend) -> VibeAcpAgentLoop:
config = build_test_vibe_config()
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self._base_config = config
self.agent_manager.invalidate_config()
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
return _create_acp_agent()
def _get_fake_client(agent: VibeAcpAgentLoop) -> FakeClient:
return agent.client # type: ignore[return-value]
def _get_usage_updates(client: FakeClient) -> list[UsageUpdate]:
return [
update.update
for update in client._session_updates
if isinstance(update.update, UsageUpdate)
]
class TestPromptResponseUsage:
@pytest.mark.asyncio
async def test_prompt_returns_usage_in_response(self) -> None:
agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50))
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
response = await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
assert response.usage is not None
assert response.usage.input_tokens == 100
assert response.usage.output_tokens == 50
assert response.usage.total_tokens == 150
@pytest.mark.asyncio
async def test_prompt_usage_optional_fields_are_none(self) -> None:
agent = _make_acp_agent(_make_backend())
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
response = await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
assert response.usage is not None
assert response.usage.thought_tokens is None
assert response.usage.cached_read_tokens is None
assert response.usage.cached_write_tokens is None
@pytest.mark.asyncio
async def test_prompt_usage_accumulates_across_turns(self) -> None:
backend = _make_backend(prompt_tokens=100, completion_tokens=50)
agent = _make_acp_agent(backend)
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
first = await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
second = await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello again")],
)
assert first.usage is not None
assert second.usage is not None
# Second turn should have strictly more cumulative tokens
assert second.usage.input_tokens > first.usage.input_tokens
assert second.usage.output_tokens > first.usage.output_tokens
assert second.usage.total_tokens > first.usage.total_tokens
class TestUsageUpdateNotification:
@pytest.mark.asyncio
async def test_prompt_sends_usage_update(self) -> None:
agent = _make_acp_agent(_make_backend())
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
await asyncio.sleep(0)
usage_updates = _get_usage_updates(_get_fake_client(agent))
assert len(usage_updates) == 1
assert usage_updates[0].session_update == "usage_update"
@pytest.mark.asyncio
async def test_usage_update_contains_context_window_info(self) -> None:
agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50))
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
await asyncio.sleep(0)
usage_updates = _get_usage_updates(_get_fake_client(agent))
assert len(usage_updates) == 1
assert usage_updates[0].size > 0
assert usage_updates[0].used > 0
@pytest.mark.asyncio
async def test_usage_update_contains_cost_when_pricing_set(self) -> None:
agent = _make_acp_agent(
_make_backend(prompt_tokens=1_000_000, completion_tokens=500_000)
)
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
# Set pricing directly on the session stats (config loading uses fixture defaults)
acp_session = agent.sessions[session.session_id]
acp_session.agent_loop.stats.update_pricing(input_price=0.4, output_price=2.0)
await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
await asyncio.sleep(0)
usage_updates = _get_usage_updates(_get_fake_client(agent))
assert len(usage_updates) == 1
cost = usage_updates[0].cost
assert cost is not None
assert cost.currency == "USD"
assert cost.amount > 0
@pytest.mark.asyncio
async def test_usage_update_no_cost_when_zero_pricing(self) -> None:
agent = _make_acp_agent(_make_backend())
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
await asyncio.sleep(0)
usage_updates = _get_usage_updates(_get_fake_client(agent))
assert len(usage_updates) == 1
assert usage_updates[0].cost is None
@pytest.mark.asyncio
async def test_usage_update_sent_per_prompt(self) -> None:
backend = _make_backend()
agent = _make_acp_agent(backend)
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello")],
)
await asyncio.sleep(0)
await agent.prompt(
session_id=session.session_id,
prompt=[TextContentBlock(type="text", text="Hello again")],
)
await asyncio.sleep(0)
usage_updates = _get_usage_updates(_get_fake_client(agent))
assert len(usage_updates) == 2
class TestLoadSessionUsageUpdate:
def _make_session_dir(self, tmp_path: Path, session_id: str, cwd: str) -> Path:
session_folder = tmp_path / f"session_20240101_120000_{session_id[:8]}"
session_folder.mkdir()
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w") as f:
f.write(json.dumps({"role": "user", "content": "Hello"}) + "\n")
meta = {
"session_id": session_id,
"start_time": "2024-01-01T12:00:00Z",
"end_time": "2024-01-01T12:05:00Z",
"environment": {"working_directory": cwd},
}
with (session_folder / "meta.json").open("w") as f:
json.dump(meta, f)
return session_folder
def _make_agent_with_session_logging(
self, backend: FakeBackend, session_dir: Path
) -> VibeAcpAgentLoop:
session_config = SessionLoggingConfig(
save_dir=str(session_dir), session_prefix="session", enabled=True
)
config = build_test_vibe_config(session_logging=session_config)
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self._base_config = config
self.agent_manager.invalidate_config()
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
agent = _create_acp_agent()
patch.object(agent, "_load_config", return_value=config).start()
return agent
@pytest.mark.asyncio
async def test_load_session_sends_usage_update(self, tmp_path: Path) -> None:
backend = _make_backend()
agent = self._make_agent_with_session_logging(backend, tmp_path)
session_id = "test-session-load-usage"
self._make_session_dir(tmp_path, session_id, str(Path.cwd()))
await agent.load_session(cwd=str(Path.cwd()), session_id=session_id)
await asyncio.sleep(0)
client = _get_fake_client(agent)
usage_updates = _get_usage_updates(client)
assert len(usage_updates) == 1
assert usage_updates[0].session_update == "usage_update"
assert usage_updates[0].size > 0

View file

@ -1,8 +1,14 @@
from __future__ import annotations
from vibe.acp.utils import get_proxy_help_text
from vibe.acp.utils import (
TOOL_OPTIONS,
ToolOption,
build_permission_options,
get_proxy_help_text,
)
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
def _write_env_file(content: str) -> None:
@ -53,3 +59,65 @@ class TestGetProxyHelpText:
assert "HTTP_PROXY=http://proxy:8080" in result
assert "HTTPS_PROXY=" not in result
class TestBuildPermissionOptions:
def test_no_permissions_returns_default_options(self) -> None:
result = build_permission_options(None)
assert result is TOOL_OPTIONS
def test_empty_list_returns_default_options(self) -> None:
result = build_permission_options([])
assert result is TOOL_OPTIONS
def test_with_permissions_includes_labels_in_allow_always(self) -> None:
permissions = [
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
]
result = build_permission_options(permissions)
assert len(result) == 3
allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS)
assert "npm install *" in allow_always.name
assert "session" in allow_always.name.lower()
def test_allow_always_has_field_meta(self) -> None:
permissions = [
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="mkdir foo",
session_pattern="mkdir *",
label="mkdir *",
)
]
result = build_permission_options(permissions)
allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS)
assert allow_always.field_meta is not None
assert "required_permissions" in allow_always.field_meta
meta_perms = allow_always.field_meta["required_permissions"]
assert len(meta_perms) == 1
assert meta_perms[0]["session_pattern"] == "mkdir *"
def test_allow_once_and_reject_unchanged(self) -> None:
permissions = [
RequiredPermission(
scope=PermissionScope.URL_PATTERN,
invocation_pattern="example.com",
session_pattern="example.com",
label="fetching from example.com",
)
]
result = build_permission_options(permissions)
allow_once = next(o for o in result if o.option_id == ToolOption.ALLOW_ONCE)
reject_once = next(o for o in result if o.option_id == ToolOption.REJECT_ONCE)
assert allow_once.name == "Allow once"
assert reject_once.name == "Reject once"
assert allow_once.field_meta is None
assert reject_once.field_meta is None

View file

@ -0,0 +1,206 @@
from __future__ import annotations
import io
import struct
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_player.audio_player import AudioPlayer
from vibe.core.audio_player.audio_player_port import (
AlreadyPlayingError,
AudioBackendUnavailableError,
AudioFormat,
NoAudioOutputDeviceError,
UnsupportedAudioFormatError,
)
def _make_wav_bytes(
n_frames: int = 1024, sample_rate: int = 48_000, channels: int = 1
) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(
struct.pack(f"<{n_frames * channels}h", *([1000] * n_frames * channels))
)
return buf.getvalue()
def _get_callback(mock_stream_cls: MagicMock):
return mock_stream_cls.call_args.kwargs["callback"]
def _get_finished_callback(mock_stream_cls: MagicMock):
return mock_stream_cls.call_args.kwargs["finished_callback"]
class TestAudioPlayerInitialState:
def test_not_playing(self) -> None:
player = AudioPlayer()
assert player.is_playing is False
class TestPlayback:
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_play_sets_playing_state(self, mock_stream_cls: MagicMock) -> None:
player = AudioPlayer()
player.play(_make_wav_bytes(), AudioFormat.WAV)
assert player.is_playing is True
mock_stream_cls.return_value.start.assert_called_once()
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_play_when_already_playing_raises(self, mock_stream_cls: MagicMock) -> None:
player = AudioPlayer()
player.play(_make_wav_bytes(), AudioFormat.WAV)
with pytest.raises(AlreadyPlayingError):
player.play(_make_wav_bytes(), AudioFormat.WAV)
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_callback_feeds_audio_data(self, mock_stream_cls: MagicMock) -> None:
wav_data = _make_wav_bytes(n_frames=512)
player = AudioPlayer()
player.play(wav_data, AudioFormat.WAV)
callback = _get_callback(mock_stream_cls)
outdata = bytearray(512 * 2) # 512 frames * 2 bytes per sample
callback(outdata, 512, {}, sd.CallbackFlags())
assert outdata != bytearray(512 * 2)
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_callback_pads_silence_at_end(self, mock_stream_cls: MagicMock) -> None:
wav_data = _make_wav_bytes(n_frames=256)
player = AudioPlayer()
player.play(wav_data, AudioFormat.WAV)
callback = _get_callback(mock_stream_cls)
# First callback consumes all 256 frames
outdata1 = bytearray(256 * 2)
callback(outdata1, 256, {}, sd.CallbackFlags())
# Second callback should raise CallbackStop (no data left)
outdata2 = bytearray(256 * 2)
with pytest.raises(sd.CallbackStop):
callback(outdata2, 256, {}, sd.CallbackFlags())
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_on_finished_called_after_natural_completion(
self, mock_stream_cls: MagicMock
) -> None:
finished = []
player = AudioPlayer()
player.play(
_make_wav_bytes(),
AudioFormat.WAV,
on_finished=lambda: finished.append(True),
)
finished_callback = _get_finished_callback(mock_stream_cls)
finished_callback()
assert player.is_playing is False
assert len(finished) == 1
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_can_play_multiple_times(self, mock_stream_cls: MagicMock) -> None:
player = AudioPlayer()
player.play(_make_wav_bytes(), AudioFormat.WAV)
finished_callback = _get_finished_callback(mock_stream_cls)
finished_callback()
assert player.is_playing is False
player.play(_make_wav_bytes(), AudioFormat.WAV)
assert player.is_playing is True
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_creates_stream_with_correct_params(
self, mock_stream_cls: MagicMock
) -> None:
wav_data = _make_wav_bytes(sample_rate=24_000, channels=1)
player = AudioPlayer()
player.play(wav_data, AudioFormat.WAV)
call_kwargs = mock_stream_cls.call_args.kwargs
assert call_kwargs["samplerate"] == 24_000
assert call_kwargs["channels"] == 1
assert call_kwargs["dtype"] == "int16"
class TestStop:
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_stop_closes_stream(self, mock_stream_cls: MagicMock) -> None:
player = AudioPlayer()
player.play(_make_wav_bytes(), AudioFormat.WAV)
player.stop()
mock_stream_cls.return_value.close.assert_called_once()
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_finished_callback_resets_state(self, mock_stream_cls: MagicMock) -> None:
player = AudioPlayer()
player.play(_make_wav_bytes(), AudioFormat.WAV)
finished_callback = _get_finished_callback(mock_stream_cls)
finished_callback()
assert player.is_playing is False
def test_stop_when_not_playing_is_noop(self) -> None:
player = AudioPlayer()
player.stop()
assert player.is_playing is False
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_stop_triggers_on_finished_via_callback(
self, mock_stream_cls: MagicMock
) -> None:
finished = []
player = AudioPlayer()
player.play(
_make_wav_bytes(),
AudioFormat.WAV,
on_finished=lambda: finished.append(True),
)
# Simulate sounddevice calling finished_callback after stop
finished_callback = _get_finished_callback(mock_stream_cls)
finished_callback()
assert len(finished) == 1
class TestUnsupportedFormat:
def test_unsupported_format_raises(self) -> None:
player = AudioPlayer()
with pytest.raises(UnsupportedAudioFormatError):
player.play(b"fake data", "mp3") # type: ignore[arg-type]
class TestGuardAudioOutput:
def test_raises_when_no_sounddevice(self) -> None:
with patch("vibe.core.audio_player.audio_player.sd", None):
player = AudioPlayer()
with pytest.raises(AudioBackendUnavailableError):
player.play(_make_wav_bytes(), AudioFormat.WAV)
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
def test_raises_when_no_output_device(self, mock_stream_cls: MagicMock) -> None:
with patch(
"vibe.core.audio_player.audio_player.sd.query_devices",
side_effect=sd.PortAudioError(-1),
):
player = AudioPlayer()
with pytest.raises(NoAudioOutputDeviceError):
player.play(_make_wav_bytes(), AudioFormat.WAV)
assert player.is_playing is False
mock_stream_cls.assert_not_called()

View file

@ -16,6 +16,7 @@ import json
from unittest.mock import MagicMock, patch
import httpx
from mistralai.client.models import AssistantMessage
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
import pytest
import respx
@ -33,13 +34,13 @@ from tests.backend.data.mistral import (
STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS,
TOOL_CONVERSATION_PARAMS as MISTRAL_TOOL_CONVERSATION_PARAMS,
)
from vibe.core.config import Backend, ModelConfig, ProviderConfig
from vibe.core.config import ModelConfig, ProviderConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.llm.backend.mistral import MistralBackend
from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.types import BackendLike
from vibe.core.types import LLMChunk, LLMMessage, Role, ToolCall
from vibe.core.types import Backend, FunctionCall, LLMChunk, LLMMessage, Role, ToolCall
from vibe.core.utils import get_user_agent
@ -435,3 +436,93 @@ class TestMistralRetry:
timeout_ms=720000,
retry_config=backend._retry_config,
)
class TestMistralMapperPrepareMessage:
"""Tests for MistralMapper.prepare_message thinking-block handling.
The Mistral API returns assistant messages with reasoning as a single
ThinkChunk (no trailing TextChunk when there is no text content). When
the mapper rebuilds the message for the next request it must NOT append
an empty TextChunk, otherwise the proxy's history-consistency check
sees a content mismatch on every turn and creates spurious conversation
segments.
"""
@pytest.fixture
def mapper(self) -> MistralMapper:
return MistralMapper()
def test_reasoning_only_no_empty_text_chunk(self, mapper: MistralMapper) -> None:
"""Assistant with reasoning_content but no text content should produce
only a ThinkChunk no trailing empty TextChunk.
"""
msg = LLMMessage(
role=Role.assistant,
content=None,
reasoning_content="Let me think step by step.",
)
result = mapper.prepare_message(msg)
content = result.content
assert isinstance(content, list)
assert len(content) == 1
assert content[0].type == "thinking"
def test_reasoning_with_empty_string_content(self, mapper: MistralMapper) -> None:
"""content='' (empty string) should also not produce a trailing TextChunk."""
msg = LLMMessage(
role=Role.assistant, content="", reasoning_content="Thinking..."
)
result = mapper.prepare_message(msg)
content = result.content
assert isinstance(content, list)
assert len(content) == 1
assert content[0].type == "thinking"
def test_reasoning_with_text_content(self, mapper: MistralMapper) -> None:
"""When there is actual text content, both ThinkChunk and TextChunk
should be present.
"""
msg = LLMMessage(
role=Role.assistant,
content="Here is the answer.",
reasoning_content="Let me reason.",
)
result = mapper.prepare_message(msg)
content = result.content
assert isinstance(content, list)
assert len(content) == 2
assert content[0].type == "thinking"
assert content[1].type == "text"
assert content[1].text == "Here is the answer."
def test_reasoning_with_tool_calls_no_text(self, mapper: MistralMapper) -> None:
"""Reasoning + tool_calls but no text content — only ThinkChunk."""
msg = LLMMessage(
role=Role.assistant,
content=None,
reasoning_content="I should run a command.",
tool_calls=[
ToolCall(
id="tc_1",
index=0,
function=FunctionCall(name="bash", arguments='{"cmd": "ls"}'),
)
],
)
result = mapper.prepare_message(msg)
assert isinstance(result, AssistantMessage)
content = result.content
assert isinstance(content, list)
assert len(content) == 1
assert content[0].type == "thinking"
# Tool calls should still be present
assert isinstance(result.tool_calls, list)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.name == "bash"
def test_no_reasoning_plain_string(self, mapper: MistralMapper) -> None:
"""Without reasoning_content, content is a plain string."""
msg = LLMMessage(role=Role.assistant, content="Hello!")
result = mapper.prepare_message(msg)
assert result.content == "Hello!"

View file

@ -13,7 +13,8 @@ from vibe.cli.plan_offer.decide_plan_offer import (
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.core.config import Backend, ProviderConfig
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
@pytest.fixture

View file

@ -8,7 +8,7 @@ class TestCommandRegistry:
registry = CommandRegistry()
assert registry.get_command_name("/help") == "help"
assert registry.get_command_name("/config") == "config"
assert registry.get_command_name("/model") == "config"
assert registry.get_command_name("/model") == "model"
assert registry.get_command_name("/clear") == "clear"
assert registry.get_command_name("/exit") == "exit"

View file

@ -0,0 +1,77 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
class TestFeedbackBarState:
def test_maybe_show_shows_when_random_below_threshold(self):
bar = FeedbackBar()
bar.display = False
bar._set_active = MagicMock()
with patch(
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0
):
bar.maybe_show()
bar._set_active.assert_called_once_with(True)
def test_maybe_show_does_not_show_when_random_above_threshold(self):
bar = FeedbackBar()
bar.display = False
bar._set_active = MagicMock()
with patch(
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=1.0
):
bar.maybe_show()
bar._set_active.assert_not_called()
def test_hide_calls_set_active_false_when_displayed(self):
bar = FeedbackBar()
bar.display = True
bar._set_active = MagicMock()
bar.hide()
bar._set_active.assert_called_once_with(False)
def test_hide_does_nothing_when_already_hidden(self):
bar = FeedbackBar()
bar.display = False
bar._set_active = MagicMock()
bar.hide()
bar._set_active.assert_not_called()
def test_handle_feedback_key_posts_message_and_deactivates(self):
bar = FeedbackBar()
bar.set_timer = MagicMock()
bar.post_message = MagicMock()
bar.query_one = MagicMock()
mock_text_area = MagicMock()
mock_text_area.feedback_active = True
mock_app = MagicMock()
mock_app.query_one.return_value = mock_text_area
with patch.object(
type(bar), "app", new_callable=lambda: property(lambda self: mock_app)
):
bar.handle_feedback_key(3)
assert mock_text_area.feedback_active is False
bar.post_message.assert_called_once()
msg = bar.post_message.call_args[0][0]
assert isinstance(msg, FeedbackBar.FeedbackGiven)
assert msg.rating == 3
bar.set_timer.assert_called_once()
class TestFeedbackGivenMessage:
def test_message_stores_rating(self):
msg = FeedbackBar.FeedbackGiven(rating=2)
assert msg.rating == 2

View file

@ -0,0 +1,284 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.app import BottomApp
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
from vibe.core.config._settings import ModelConfig
def _make_config_with_models():
models = [
ModelConfig(name="model-a", provider="mistral", alias="alpha"),
ModelConfig(name="model-b", provider="mistral", alias="beta"),
ModelConfig(name="model-c", provider="mistral", alias="gamma"),
]
return build_test_vibe_config(models=models, active_model="alpha")
# --- /config command ---
@pytest.mark.asyncio
async def test_config_opens_config_app() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Config
assert len(app.query(ConfigApp)) == 1
@pytest.mark.asyncio
async def test_config_escape_returns_to_input() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Input
assert len(app.query(ConfigApp)) == 0
@pytest.mark.asyncio
async def test_config_toggle_autocopy() -> None:
config = _make_config_with_models()
config.autocopy_to_clipboard = False
app = build_test_vibe_app(config=config)
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Navigate down to Auto-copy (second item) and toggle
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
# Verify the toggle happened in the widget
config_app = app.query_one(ConfigApp)
assert config_app.changes.get("autocopy_to_clipboard") == "On"
@pytest.mark.asyncio
async def test_config_escape_saves_changes() -> None:
config = _make_config_with_models()
config.autocopy_to_clipboard = False
app = build_test_vibe_app(config=config)
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Toggle auto-copy
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
await pilot.press("escape")
await pilot.pause(0.2)
mock_save.assert_called_once()
changes = mock_save.call_args[0][0]
assert changes["autocopy_to_clipboard"] is True
# --- /model command ---
@pytest.mark.asyncio
async def test_model_opens_model_picker() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_model()
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.ModelPicker
assert len(app.query(ModelPickerApp)) == 1
@pytest.mark.asyncio
async def test_model_picker_shows_all_models() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_model()
await pilot.pause(0.2)
picker = app.query_one(ModelPickerApp)
assert picker._model_aliases == ["alpha", "beta", "gamma"]
assert picker._current_model == "alpha"
@pytest.mark.asyncio
async def test_model_picker_escape_returns_to_input() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_model()
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Input
assert len(app.query(ModelPickerApp)) == 0
@pytest.mark.asyncio
async def test_model_picker_escape_does_not_save() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_model()
await pilot.pause(0.2)
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
await pilot.press("escape")
await pilot.pause(0.2)
mock_save.assert_not_called()
@pytest.mark.asyncio
async def test_model_picker_select_model() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_model()
await pilot.pause(0.2)
# Navigate down to "beta" and select
await pilot.press("down")
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
await pilot.press("enter")
await pilot.pause(0.2)
mock_save.assert_called_once_with({"active_model": "beta"})
assert app._current_bottom_app == BottomApp.Input
assert len(app.query(ModelPickerApp)) == 0
@pytest.mark.asyncio
async def test_model_picker_select_current_model() -> None:
"""Selecting the already-active model still saves (idempotent)."""
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_model()
await pilot.pause(0.2)
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
await pilot.press("enter")
await pilot.pause(0.2)
mock_save.assert_called_once_with({"active_model": "alpha"})
assert app._current_bottom_app == BottomApp.Input
# --- config -> model picker flow ---
@pytest.mark.asyncio
async def test_config_model_entry_opens_model_picker() -> None:
"""Pressing Enter on the Model row in /config opens the model picker."""
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Model row is the first item, already highlighted. Press enter.
await pilot.press("enter")
await pilot.pause(0.3)
assert app._current_bottom_app == BottomApp.ModelPicker
assert len(app.query(ModelPickerApp)) == 1
assert len(app.query(ConfigApp)) == 0
@pytest.mark.asyncio
async def test_config_to_model_picker_escape_returns_to_input() -> None:
"""Opening model picker from config, then ESC, returns to input (not config)."""
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Open model picker from config
await pilot.press("enter")
await pilot.pause(0.3)
# Escape model picker
await pilot.press("escape")
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Input
assert len(app.query(ModelPickerApp)) == 0
assert len(app.query(ConfigApp)) == 0
@pytest.mark.asyncio
async def test_config_to_model_picker_select_returns_to_input() -> None:
"""Opening model picker from config, selecting a model, returns to input."""
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Open model picker from config
await pilot.press("enter")
await pilot.pause(0.3)
# Select second model
await pilot.press("down")
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
await pilot.press("enter")
await pilot.pause(0.2)
mock_save.assert_called_once_with({"active_model": "beta"})
assert app._current_bottom_app == BottomApp.Input
@pytest.mark.asyncio
async def test_config_pending_changes_saved_before_model_picker() -> None:
"""Toggle changes in config are saved before switching to model picker."""
config = _make_config_with_models()
config.autocopy_to_clipboard = False
app = build_test_vibe_app(config=config)
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Toggle auto-copy (second row)
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
# Go back up to model row and open model picker
await pilot.press("up")
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
await pilot.press("enter")
await pilot.pause(0.3)
mock_save.assert_called_once()
changes = mock_save.call_args[0][0]
assert changes["autocopy_to_clipboard"] is True

View file

@ -0,0 +1,45 @@
from __future__ import annotations
import pytest
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.core.types import AgentStats
def test_print_session_resume_message_skips_output_without_session_id(
capsys: pytest.CaptureFixture[str],
) -> None:
print_session_resume_message(None, AgentStats())
assert capsys.readouterr().out == ""
def test_print_session_resume_message_prints_resume_commands_and_usage(
capsys: pytest.CaptureFixture[str],
) -> None:
print_session_resume_message(
"12345678-1234-1234-1234-123456789abc",
AgentStats(session_prompt_tokens=14_867, session_completion_tokens=6),
)
assert capsys.readouterr().out == (
"\n"
"Total tokens used this session: input=14,867 output=6 (total=14,873)\n"
"\n"
"To continue this session, run: vibe --continue\n"
"Or: vibe --resume 12345678-1234-1234-1234-123456789abc\n"
)
def test_print_session_resume_message_prints_zero_usage_for_resumed_run_without_llm_activity(
capsys: pytest.CaptureFixture[str],
) -> None:
print_session_resume_message("12345678", AgentStats())
assert capsys.readouterr().out == (
"\n"
"Total tokens used this session: input=0 output=0 (total=0)\n"
"\n"
"To continue this session, run: vibe --continue\n"
"Or: vibe --resume 12345678\n"
)

View file

@ -15,7 +15,7 @@ from tests.update_notifier.adapters.fake_update_cache_repository import (
)
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
from vibe.cli.textual_ui.app import CORE_VERSION, VibeApp
from vibe.cli.textual_ui.app import CORE_VERSION, StartupOptions, VibeApp
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
@ -121,14 +121,28 @@ def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"])
@pytest.fixture(autouse=True)
def _disable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 0
)
@pytest.fixture(autouse=True)
def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
def record_telemetry(
self: Any, event_name: str, properties: dict[str, Any]
self: Any,
event_name: str,
properties: dict[str, Any],
*,
correlation_id: str | None = None,
) -> None:
events.append({"event_name": event_name, "properties": properties})
event: dict[str, Any] = {"event_name": event_name, "properties": properties}
if correlation_id is not None:
event["correlation_id"] = correlation_id
events.append(event)
monkeypatch.setattr(
"vibe.core.telemetry.send.TelemetryClient.send_telemetry_event",
@ -236,11 +250,11 @@ def build_test_vibe_app(
return VibeApp(
agent_loop=resolved_agent_loop,
startup=StartupOptions(initial_prompt=kwargs.pop("initial_prompt", None)),
current_version=resolved_current_version,
update_notifier=resolved_update_notifier,
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

@ -0,0 +1,127 @@
from __future__ import annotations
import pytest
from vibe.core.config import OtelExporterConfig, ProviderConfig, VibeConfig
from vibe.core.types import Backend
class TestOtelExporterConfig:
def test_derives_endpoint_from_mistral_provider(
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "sk-test")
config = vibe_config.model_copy(
update={
"providers": [
ProviderConfig(
name="mistral",
api_base="https://customer.mistral.ai/v1",
backend=Backend.MISTRAL,
)
]
}
)
result = config.otel_exporter_config
assert result is not None
assert result.endpoint == "https://customer.mistral.ai/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-test"}
def test_uses_first_mistral_provider(
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("EU_KEY", "sk-eu")
config = vibe_config.model_copy(
update={
"providers": [
ProviderConfig(
name="mistral-eu",
api_base="https://eu.mistral.ai/v1",
api_key_env_var="EU_KEY",
backend=Backend.MISTRAL,
),
ProviderConfig(
name="mistral-us",
api_base="https://us.mistral.ai/v1",
api_key_env_var="US_KEY",
backend=Backend.MISTRAL,
),
]
}
)
result = config.otel_exporter_config
assert result is not None
assert result.endpoint == "https://eu.mistral.ai/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-eu"}
def test_falls_back_to_default_when_no_mistral_provider(
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "sk-fallback")
config = vibe_config.model_copy(
update={
"providers": [
ProviderConfig(
name="anthropic", api_base="https://api.anthropic.com/v1"
)
]
}
)
result = config.otel_exporter_config
assert result is not None
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-fallback"}
def test_default_providers(
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "sk-default")
result = vibe_config.otel_exporter_config
assert result is not None
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
def test_returns_none_and_warns_when_api_key_missing(
self,
vibe_config: VibeConfig,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
with caplog.at_level("WARNING"):
assert vibe_config.otel_exporter_config is None
assert "OTEL tracing enabled but MISTRAL_API_KEY is not set" in caplog.text
def test_custom_api_key_env_var(
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setenv("MY_CUSTOM_KEY", "sk-custom")
config = vibe_config.model_copy(
update={
"providers": [
ProviderConfig(
name="mistral-onprem",
api_base="https://onprem.corp.com/v1",
api_key_env_var="MY_CUSTOM_KEY",
backend=Backend.MISTRAL,
)
]
}
)
result = config.otel_exporter_config
assert result is not None
assert result.endpoint == "https://onprem.corp.com/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-custom"}
def test_explicit_otel_endpoint_bypasses_provider_derivation(
self, vibe_config: VibeConfig
) -> None:
config = vibe_config.model_copy(
update={"otel_endpoint": "https://my-collector:4318/v1/traces"}
)
result = config.otel_exporter_config
assert result is not None
assert result == OtelExporterConfig(
endpoint="https://my-collector:4318/v1/traces"
)
assert result.headers is None

View file

@ -0,0 +1,135 @@
from __future__ import annotations
from pathlib import Path
from vibe.core.paths._local_config_walk import (
_MAX_DIRS,
WALK_MAX_DEPTH,
has_config_dirs_nearby,
walk_local_config_dirs_all,
)
class TestBoundedWalk:
def test_finds_config_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
assert tmp_path / ".vibe" / "tools" in tools
def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path
for i in range(WALK_MAX_DEPTH):
nested = nested / f"level{i}"
(nested / ".vibe" / "skills").mkdir(parents=True)
_, skills, _ = walk_local_config_dirs_all(tmp_path)
assert nested / ".vibe" / "skills" in skills
def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path
for i in range(WALK_MAX_DEPTH + 1):
nested = nested / f"level{i}"
(nested / ".vibe" / "tools").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
assert not tools
assert not skills
assert not agents
def test_respects_dir_count_limit(self, tmp_path: Path) -> None:
# Create more directories than _MAX_DIRS at depth 1
for i in range(_MAX_DIRS + 10):
(tmp_path / f"dir{i:05d}").mkdir()
# Place config in a directory that would be scanned late
(tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
# The walk should stop before visiting all dirs.
# Whether zzz_last is found depends on sort order and limit,
# but total visited dirs should be bounded.
# We just verify no crash and the function returns.
assert isinstance(tools, tuple)
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
assert tools == (tmp_path / ".vibe" / "tools",)
def test_skips_dot_directories(self, tmp_path: Path) -> None:
(tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
assert not tools
def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None:
(tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
assert tools == (
tmp_path / ".vibe" / "tools",
tmp_path / "aaa" / ".vibe" / "tools",
tmp_path / "bbb" / ".vibe" / "tools",
)
def test_finds_agents_skills(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
_, skills, _ = walk_local_config_dirs_all(tmp_path)
assert tmp_path / ".agents" / "skills" in skills
def test_finds_all_config_types(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
assert tmp_path / ".vibe" / "tools" in tools
assert tmp_path / ".vibe" / "skills" in skills
assert tmp_path / ".vibe" / "agents" in agents
assert tmp_path / ".agents" / "skills" in skills
class TestHasConfigDirsNearby:
def test_returns_true_when_vibe_tools_exist(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
def test_returns_true_when_vibe_skills_exist(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
def test_returns_true_when_agents_skills_exist(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
def test_returns_false_when_empty(self, tmp_path: Path) -> None:
assert has_config_dirs_nearby(tmp_path) is False
def test_returns_false_for_vibe_dir_without_subdirs(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
assert has_config_dirs_nearby(tmp_path) is False
def test_returns_true_for_shallow_nested(self, tmp_path: Path) -> None:
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
def test_returns_true_at_depth_2(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
def test_returns_false_beyond_default_depth(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is False
def test_custom_depth(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path, max_depth=5) is True
def test_early_exit_on_first_match(self, tmp_path: Path) -> None:
# Create many dirs but put config early; function should return quickly
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
for i in range(100):
(tmp_path / f"dir{i}").mkdir()
assert has_config_dirs_nearby(tmp_path) is True
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is False

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from vibe.core.slug import _ADJECTIVES, _NOUNS, create_slug
from vibe.core.utils.slug import _ADJECTIVES, _NOUNS, create_slug
class TestCreateSlug:

View file

@ -9,10 +9,10 @@ import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
from vibe.core.config import Backend
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
from vibe.core.tools.base import BaseTool, ToolPermission
from vibe.core.types import Backend
from vibe.core.utils import get_user_agent
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
@ -258,6 +258,8 @@ class TestTelemetryClient:
nb_mcp_servers=1,
nb_models=3,
entrypoint="cli",
client_name="vscode",
client_version="1.96.0",
terminal_emulator="vscode",
)
@ -270,6 +272,8 @@ class TestTelemetryClient:
assert properties["nb_mcp_servers"] == 1
assert properties["nb_models"] == 3
assert properties["entrypoint"] == "cli"
assert properties["client_name"] == "vscode"
assert properties["client_version"] == "1.96.0"
assert properties["terminal_emulator"] == "vscode"
assert "version" in properties
@ -372,3 +376,41 @@ class TestTelemetryClient:
assert (
calls[1].kwargs["json"]["properties"]["session_id"] == "second-session-id"
)
def test_send_user_rating_feedback_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_user_rating_feedback(rating=2, model="mistral-large")
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.user_rating_feedback"
properties = telemetry_events[0]["properties"]
assert properties["rating"] == 2
assert properties["model"] == "mistral-large"
assert "version" in properties
def test_send_user_rating_feedback_includes_correlation_id(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.last_correlation_id = "corr-abc-123"
client.send_user_rating_feedback(rating=1, model="mistral-large")
assert len(telemetry_events) == 1
assert telemetry_events[0]["correlation_id"] == "corr-abc-123"
def test_send_user_rating_feedback_omits_correlation_id_when_none(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_user_rating_feedback(rating=1, model="mistral-large")
assert len(telemetry_events) == 1
assert "correlation_id" not in telemetry_events[0]

View file

@ -0,0 +1,107 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.config import (
DEFAULT_TTS_MODELS,
DEFAULT_TTS_PROVIDERS,
TTSClient,
TTSModelConfig,
TTSProviderConfig,
)
class TestTTSConfigDefaults:
def test_default_tts_providers_loaded(self) -> None:
config = build_test_vibe_config()
assert len(config.tts_providers) == len(DEFAULT_TTS_PROVIDERS)
assert config.tts_providers[0].name == "mistral"
assert config.tts_providers[0].api_base == "https://api.mistral.ai"
def test_default_tts_models_loaded(self) -> None:
config = build_test_vibe_config()
assert len(config.tts_models) == len(DEFAULT_TTS_MODELS)
assert config.tts_models[0].alias == "voxtral-tts"
assert config.tts_models[0].name == "voxtral-mini-tts-latest"
def test_default_active_tts_model(self) -> None:
config = build_test_vibe_config()
assert config.active_tts_model == "voxtral-tts"
class TestGetActiveTTSModel:
def test_resolves_by_alias(self) -> None:
config = build_test_vibe_config()
model = config.get_active_tts_model()
assert model.alias == "voxtral-tts"
assert model.name == "voxtral-mini-tts-latest"
def test_raises_for_unknown_alias(self) -> None:
config = build_test_vibe_config(active_tts_model="nonexistent")
with pytest.raises(ValueError, match="not found in configuration"):
config.get_active_tts_model()
class TestGetTTSProviderForModel:
def test_resolves_by_name(self) -> None:
config = build_test_vibe_config()
model = config.get_active_tts_model()
provider = config.get_tts_provider_for_model(model)
assert provider.name == "mistral"
assert provider.api_base == "https://api.mistral.ai"
def test_raises_for_unknown_provider(self) -> None:
config = build_test_vibe_config(
tts_models=[
TTSModelConfig(name="test-model", provider="nonexistent", alias="test")
],
active_tts_model="test",
)
model = config.get_active_tts_model()
with pytest.raises(ValueError, match="not found in configuration"):
config.get_tts_provider_for_model(model)
class TestTTSModelUniqueness:
def test_duplicate_aliases_raise(self) -> None:
with pytest.raises(ValueError, match="Duplicate TTS model alias"):
build_test_vibe_config(
tts_models=[
TTSModelConfig(
name="model-a", provider="mistral", alias="same-alias"
),
TTSModelConfig(
name="model-b", provider="mistral", alias="same-alias"
),
],
active_tts_model="same-alias",
)
class TestTTSModelConfig:
def test_alias_defaults_to_name(self) -> None:
model = TTSModelConfig.model_validate({
"name": "my-model",
"provider": "mistral",
})
assert model.alias == "my-model"
def test_explicit_alias(self) -> None:
model = TTSModelConfig(
name="my-model", provider="mistral", alias="custom-alias"
)
assert model.alias == "custom-alias"
def test_default_values(self) -> None:
model = TTSModelConfig(name="my-model", provider="mistral", alias="my-model")
assert model.voice == "gb_jane_neutral"
assert model.response_format == "wav"
class TestTTSProviderConfig:
def test_default_values(self) -> None:
provider = TTSProviderConfig(name="test")
assert provider.api_base == "https://api.mistral.ai"
assert provider.api_key_env_var == ""
assert provider.client == TTSClient.MISTRAL

View file

@ -1,8 +1,11 @@
from __future__ import annotations
from pathlib import Path
import pytest
from vibe.core.utils import get_server_url_from_api_base
from vibe.core.utils.io import read_safe
@pytest.mark.parametrize(
@ -17,3 +20,45 @@ from vibe.core.utils import get_server_url_from_api_base
)
def test_get_server_url_from_api_base(api_base, expected):
assert get_server_url_from_api_base(api_base) == expected
class TestReadSafe:
def test_reads_utf8(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt"
f.write_text("café\n", encoding="utf-8")
assert read_safe(f) == "café\n"
def test_falls_back_on_non_utf8(self, tmp_path: Path) -> None:
f = tmp_path / "latin.txt"
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
f.write_bytes(b"maf\x81\n")
result = read_safe(f)
assert result == "maf<EFBFBD>\n"
def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt"
f.write_text("café\n", encoding="utf-8")
assert read_safe(f, raise_on_error=True) == "café\n"
def test_raise_on_error_true_non_utf8_raises(self, tmp_path: Path) -> None:
f = tmp_path / "bad.txt"
# Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate
f.write_bytes(b"maf\x81\n")
assert read_safe(f, raise_on_error=False) == "maf<EFBFBD>\n"
with pytest.raises(UnicodeDecodeError):
read_safe(f, raise_on_error=True)
def test_empty_file(self, tmp_path: Path) -> None:
f = tmp_path / "empty.txt"
f.write_bytes(b"")
assert read_safe(f) == ""
def test_binary_garbage_does_not_raise(self, tmp_path: Path) -> None:
f = tmp_path / "garbage.bin"
f.write_bytes(bytes(range(256)))
result = read_safe(f)
assert isinstance(result, str)
def test_file_not_found_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
read_safe(tmp_path / "nope.txt")

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Sequence
from contextlib import AbstractContextManager
import io
from pathlib import Path
@ -13,7 +13,7 @@ import pexpect
class SpawnedVibeProcessFixture(Protocol):
def __call__(
self, workdir: Path
self, workdir: Path, extra_args: Sequence[str] | None = None
) -> AbstractContextManager[tuple[pexpect.spawn, io.StringIO]]: ...

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
from collections.abc import Callable, Iterator, Sequence
from contextlib import AbstractContextManager, contextmanager
import io
import os
@ -52,17 +52,21 @@ type SpawnedVibeContext = Iterator[tuple[pexpect.spawn, io.StringIO]]
type SpawnedVibeContextManager = AbstractContextManager[
tuple[pexpect.spawn, io.StringIO]
]
type SpawnedVibeFactory = Callable[[Path], SpawnedVibeContextManager]
type SpawnedVibeFactory = Callable[
[Path, Sequence[str] | None], SpawnedVibeContextManager
]
@pytest.fixture
def spawned_vibe_process() -> SpawnedVibeFactory:
@contextmanager
def spawn(workdir: Path) -> SpawnedVibeContext:
def spawn(
workdir: Path, extra_args: Sequence[str] | None = None
) -> SpawnedVibeContext:
captured = io.StringIO()
child = pexpect.spawn(
"uv",
["run", "vibe", "--workdir", str(workdir)],
["run", "vibe", "--workdir", str(workdir), *(extra_args or [])],
cwd=str(TESTS_ROOT.parent),
env=os.environ,
encoding="utf-8",

View file

@ -0,0 +1,138 @@
from __future__ import annotations
from collections.abc import Callable
import io
from pathlib import Path
import re
import time
import pexpect
import pytest
from tests.e2e.common import (
SpawnedVibeProcessFixture,
ansi_tolerant_pattern,
strip_ansi,
wait_for_main_screen,
wait_for_request_count,
)
from tests.e2e.mock_server import StreamingMockServer
def _usage_by_run_factory(
request_index: int, _payload: object
) -> list[dict[str, object]]:
return [
StreamingMockServer.build_chunk(
created=123,
delta={"role": "assistant", "content": f"Reply {request_index + 1}"},
finish_reason=None,
),
StreamingMockServer.build_chunk(
created=124,
delta={},
finish_reason="stop",
usage=(
{"prompt_tokens": 11, "completion_tokens": 7}
if request_index == 0
else {"prompt_tokens": 2, "completion_tokens": 1}
),
),
]
def _finish_turn(
child: pexpect.spawn,
captured: io.StringIO,
expected_reply: str,
expected_request_count: int,
request_count_getter: Callable[[], int],
) -> None:
wait_for_request_count(
request_count_getter, expected_count=expected_request_count, timeout=10
)
child.expect(ansi_tolerant_pattern(expected_reply), timeout=10)
start = time.monotonic()
last_change = start
last_size = len(captured.getvalue())
while time.monotonic() - start < 5:
try:
child.expect(r"\S", timeout=0.05)
except pexpect.TIMEOUT:
pass
current_size = len(captured.getvalue())
if current_size != last_size:
last_size = current_size
last_change = time.monotonic()
continue
if time.monotonic() - last_change >= 0.3:
return
rendered_tail = strip_ansi(captured.getvalue())[-1200:]
raise AssertionError(
f"Timed out waiting for the turn to finish.\n\nRendered tail:\n{rendered_tail}"
)
@pytest.mark.timeout(30)
@pytest.mark.parametrize(
"streaming_mock_server",
[pytest.param(_usage_by_run_factory, id="fresh-usage-after-resume")],
indirect=True,
)
def test_resumed_session_prints_only_fresh_token_usage_on_exit(
streaming_mock_server: StreamingMockServer,
setup_e2e_env: None,
e2e_workdir: Path,
spawned_vibe_process: SpawnedVibeProcessFixture,
) -> None:
with spawned_vibe_process(e2e_workdir) as (child, captured):
wait_for_main_screen(child, timeout=15)
child.send("First run")
child.send("\r")
_finish_turn(
child,
captured,
expected_reply="Reply 1",
expected_request_count=1,
request_count_getter=lambda: len(streaming_mock_server.requests),
)
child.sendcontrol("c")
child.expect(pexpect.EOF, timeout=10)
first_output = strip_ansi(captured.getvalue())
resume_match = re.search(r"Or: vibe --resume ([0-9a-f-]+)", first_output)
assert resume_match is not None
session_id = resume_match.group(1)
assert (
"Total tokens used this session: input=11 output=7 (total=18)" in first_output
)
with spawned_vibe_process(e2e_workdir, extra_args=["--resume", session_id]) as (
resumed_child,
resumed_captured,
):
wait_for_main_screen(resumed_child, timeout=15)
resumed_child.send("Second run")
resumed_child.send("\r")
_finish_turn(
resumed_child,
resumed_captured,
expected_reply="Reply 2",
expected_request_count=2,
request_count_getter=lambda: len(streaming_mock_server.requests),
)
resumed_child.sendcontrol("c")
resumed_child.expect(pexpect.EOF, timeout=10)
second_output = strip_ansi(resumed_captured.getvalue())
assert "Total tokens used this session: input=2 output=1 (total=3)" in second_output

View file

@ -2,8 +2,8 @@ from __future__ import annotations
from contextlib import contextmanager
from vibe.core.config import Backend
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.types import Backend
@contextmanager

View file

@ -1008,12 +1008,13 @@ class TestSessionLoaderUTF8Encoding:
session_folder = session_dir / "test_20230101_120000_latin100"
session_folder.mkdir()
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
metadata_content = {
"session_id": "latin1-test",
"start_time": "2023-01-01T12:00:00Z",
"end_time": "2023-01-01T12:05:00Z",
"username": "testuser",
"environment": {"working_directory": "/home/user/café_project"},
"environment": {"working_directory": "/home/user/caf\x81_project"},
"git_commit": None,
"git_branch": None,
}
@ -1027,7 +1028,7 @@ class TestSessionLoaderUTF8Encoding:
metadata = SessionLoader.load_metadata(session_folder)
assert metadata.session_id == "latin1-test"
assert metadata.environment["working_directory"] == "/home/user/caf_project"
assert metadata.environment["working_directory"] == "/home/user/caf<EFBFBD>_project"
def test_load_session_with_utf8_metadata_and_messages(
self, session_config: SessionLoggingConfig

View file

@ -40,7 +40,7 @@
.terminal-r6 { fill: #d2d2d2 }
.terminal-r7 { fill: #292929 }
.terminal-r8 { fill: #4b4e55 }
.terminal-r9 { fill: #d0b344;font-weight: bold }
.terminal-r9 { fill: #98a84b;font-weight: bold }
.terminal-r10 { fill: #9a9b99 }
</style>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -0,0 +1,200 @@
<svg class="rich-terminal" viewBox="0 0 1238 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="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</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="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1220" 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;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="556.8" textLength="146.4" clip-path="url(#terminal-line-22)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="556.8" textLength="122" clip-path="url(#terminal-line-22)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="556.8" textLength="183" clip-path="url(#terminal-line-22)">devstral-latest</text><text class="terminal-r1" x="622.2" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="581.2" textLength="414.8" clip-path="url(#terminal-line-23)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">Type&#160;</text><text class="terminal-r3" x="231.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">/help</text><text class="terminal-r1" x="292.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="280.6" clip-path="url(#terminal-line-26)">Configuration&#160;opened...</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" 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="488" clip-path="url(#terminal-line-27)">Configuration&#160;closed&#160;(no&#160;changes&#160;saved).</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="1220" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1220" 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-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.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,204 @@
<svg class="rich-terminal" viewBox="0 0 1238 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 }
.terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #c5c8c6;font-weight: bold }
.terminal-r7 { fill: #98a84b;font-weight: bold }
.terminal-r8 { fill: #868887 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#608ab1" x="36.6" y="709.1" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="122" y="709.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="305" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="622.2" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type&#160;</text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="48.8" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Configuration&#160;opened...</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="1220" clip-path="url(#terminal-line-26)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">Settings</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="36.6" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">Model:&#160;</text><text class="terminal-r6" x="122" y="727.6" textLength="183" clip-path="url(#terminal-line-29)">devstral-latest</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="36.6" y="752" textLength="134.2" clip-path="url(#terminal-line-30)">Auto-copy:&#160;</text><text class="terminal-r7" x="170.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">On</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" 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-r1" x="36.6" y="776.4" textLength="671" clip-path="url(#terminal-line-31)">Autocomplete&#160;watcher&#160;(may&#160;delay&#160;first&#160;autocompletion):&#160;</text><text class="terminal-r8" x="707.6" y="776.4" textLength="36.6" clip-path="url(#terminal-line-31)">Off</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select/Toggle&#160;&#160;Esc&#160;Exit</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,204 @@
<svg class="rich-terminal" viewBox="0 0 1238 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 }
.terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #c5c8c6;font-weight: bold }
.terminal-r7 { fill: #98a84b;font-weight: bold }
.terminal-r8 { fill: #868887 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#608ab1" x="36.6" y="733.5" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="170.8" y="733.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="195.2" y="733.5" width="988.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="622.2" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type&#160;</text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="48.8" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Configuration&#160;opened...</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="1220" clip-path="url(#terminal-line-26)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">Settings</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="36.6" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">Model:&#160;</text><text class="terminal-r6" x="122" y="727.6" textLength="183" clip-path="url(#terminal-line-29)">devstral-latest</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r6" x="36.6" y="752" textLength="134.2" clip-path="url(#terminal-line-30)">Auto-copy:&#160;</text><text class="terminal-r7" x="170.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">On</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" 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-r1" x="36.6" y="776.4" textLength="671" clip-path="url(#terminal-line-31)">Autocomplete&#160;watcher&#160;(may&#160;delay&#160;first&#160;autocompletion):&#160;</text><text class="terminal-r8" x="707.6" y="776.4" textLength="36.6" clip-path="url(#terminal-line-31)">Off</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select/Toggle&#160;&#160;Esc&#160;Exit</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,204 @@
<svg class="rich-terminal" viewBox="0 0 1238 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 }
.terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #c5c8c6;font-weight: bold }
.terminal-r7 { fill: #9cafbd;font-weight: bold }
.terminal-r8 { fill: #868887 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#608ab1" x="36.6" y="733.5" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="170.8" y="733.5" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="207.4" y="733.5" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="622.2" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type&#160;</text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="48.8" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Configuration&#160;opened...</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="1220" clip-path="url(#terminal-line-26)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">Settings</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="36.6" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">Model:&#160;</text><text class="terminal-r6" x="122" y="727.6" textLength="183" clip-path="url(#terminal-line-29)">devstral-latest</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r6" x="36.6" y="752" textLength="134.2" clip-path="url(#terminal-line-30)">Auto-copy:&#160;</text><text class="terminal-r7" x="170.8" y="752" textLength="36.6" clip-path="url(#terminal-line-30)">Off</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" 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-r1" x="36.6" y="776.4" textLength="671" clip-path="url(#terminal-line-31)">Autocomplete&#160;watcher&#160;(may&#160;delay&#160;first&#160;autocompletion):&#160;</text><text class="terminal-r8" x="707.6" y="776.4" textLength="36.6" clip-path="url(#terminal-line-31)">Off</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select/Toggle&#160;&#160;Esc&#160;Exit</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,202 @@
<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: #608ab1 }
.terminal-r6 { 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">FeedbackBarSnapshotApp</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="329.4" clip-path="url(#terminal-line-27)">Sure,&#160;I&#160;can&#160;help&#160;with&#160;that.</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="841.8" y="727.6" textLength="329.4" clip-path="url(#terminal-line-29)">How&#160;is&#160;Vibe&#160;doing&#160;so&#160;far?&#160;&#160;</text><text class="terminal-r5" x="1171.2" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">1</text><text class="terminal-r1" x="1183.4" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">:&#160;good&#160;&#160;</text><text class="terminal-r5" x="1281" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">2</text><text class="terminal-r1" x="1293.2" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">:&#160;fine&#160;&#160;</text><text class="terminal-r5" x="1390.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">3</text><text class="terminal-r1" x="1403" y="727.6" textLength="61" clip-path="url(#terminal-line-29)">:&#160;bad</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r6" x="0" y="752" textLength="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-r6" 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-r6" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r6" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r6" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r6" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r6" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r6" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6%&#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 1238 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="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</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="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1220" 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="97.6" clip-path="url(#terminal-line-24)">devstral</text><text class="terminal-r1" x="536.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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="427" clip-path="url(#terminal-line-25)">5&#160;models&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="1220" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1220" 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-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.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,203 @@
<svg class="rich-terminal" viewBox="0 0 1238 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 }
.terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #98a84b;font-weight: bold }
.terminal-r7 { fill: #c5c8c6;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#608ab1" x="36.6" y="684.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="61" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="158.6" y="684.7" width="1024.8" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="483.6" textLength="97.6" clip-path="url(#terminal-line-19)">devstral</text><text class="terminal-r1" x="536.8" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="427" clip-path="url(#terminal-line-20)">5&#160;models&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type&#160;</text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="1220" clip-path="url(#terminal-line-25)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="654.4" textLength="146.4" clip-path="url(#terminal-line-26)">Select&#160;Model</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="36.6" y="678.8" textLength="183" clip-path="url(#terminal-line-27)">&#160;&#160;mistral-large</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r6" x="36.6" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r7" x="61" y="703.2" textLength="97.6" clip-path="url(#terminal-line-28)">devstral</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="36.6" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">&#160;&#160;codestral</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="36.6" y="752" textLength="183" clip-path="url(#terminal-line-30)">&#160;&#160;mistral-small</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" 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-r1" x="36.6" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">&#160;&#160;local</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="451.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select&#160;&#160;Esc&#160;Cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1238 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 }
.terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #98a84b }
.terminal-r7 { fill: #c5c8c6;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#608ab1" x="36.6" y="709.1" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="170.8" y="709.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="483.6" textLength="97.6" clip-path="url(#terminal-line-19)">devstral</text><text class="terminal-r1" x="536.8" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="427" clip-path="url(#terminal-line-20)">5&#160;models&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type&#160;</text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="1220" clip-path="url(#terminal-line-25)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" 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-r5" x="24.4" y="654.4" textLength="146.4" clip-path="url(#terminal-line-26)">Select&#160;Model</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="36.6" y="678.8" textLength="183" clip-path="url(#terminal-line-27)">&#160;&#160;mistral-large</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r6" x="36.6" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r7" x="61" y="703.2" textLength="97.6" clip-path="url(#terminal-line-28)">devstral</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r7" x="36.6" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">&#160;&#160;codestral</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="36.6" y="752" textLength="183" clip-path="url(#terminal-line-30)">&#160;&#160;mistral-small</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="1220" 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-r1" x="36.6" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">&#160;&#160;local</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="24.4" y="825.2" textLength="451.4" clip-path="url(#terminal-line-33)">↑↓&#160;Navigate&#160;&#160;Enter&#160;Select&#160;&#160;Esc&#160;Cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,200 @@
<svg class="rich-terminal" viewBox="0 0 1238 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="1219.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1220" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1220" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</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="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1220" 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;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="581.2" textLength="146.4" clip-path="url(#terminal-line-23)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="581.2" textLength="122" clip-path="url(#terminal-line-23)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="581.2" textLength="183" clip-path="url(#terminal-line-23)">devstral-latest</text><text class="terminal-r1" x="622.2" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1220" 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;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="605.6" textLength="414.8" clip-path="url(#terminal-line-24)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" 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;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="630" textLength="61" clip-path="url(#terminal-line-25)">Type&#160;</text><text class="terminal-r3" x="231.8" y="630" textLength="61" clip-path="url(#terminal-line-25)">/help</text><text class="terminal-r1" x="292.8" y="630" textLength="256.2" clip-path="url(#terminal-line-25)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" 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="280.6" clip-path="url(#terminal-line-27)">Configuration&#160;reloaded.</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="0" y="752" textLength="1220" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1220" 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-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1220" 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="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" 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="1012.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">NarratorFlowApp</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)">Hello!&#160;I&#160;can&#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)">6%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #ffa500;font-weight: bold }
.terminal-r6 { fill: #868887 }
.terminal-r7 { 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">NarratorFlowApp</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)">Hello!&#160;I&#160;can&#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-r5" x="12.2" y="727.6" textLength="36.6" clip-path="url(#terminal-line-29)">▂▅▇</text><text class="terminal-r1" x="48.8" y="727.6" textLength="122" clip-path="url(#terminal-line-29)">&#160;speaking&#160;</text><text class="terminal-r6" x="170.8" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">esc&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" 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-r7" 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-r7" 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-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" 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-r7" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r7" 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-r7" 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-r7" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r7" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #ffa500;font-weight: bold }
.terminal-r6 { fill: #868887 }
.terminal-r7 { 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">NarratorFlowApp</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)">Hello!&#160;I&#160;can&#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-r5" x="12.2" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="24.4" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">&#160;summarizing&#160;</text><text class="terminal-r6" x="183" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">esc&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" 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-r7" 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-r7" 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-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" 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-r7" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r7" 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-r7" 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-r7" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r7" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -180,13 +180,13 @@
</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="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-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-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="73.2" clip-path="url(#terminal-line-25)">/voice</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Voice&#160;settings&#160;opened...</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -180,13 +180,13 @@
</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="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-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-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="73.2" clip-path="url(#terminal-line-25)">/voice</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Voice&#160;settings&#160;opened...</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
class ConfigTestApp(BaseSnapshotTestApp):
async def on_mount(self) -> None:
await super().on_mount()
await self._switch_to_config_app()
def test_snapshot_config_initial(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_config_app.py:ConfigTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_config_navigate_down(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press("down")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_config_app.py:ConfigTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_config_toggle_autocopy(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_config_app.py:ConfigTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_config_escape_closes(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_config_app.py:ConfigTestApp",
terminal_size=(100, 36),
run_before=run_before,
)

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from textual.pilot import Pilot
from tests.mock.utils import mock_llm_chunk
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend
@pytest.fixture(autouse=True)
def _enable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 1
)
class FeedbackBarSnapshotApp(BaseSnapshotTestApp):
def __init__(self) -> None:
fake_backend = FakeBackend(
mock_llm_chunk(
content="Sure, I can help with that.",
prompt_tokens=10_000,
completion_tokens=2_500,
)
)
super().__init__(backend=fake_backend)
def test_snapshot_feedback_bar_visible(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
with patch(
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0
):
await pilot.press(*"Hello")
await pilot.press("enter")
await pilot.pause(0.4)
assert snap_compare(
"test_ui_snapshot_feedback_bar.py:FeedbackBarSnapshotApp",
terminal_size=(120, 36),
run_before=run_before,
)

View file

@ -0,0 +1,95 @@
from __future__ import annotations
from unittest.mock import patch
from textual.pilot import Pilot
from tests.conftest import build_test_vibe_config
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from vibe.core.config._settings import ModelConfig
def _model_picker_config():
models = [
ModelConfig(
name="mistral-large-latest", provider="mistral", alias="mistral-large"
),
ModelConfig(name="devstral-latest", provider="mistral", alias="devstral"),
ModelConfig(name="codestral-latest", provider="mistral", alias="codestral"),
ModelConfig(
name="mistral-small-latest", provider="mistral", alias="mistral-small"
),
ModelConfig(name="devstral", provider="llamacpp", alias="local"),
]
return build_test_vibe_config(
models=models,
active_model="devstral",
disable_welcome_banner_animation=True,
displayed_workdir="/test/workdir",
)
class ModelPickerTestApp(BaseSnapshotTestApp):
def __init__(self):
super().__init__(config=_model_picker_config())
async def on_mount(self) -> None:
await super().on_mount()
await self._switch_to_model_picker_app()
def test_snapshot_model_picker_initial(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_model_picker_navigate_down(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press("down")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_model_picker_select_different_model(
snap_compare: SnapCompare,
) -> None:
"""Select the second model and verify the picker closes back to input."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.2)
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates"):
assert snap_compare(
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_model_picker_escape_cancels(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
terminal_size=(100, 36),
run_before=run_before,
)

View file

@ -0,0 +1,148 @@
from __future__ import annotations
import asyncio
from typing import Any, cast
from textual.pilot import Pilot
from tests.conftest import build_test_vibe_config
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_audio_player import FakeAudioPlayer
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_tts_client import FakeTTSClient
import vibe.cli.textual_ui.widgets.narrator_status as narrator_status_mod
from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus
from vibe.cli.turn_summary import TurnSummaryTracker
narrator_status_mod.SHRINK_FRAMES = ""
narrator_status_mod.BAR_FRAMES = ["▂▅▇"]
from vibe.core.config import ModelConfig
from vibe.core.tts.tts_client_port import TTSResult
from vibe.core.types import LLMChunk
_TEST_MODEL = ModelConfig(name="test-model", provider="test", alias="test-model")
def _narrator_config():
return build_test_vibe_config(
narrator_enabled=True,
disable_welcome_banner_animation=True,
displayed_workdir="/test/workdir",
)
class GatedBackend(FakeBackend):
def __init__(self, chunks: LLMChunk) -> None:
super().__init__(chunks)
self._gate = asyncio.Event()
def release(self) -> None:
self._gate.set()
async def complete(self, **kwargs: Any) -> LLMChunk:
await self._gate.wait()
return await super().complete(**kwargs)
class GatedTTSClient(FakeTTSClient):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._gate = asyncio.Event()
def release(self) -> None:
self._gate.set()
async def speak(self, text: str) -> TTSResult:
await self._gate.wait()
return await super().speak(text)
class NarratorFlowApp(BaseSnapshotTestApp):
def __init__(self) -> None:
self.summary_gate = GatedBackend(
mock_llm_chunk(content="Summary of the conversation")
)
self.tts_gate = GatedTTSClient()
super().__init__(
config=_narrator_config(),
backend=FakeBackend(
mock_llm_chunk(
content="Hello! I can help you.",
prompt_tokens=10_000,
completion_tokens=2_500,
)
),
)
self._tts_client = self.tts_gate
self._audio_player = FakeAudioPlayer()
self._turn_summary = TurnSummaryTracker(
backend=self.summary_gate,
model=_TEST_MODEL,
on_summary=self._on_turn_summary,
)
def test_snapshot_narrator_summarizing(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
app = cast(NarratorFlowApp, pilot.app)
# Send message and wait for agent response to complete
await pilot.press(*"Hello")
await pilot.press("enter")
await pilot.pause(0.5)
# end_turn has fired, SUMMARIZING is set, summary backend is gated
assert app.summary_gate._gate.is_set() is False
# Freeze animation at frame 0 for deterministic snapshot
app.query_one(NarratorStatus)._stop_timer()
assert snap_compare(
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_narrator_speaking(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
app = cast(NarratorFlowApp, pilot.app)
await pilot.press(*"Hello")
await pilot.press("enter")
await pilot.pause(0.5)
# Release summary gate → summary resolves → speak task starts → blocks on TTS gate
app.summary_gate.release()
await pilot.pause(0.2)
# Release TTS gate → TTS resolves → SPEAKING set
app.tts_gate.release()
await pilot.pause(0.2)
# Freeze animation at frame 0 for deterministic snapshot
app.query_one(NarratorStatus)._stop_timer()
assert snap_compare(
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_narrator_idle_after_speaking(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
app = cast(NarratorFlowApp, pilot.app)
await pilot.press(*"Hello")
await pilot.press("enter")
await pilot.pause(0.5)
# Release both gates to reach SPEAKING
app.summary_gate.release()
await pilot.pause(0.2)
app.tts_gate.release()
await pilot.pause(0.2)
# Simulate playback finishing (same thread, so call directly)
app._audio_player.stop()
app._set_narrator_state(NarratorState.IDLE)
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
terminal_size=(120, 36),
run_before=run_before,
)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from textual.pilot import Pilot
from tests.conftest import build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
@ -17,7 +18,14 @@ class VoiceEnableApp(BaseSnapshotTestApp):
class VoiceDisableApp(BaseSnapshotTestApp):
def __init__(self) -> None:
super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=True))
config = build_test_vibe_config(
disable_welcome_banner_animation=True,
displayed_workdir="/test/workdir",
voice_mode_enabled=True,
)
super().__init__(
config=config, voice_manager=FakeVoiceManager(is_voice_ready=True)
)
class RecordingActiveApp(BaseSnapshotTestApp):
@ -49,6 +57,10 @@ def test_snapshot_voice_enable(snap_compare: SnapCompare) -> None:
await pilot.press(*"/voice")
await pilot.press("enter")
await pilot.pause(0.4)
await pilot.press("space")
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.pause(0.4)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:VoiceEnableApp",
@ -62,6 +74,10 @@ def test_snapshot_voice_disable(snap_compare: SnapCompare) -> None:
await pilot.press(*"/voice")
await pilot.press("enter")
await pilot.pause(0.4)
await pilot.press("space")
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.pause(0.4)
assert snap_compare(
"test_ui_snapshot_voice_mode.py:VoiceDisableApp",

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from collections.abc import Callable
from vibe.core.audio_player import AlreadyPlayingError
from vibe.core.audio_player.audio_player_port import AudioFormat
class FakeAudioPlayer:
def __init__(self) -> None:
self._playing = False
self._on_finished: Callable[[], object] | None = None
@property
def is_playing(self) -> bool:
return self._playing
def play(
self,
audio_data: bytes,
audio_format: AudioFormat,
*,
on_finished: Callable[[], object] | None = None,
) -> None:
if self._playing:
raise AlreadyPlayingError("Already playing")
self._playing = True
self._on_finished = on_finished
def stop(self) -> None:
self._playing = False
def simulate_finished(self) -> None:
self._playing = False
if self._on_finished is not None:
self._on_finished()

View file

@ -6,7 +6,7 @@ from acp import (
Agent as AcpAgent,
Client,
CreateTerminalResponse,
KillTerminalCommandResponse,
KillTerminalResponse,
ReadTextFileResponse,
ReleaseTerminalResponse,
RequestPermissionResponse,
@ -112,7 +112,7 @@ class FakeClient(Client):
async def kill_terminal(
self, session_id: str, terminal_id: str, **kwargs: Any
) -> KillTerminalCommandResponse | None:
) -> KillTerminalResponse | None:
raise NotImplementedError()
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any
from vibe.core.tts.tts_client_port import TTSResult
class FakeTTSClient:
def __init__(
self, *_args: Any, result: TTSResult | None = None, **_kwargs: Any
) -> None:
self._result: TTSResult = result or TTSResult(audio_data=b"fake-audio")
def set_result(self, result: TTSResult) -> None:
self._result = result
async def speak(self, text: str) -> TTSResult:
return self._result
async def close(self) -> None:
pass

View file

@ -10,11 +10,17 @@ from mcp.types import (
)
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.conftest import (
build_test_agent_loop,
build_test_vibe_config,
make_test_models,
)
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
from vibe.core.types import EntrypointMetadata
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import Backend, EntrypointMetadata, FunctionCall, ToolCall
def _two_model_vibe_config(active_model: str) -> VibeConfig:
@ -119,7 +125,12 @@ async def test_passes_session_id_to_backend(vibe_config: VibeConfig):
[_ async for _ in agent.act("Hello")]
assert len(backend.requests_metadata) > 0
assert backend.requests_metadata[0] == {"session_id": agent.session_id}
meta = backend.requests_metadata[0]
assert meta is not None
assert meta["session_id"] == agent.session_id
assert "message_id" in meta
assert meta["is_user_prompt"] == "true"
assert meta["call_type"] == "main_call"
@pytest.mark.asyncio
@ -141,13 +152,16 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
[_ async for _ in agent.act("Hello")]
assert len(backend.requests_metadata) > 0
assert backend.requests_metadata[0] == {
"agent_entrypoint": "acp",
"agent_version": "2.0.0",
"client_name": "vibe_ide",
"client_version": "0.5.0",
"session_id": agent.session_id,
}
meta = backend.requests_metadata[0]
assert meta is not None
assert meta["agent_entrypoint"] == "acp"
assert meta["agent_version"] == "2.0.0"
assert meta["client_name"] == "vibe_ide"
assert meta["client_version"] == "0.5.0"
assert meta["session_id"] == agent.session_id
assert "message_id" in meta
assert meta["is_user_prompt"] == "true"
assert meta["call_type"] == "main_call"
@pytest.mark.asyncio
@ -197,3 +211,108 @@ async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_change
result2 = await handler(context, params)
assert isinstance(result2, CreateMessageResult)
assert result2.model == "devstral-small-latest"
def _generic_provider_vibe_config() -> VibeConfig:
"""VibeConfig with generic backend so no metadata header is sent."""
providers = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.GENERIC,
)
]
return build_test_vibe_config(providers=providers)
@pytest.mark.asyncio
async def test_mistral_metadata_header_is_user_prompt_per_turn() -> None:
"""First LLM call in a turn has is_user_prompt=True; second call (after tools) has is_user_prompt=False."""
tool_call = ToolCall(
id="call_1",
index=0,
function=FunctionCall(name="todo", arguments='{"action": "read"}'),
)
backend = FakeBackend([
[mock_llm_chunk(content="Checking todos.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Here are your todos.")],
])
config = build_test_vibe_config(
providers=[
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
)
agent = build_test_agent_loop(
config=config, backend=backend, agent_name=BuiltinAgentName.AUTO_APPROVE
)
[_ async for _ in agent.act("What's on my todo list?")]
assert len(backend.requests_metadata) == 2
first_metadata = backend.requests_metadata[0]
second_metadata = backend.requests_metadata[1]
assert first_metadata is not None and "is_user_prompt" in first_metadata
assert second_metadata is not None and "is_user_prompt" in second_metadata
assert first_metadata["is_user_prompt"] == "true"
assert second_metadata["is_user_prompt"] == "false"
assert first_metadata["call_type"] == "main_call"
assert second_metadata["call_type"] == "secondary_call"
@pytest.mark.asyncio
async def test_auto_compact_internal_chat_has_is_user_prompt_false_then_user_turn_true() -> (
None
):
"""Compact's internal _chat() sends is_user_prompt=False; the following user turn sends is_user_prompt=True."""
backend = FakeBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
config = build_test_vibe_config(
models=make_test_models(auto_compact_threshold=1),
providers=[
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
)
agent = build_test_agent_loop(config=config, backend=backend)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
assert len(backend.requests_metadata) == 2
compact_metadata = backend.requests_metadata[0]
user_turn_metadata = backend.requests_metadata[1]
assert compact_metadata is not None and "is_user_prompt" in compact_metadata
assert user_turn_metadata is not None and "is_user_prompt" in user_turn_metadata
assert compact_metadata["is_user_prompt"] == "false"
assert user_turn_metadata["is_user_prompt"] == "true"
assert compact_metadata["call_type"] == "secondary_call"
assert user_turn_metadata["call_type"] == "main_call"
@pytest.mark.asyncio
async def test_generic_provider_has_no_metadata_header() -> None:
"""Non-Mistral provider does not send the metadata header."""
backend = FakeBackend([mock_llm_chunk(content="Response")])
config = _generic_provider_vibe_config()
agent = build_test_agent_loop(config=config, backend=backend)
[_ async for _ in agent.act("Hello")]
assert len(backend.requests_extra_headers) == 1
headers = backend.requests_extra_headers[0]
assert headers is not None
assert "metadata" not in headers

View file

@ -22,7 +22,6 @@ from vibe.core.middleware import (
MiddlewareResult,
ResetReason,
)
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoArgs
from vibe.core.types import (
ApprovalResponse,
@ -54,9 +53,7 @@ class InjectBeforeMiddleware:
def make_config(
*,
enabled_tools: list[str] | None = None,
tools: dict[str, BaseToolConfig] | None = None,
*, enabled_tools: list[str] | None = None, tools: dict[str, dict] | None = None
) -> VibeConfig:
return build_test_vibe_config(
system_prompt_id="tests",
@ -218,8 +215,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
])
agent = build_test_agent_loop(
config=make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
),
backend=backend,
agent_name=BuiltinAgentName.AUTO_APPROVE,
@ -268,8 +264,7 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
])
agent = build_test_agent_loop(
config=make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
),
backend=backend,
agent_name=BuiltinAgentName.AUTO_APPROVE,
@ -324,8 +319,7 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
])
agent = build_test_agent_loop(
config=make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
),
backend=backend,
agent_name=BuiltinAgentName.AUTO_APPROVE,
@ -387,8 +381,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
])
agent = build_test_agent_loop(
config=make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
enabled_tools=["todo"], tools={"todo": {"permission": "ask"}}
),
backend=backend,
agent_name=BuiltinAgentName.DEFAULT,
@ -398,7 +391,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
agent.middleware_pipeline.add(middleware)
async def _reject_callback(
_name: str, _args: BaseModel, _id: str
_name: str, _args: BaseModel, _id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
return (
ApprovalResponse.NO,

View file

@ -13,16 +13,16 @@ from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
Backend,
ModelConfig,
ProviderConfig,
SessionLoggingConfig,
VibeConfig,
)
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.base import ToolPermission
from vibe.core.types import (
AgentStats,
AssistantEvent,
Backend,
CompactEndEvent,
CompactStartEvent,
FunctionCall,
@ -95,7 +95,7 @@ def make_config(
models=models,
providers=providers,
enabled_tools=enabled_tools or [],
tools={"todo": BaseToolConfig(permission=todo_permission)},
tools={"todo": {"permission": todo_permission.value}},
)

View file

@ -13,7 +13,7 @@ from tests.stubs.fake_tool import FakeTool
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.builtins.todo import TodoItem
from vibe.core.types import (
ApprovalCallback,
@ -37,7 +37,7 @@ async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[Bas
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
return build_test_vibe_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=todo_permission)},
tools={"todo": {"permission": todo_permission.value}},
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
@ -166,7 +166,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:
async def approval_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
_tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.YES, None)
@ -210,7 +210,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
custom_feedback = "User declined tool execution"
async def approval_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
_tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.NO, custom_feedback)
@ -299,14 +299,14 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
agent_ref: AgentLoop | None = None
async def approval_callback(
tool_name: str, _args: BaseModel, _tool_call_id: str
tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
callback_invocations.append(tool_name)
# Set permission to ALWAYS for this tool (simulating the new behavior)
assert agent_ref is not None
if tool_name not in agent_ref.config.tools:
agent_ref.config.tools[tool_name] = BaseToolConfig()
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
agent_ref.config.tools[tool_name] = {}
agent_ref.config.tools[tool_name]["permission"] = "always"
return (ApprovalResponse.YES, None)
agent_loop = make_agent_loop(
@ -596,7 +596,7 @@ async def test_parallel_tool_calls_with_approval_callback(
approval_calls: list[str] = []
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
approval_calls.append(tool_call_id)
return (ApprovalResponse.YES, None)
@ -640,7 +640,7 @@ async def test_parallel_approvals_can_run_concurrently() -> None:
max_concurrency = 0
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
nonlocal concurrency, max_concurrency
concurrency += 1
@ -674,7 +674,7 @@ async def test_parallel_mixed_approval_and_rejection(
"""One tool approved, one rejected — both should produce correct events."""
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
if tool_call_id == "call_yes":
return (ApprovalResponse.YES, None)
@ -813,7 +813,7 @@ async def test_parallel_all_permission_never() -> None:
approval_calls: list[str] = []
async def approval_callback(
tool_name: str, _args: BaseModel, tool_call_id: str
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
) -> tuple[ApprovalResponse, str | None]:
approval_calls.append(tool_call_id)
return (ApprovalResponse.YES, None)

View file

@ -160,6 +160,32 @@ class TestAgentProfile:
class TestAgentApplyToConfig:
def test_profile_disabled_tools_are_merged_with_base_config(self) -> None:
base = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_tools=["ask_user_question"],
)
result = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].apply_to_config(base)
assert set(result.disabled_tools) == {"ask_user_question", "exit_plan_mode"}
def test_profile_disabled_tools_preserve_user_disabled_tools(self) -> None:
base = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_tools=["ask_user_question", "custom_tool"],
)
result = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].apply_to_config(base)
assert set(result.disabled_tools) == {
"ask_user_question",
"custom_tool",
"exit_plan_mode",
}
def test_custom_prompt_found_in_global_when_missing_from_project(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@ -204,8 +230,9 @@ class TestAgentApplyToConfig:
class TestAgentProfileOverrides:
def test_default_agent_has_no_overrides(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
def test_default_agent_disables_exit_plan_mode(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides
assert "exit_plan_mode" in overrides.get("base_disabled", [])
def test_auto_approve_agent_sets_auto_approve(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides

View file

@ -7,8 +7,7 @@ from tests.mock.mock_backend_factory import mock_backend_factory
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core import run_programmatic
from vibe.core.config import Backend
from vibe.core.types import LLMMessage, OutputFormat, Role
from vibe.core.types import Backend, LLMMessage, OutputFormat, Role
class SpyStreamingFormatter:

View file

@ -158,7 +158,7 @@ class TestMistralMapperPrepareMessage:
assert isinstance(result, AssistantMessage)
assert isinstance(result.content, list)
assert len(result.content) == 2
assert len(result.content) == 1
think_chunk = result.content[0]
assert isinstance(think_chunk, ThinkChunk)
@ -168,10 +168,6 @@ class TestMistralMapperPrepareMessage:
assert isinstance(inner_chunk, TextChunk)
assert inner_chunk.text == "Just thinking..."
text_chunk = result.content[1]
assert isinstance(text_chunk, TextChunk)
assert text_chunk.text == ""
class TestGenericBackendReasoningContent:
@pytest.mark.asyncio

371
tests/test_tracing.py Normal file
View file

@ -0,0 +1,371 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
SimpleSpanProcessor,
SpanExporter,
SpanExportResult,
)
from opentelemetry.trace import StatusCode
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core import tracing
from vibe.core.config import OtelExporterConfig
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tracing import agent_span, setup_tracing, tool_span
from vibe.core.types import BaseEvent, FunctionCall, ToolCall
class _CollectingExporter(SpanExporter):
def __init__(self) -> None:
self.spans: list = []
def export(self, spans):
self.spans.extend(spans)
return SpanExportResult.SUCCESS
def shutdown(self) -> None:
pass
@pytest.fixture(autouse=True)
def _otel_provider(monkeypatch: pytest.MonkeyPatch):
# Patch get_tracer_provider instead of set_tracer_provider to sidestep the
# OTEL singleton guard that rejects a second set_tracer_provider call.
exporter = _CollectingExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider)
yield exporter
class TestSetupTracing:
def test_noop_when_disabled(self) -> None:
config = MagicMock(enable_otel=False)
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
setup_tracing(config)
mock_set.assert_not_called()
def test_noop_when_exporter_config_is_none(self) -> None:
config = MagicMock(enable_otel=True, otel_exporter_config=None)
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
setup_tracing(config)
mock_set.assert_not_called()
def test_configures_provider_from_exporter_config(self) -> None:
config = MagicMock(
enable_otel=True,
otel_exporter_config=OtelExporterConfig(
endpoint="https://customer.mistral.ai/telemetry/v1/traces",
headers={"Authorization": "Bearer sk-test"},
),
)
with (
patch(
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter"
) as mock_exporter,
patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set,
):
setup_tracing(config)
mock_exporter.assert_called_once_with(
endpoint="https://customer.mistral.ai/telemetry/v1/traces",
headers={"Authorization": "Bearer sk-test"},
)
mock_set.assert_called_once()
assert isinstance(mock_set.call_args[0][0], TracerProvider)
def test_custom_endpoint_has_no_auth_headers(self) -> None:
config = MagicMock(
enable_otel=True,
otel_exporter_config=OtelExporterConfig(
endpoint="https://my-collector:4318/v1/traces"
),
)
with (
patch(
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter"
) as mock_exporter,
patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set,
):
setup_tracing(config)
mock_exporter.assert_called_once_with(
endpoint="https://my-collector:4318/v1/traces", headers=None
)
mock_set.assert_called_once()
assert isinstance(mock_set.call_args[0][0], TracerProvider)
class TestAgentSpan:
@pytest.mark.asyncio
async def test_span_name_status_and_attributes(
self, _otel_provider: _CollectingExporter
) -> None:
async with agent_span(model="devstral", session_id="s1"):
pass
assert len(_otel_provider.spans) == 1
span = _otel_provider.spans[0]
assert span.name == "invoke_agent mistral-vibe"
assert span.status.status_code == StatusCode.OK
attrs = dict(span.attributes)
assert attrs["gen_ai.operation.name"] == "invoke_agent"
assert attrs["gen_ai.provider.name"] == "mistral_ai"
assert attrs["gen_ai.agent.name"] == "mistral-vibe"
assert attrs["gen_ai.request.model"] == "devstral"
assert attrs["gen_ai.conversation.id"] == "s1"
@pytest.mark.asyncio
async def test_omits_optional_attributes(
self, _otel_provider: _CollectingExporter
) -> None:
async with agent_span():
pass
attrs = dict(_otel_provider.spans[0].attributes)
assert "gen_ai.request.model" not in attrs
assert "gen_ai.conversation.id" not in attrs
@pytest.mark.asyncio
async def test_records_error_on_exception(
self, _otel_provider: _CollectingExporter
) -> None:
with pytest.raises(ValueError, match="boom"):
async with agent_span():
raise ValueError("boom")
span = _otel_provider.spans[0]
assert span.status.status_code == StatusCode.ERROR
assert "boom" in span.status.description
class TestToolSpan:
@pytest.mark.asyncio
async def test_span_name_status_and_attributes(
self, _otel_provider: _CollectingExporter
) -> None:
async with tool_span(tool_name="bash", call_id="c1", arguments='{"cmd": "ls"}'):
pass
assert len(_otel_provider.spans) == 1
span = _otel_provider.spans[0]
assert span.name == "execute_tool bash"
assert span.status.status_code == StatusCode.OK
attrs = dict(span.attributes)
assert attrs["gen_ai.operation.name"] == "execute_tool"
assert attrs["gen_ai.tool.name"] == "bash"
assert attrs["gen_ai.tool.call.id"] == "c1"
assert attrs["gen_ai.tool.call.arguments"] == '{"cmd": "ls"}'
assert attrs["gen_ai.tool.type"] == "function"
@pytest.mark.asyncio
async def test_records_error_and_exception_event(
self, _otel_provider: _CollectingExporter
) -> None:
with pytest.raises(RuntimeError):
async with tool_span(tool_name="bash", call_id="c1", arguments="{}"):
raise RuntimeError("fail")
span = _otel_provider.spans[0]
assert span.status.status_code == StatusCode.ERROR
exc_events = [e for e in span.events if e.name == "exception"]
assert len(exc_events) == 1
class TestSpanHierarchy:
@pytest.mark.asyncio
async def test_chat_and_tool_are_siblings_under_agent(
self, _otel_provider: _CollectingExporter
) -> None:
async with agent_span(model="devstral"):
tracer = trace.get_tracer("mistralai_sdk_tracer")
# Simulate a chat span created by the Mistral SDK.
with tracer.start_as_current_span("chat devstral"):
pass
async with tool_span(tool_name="grep", call_id="c1", arguments="{}"):
pass
with tracer.start_as_current_span("chat devstral"):
pass
agent = next(s for s in _otel_provider.spans if "invoke_agent" in s.name)
children = [
s
for s in _otel_provider.spans
if s.parent and s.parent.span_id == agent.context.span_id
]
assert len(children) == 3
assert [s.name for s in children] == [
"chat devstral",
"execute_tool grep",
"chat devstral",
]
class TestBaggagePropagation:
@pytest.mark.asyncio
async def test_tool_span_inherits_conversation_id(
self, _otel_provider: _CollectingExporter
) -> None:
async with agent_span(model="devstral", session_id="sess-42"):
async with tool_span(tool_name="bash", call_id="c1", arguments="{}"):
pass
tool = next(s for s in _otel_provider.spans if "execute_tool" in s.name)
assert dict(tool.attributes)["gen_ai.conversation.id"] == "sess-42"
@pytest.mark.asyncio
async def test_tool_span_omits_conversation_id_when_no_session(
self, _otel_provider: _CollectingExporter
) -> None:
async with agent_span(model="devstral"):
async with tool_span(tool_name="bash", call_id="c1", arguments="{}"):
pass
tool = next(s for s in _otel_provider.spans if "execute_tool" in s.name)
assert "gen_ai.conversation.id" not in dict(tool.attributes)
@pytest.mark.asyncio
async def test_baggage_does_not_leak_after_agent_span(self) -> None:
from opentelemetry import baggage as baggage_api
async with agent_span(model="devstral", session_id="sess-1"):
pass
assert baggage_api.get_baggage("gen_ai.conversation.id") is None
class TestErrorIsolation:
@pytest.mark.asyncio
async def test_yields_invalid_span_on_creation_failure(
self, _otel_provider: _CollectingExporter, monkeypatch: pytest.MonkeyPatch
) -> None:
def _broken_tracer() -> trace.Tracer:
raise RuntimeError("tracer broken")
monkeypatch.setattr(tracing, "_get_tracer", _broken_tracer)
async with agent_span():
pass
assert len(_otel_provider.spans) == 0
@pytest.mark.asyncio
async def test_caller_exception_propagates_when_set_status_fails(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def _broken_set_status(self, *args, **kwargs):
raise RuntimeError("set_status broken")
monkeypatch.setattr(
"opentelemetry.sdk.trace.Span.set_status", _broken_set_status
)
with pytest.raises(ValueError, match="original"):
async with agent_span():
raise ValueError("original")
@pytest.mark.asyncio
async def test_cancellation_ends_span_without_error_status(
self, _otel_provider: _CollectingExporter
) -> None:
with pytest.raises(asyncio.CancelledError):
async with agent_span():
raise asyncio.CancelledError
span = _otel_provider.spans[0]
assert span.status.status_code != StatusCode.ERROR
@pytest.mark.asyncio
async def test_success_path_swallows_span_end_failure(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def _broken_end(self, *args, **kwargs):
raise RuntimeError("end broken")
monkeypatch.setattr("opentelemetry.sdk.trace.Span.end", _broken_end)
async with agent_span():
pass
class TestIntegration:
@staticmethod
async def _collect_events(agent_loop, prompt: str) -> list[BaseEvent]:
return [ev async for ev in agent_loop.act(prompt)]
@pytest.mark.asyncio
async def test_agent_turn_with_tool_call_produces_spans(
self, _otel_provider: _CollectingExporter
) -> None:
tool_call = ToolCall(
id="call_1",
index=0,
function=FunctionCall(name="todo", arguments='{"action": "read"}'),
)
backend = FakeBackend([
[mock_llm_chunk(content="Let me check.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Done.")],
])
config = build_test_vibe_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
)
agent_loop = build_test_agent_loop(config=config, backend=backend)
await self._collect_events(agent_loop, "What are my todos?")
spans = _otel_provider.spans
agent_spans = [s for s in spans if "invoke_agent" in s.name]
tool_spans = [s for s in spans if "execute_tool" in s.name]
assert len(agent_spans) == 1
assert len(tool_spans) == 1
agent = agent_spans[0]
tool = tool_spans[0]
# Parent-child relationship
assert tool.parent is not None
assert tool.parent.span_id == agent.context.span_id
# -- Agent span: name, status, and every attribute set by agent_span() --
assert agent.name == "invoke_agent mistral-vibe"
assert agent.status.status_code == StatusCode.OK
agent_attrs = dict(agent.attributes)
assert agent_attrs["gen_ai.operation.name"] == "invoke_agent"
assert agent_attrs["gen_ai.provider.name"] == "mistral_ai"
assert agent_attrs["gen_ai.agent.name"] == "mistral-vibe"
assert agent_attrs["gen_ai.request.model"] == "mistral-vibe-cli-latest"
assert agent_attrs["gen_ai.conversation.id"] == agent_loop.session_id
# -- Tool span: name, status, and every attribute set by tool_span() + set_tool_result() --
assert tool.name == "execute_tool todo"
assert tool.status.status_code == StatusCode.OK
tool_attrs = dict(tool.attributes)
assert tool_attrs["gen_ai.operation.name"] == "execute_tool"
assert tool_attrs["gen_ai.tool.name"] == "todo"
assert tool_attrs["gen_ai.tool.call.id"] == "call_1"
assert tool_attrs["gen_ai.tool.type"] == "function"
assert (
tool_attrs["gen_ai.tool.call.arguments"] == '{"action":"read","todos":null}'
)
assert tool_attrs["gen_ai.tool.call.result"] == (
"message: Retrieved 0 todos\ntodos: []\ntotal_count: 0"
)
# Conversation ID propagated via baggage from agent_span
assert tool_attrs["gen_ai.conversation.id"] == agent_loop.session_id

349
tests/test_turn_summary.py Normal file
View file

@ -0,0 +1,349 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import logging
import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.turn_summary import (
NARRATOR_MODEL,
NoopTurnSummary,
TurnSummaryResult,
TurnSummaryTracker,
create_narrator_backend,
)
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.llm.backend.mistral import MistralBackend
from vibe.core.types import AssistantEvent, Backend, ToolStreamEvent, UserMessageEvent
_TEST_MODEL = ModelConfig(name="test-model", provider="test", alias="test-model")
def _noop_callback(result: TurnSummaryResult) -> None:
pass
class TestCreateNarratorBackend:
def test_uses_mistral_provider(self, monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
config = VibeConfig()
result = create_narrator_backend(config)
assert result is not None
backend, model = result
assert isinstance(backend, MistralBackend)
assert model is NARRATOR_MODEL
def test_uses_custom_provider_base_url(self, monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
custom_provider = ProviderConfig(
name="mistral",
api_base="https://on-prem.example.com/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
config = VibeConfig(providers=[custom_provider])
result = create_narrator_backend(config)
assert result is not None
backend, model = result
assert isinstance(backend, MistralBackend)
assert backend._provider.api_base == custom_provider.api_base
def test_returns_none_when_api_key_missing(self, monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
config = VibeConfig()
monkeypatch.delenv("MISTRAL_API_KEY")
assert create_narrator_backend(config) is None
def test_returns_none_when_provider_missing(self):
config = VibeConfig(providers=[])
assert create_narrator_backend(config) is None
class TestTrack:
def _make_tracker(self, backend: FakeBackend | None = None) -> TurnSummaryTracker:
return TurnSummaryTracker(
backend=backend or FakeBackend(),
model=_TEST_MODEL,
on_summary=_noop_callback,
)
def test_assistant_event(self):
tracker = self._make_tracker()
tracker.start_turn("test")
tracker.track(AssistantEvent(content="chunk1"))
tracker.track(AssistantEvent(content="chunk2"))
assert tracker._data is not None
assert tracker._data.assistant_fragments == ["chunk1", "chunk2"]
def test_assistant_event_empty_content_ignored(self):
tracker = self._make_tracker()
tracker.start_turn("test")
tracker.track(AssistantEvent(content=""))
assert tracker._data is not None
assert tracker._data.assistant_fragments == []
def test_start_turn_preserves_full_message(self):
tracker = self._make_tracker()
long_msg = "a" * 1500
tracker.start_turn(long_msg)
assert tracker._data is not None
assert len(tracker._data.user_message) == 1500
def test_start_turn_increments_generation(self):
tracker = self._make_tracker()
assert tracker.generation == 0
tracker.start_turn("turn 1")
assert tracker.generation == 1
tracker.start_turn("turn 2")
assert tracker.generation == 2
def test_cancel_turn_clears_data(self):
tracker = self._make_tracker()
tracker.start_turn("test")
assert tracker._data is not None
tracker.cancel_turn()
assert tracker._data is None
def test_set_error_stores_message(self):
tracker = self._make_tracker()
tracker.start_turn("test")
tracker.set_error("rate limit exceeded")
assert tracker._data is not None
assert tracker._data.error == "rate limit exceeded"
def test_set_error_without_start_is_noop(self):
tracker = self._make_tracker()
tracker.set_error("should be ignored")
assert tracker._data is None
def test_cancel_turn_without_start_is_noop(self):
tracker = self._make_tracker()
tracker.cancel_turn()
assert tracker._data is None
def test_unrelated_events_ignored(self):
tracker = self._make_tracker()
tracker.start_turn("test")
tracker.track(UserMessageEvent(content="hi", message_id="m1"))
tracker.track(
ToolStreamEvent(tool_name="bash", message="output", tool_call_id="tc1")
)
assert tracker._data is not None
assert tracker._data.assistant_fragments == []
class TestTurnSummaryTracker:
def _make_tracker(
self,
backend: FakeBackend,
on_summary: Callable[[TurnSummaryResult], None] = _noop_callback,
) -> TurnSummaryTracker:
return TurnSummaryTracker(
backend=backend, model=_TEST_MODEL, on_summary=on_summary
)
@pytest.mark.asyncio
async def test_track_accumulates_events(self):
backend = FakeBackend(mock_llm_chunk(content="summary"))
tracker = self._make_tracker(backend)
tracker.start_turn("hello")
tracker.track(AssistantEvent(content="chunk1"))
tracker.track(AssistantEvent(content="chunk2"))
assert tracker._data is not None
assert tracker._data.assistant_fragments == ["chunk1", "chunk2"]
@pytest.mark.asyncio
async def test_end_turn_fires_summary(self):
backend = FakeBackend(mock_llm_chunk(content="the summary"))
tracker = self._make_tracker(backend)
tracker.start_turn("do something")
tracker.track(AssistantEvent(content="response"))
tracker.end_turn()
await asyncio.sleep(0.1)
assert len(backend.requests_messages) == 1
summary_msgs = backend.requests_messages[0]
assert len(summary_msgs) == 2
assert summary_msgs[0].role.value == "system"
assert summary_msgs[1].role.value == "user"
assert summary_msgs[1].content is not None
assert "do something" in summary_msgs[1].content
@pytest.mark.asyncio
async def test_end_turn_clears_state(self):
backend = FakeBackend(mock_llm_chunk(content="summary"))
tracker = self._make_tracker(backend)
tracker.start_turn("hello")
tracker.end_turn()
assert tracker._data is None
@pytest.mark.asyncio
async def test_track_without_start_is_noop(self):
backend = FakeBackend(mock_llm_chunk(content="summary"))
tracker = self._make_tracker(backend)
tracker.track(AssistantEvent(content="ignored"))
assert tracker._data is None
@pytest.mark.asyncio
async def test_end_turn_without_start_is_noop(self):
backend = FakeBackend(mock_llm_chunk(content="summary"))
tracker = self._make_tracker(backend)
tracker.end_turn()
assert len(backend.requests_messages) == 0
@pytest.mark.asyncio
async def test_end_turn_after_cancel_is_noop(self):
backend = FakeBackend(mock_llm_chunk(content="summary"))
tracker = self._make_tracker(backend)
tracker.start_turn("hello")
tracker.cancel_turn()
tracker.end_turn()
await asyncio.sleep(0.1)
assert len(backend.requests_messages) == 0
@pytest.mark.asyncio
async def test_on_summary_callback_called(self):
backend = FakeBackend(mock_llm_chunk(content="the summary text"))
received: list[TurnSummaryResult] = []
def capture(result: TurnSummaryResult) -> None:
received.append(result)
tracker = self._make_tracker(backend, on_summary=capture)
tracker.start_turn("hello")
tracker.track(AssistantEvent(content="response"))
tracker.end_turn()
await asyncio.sleep(0.1)
assert len(received) == 1
assert received[0].summary == "the summary text"
assert received[0].generation == tracker.generation
@pytest.mark.asyncio
async def test_backend_error_calls_callback_with_none(self):
backend = FakeBackend(exception_to_raise=RuntimeError("backend down"))
received: list[TurnSummaryResult] = []
def capture(result: TurnSummaryResult) -> None:
received.append(result)
tracker = self._make_tracker(backend, on_summary=capture)
tracker.start_turn("hello")
tracker.end_turn()
await asyncio.sleep(0.2)
assert len(received) == 1
assert received[0].summary is None
@pytest.mark.asyncio
async def test_backend_error_logged_no_crash(self, caplog):
backend = FakeBackend(exception_to_raise=RuntimeError("backend down"))
tracker = self._make_tracker(backend)
with caplog.at_level(logging.WARNING, logger="vibe"):
tracker.start_turn("hello")
tracker.end_turn()
await asyncio.sleep(0.2)
assert "Turn summary generation failed" in caplog.text
@pytest.mark.asyncio
async def test_close_cancels_pending_tasks(self):
backend = FakeBackend(mock_llm_chunk(content="summary"))
tracker = self._make_tracker(backend)
tracker.start_turn("hello")
tracker.end_turn()
assert len(tracker._tasks) == 1
await tracker.close()
assert len(tracker._tasks) == 0
@pytest.mark.asyncio
async def test_error_only_turn_includes_error_in_summary(self):
backend = FakeBackend(mock_llm_chunk(content="error summary"))
received: list[TurnSummaryResult] = []
def capture(result: TurnSummaryResult) -> None:
received.append(result)
tracker = self._make_tracker(backend, on_summary=capture)
tracker.start_turn("do something")
tracker.set_error("Rate limit exceeded")
cancel = tracker.end_turn()
await asyncio.sleep(0.1)
assert cancel is not None
assert len(backend.requests_messages) == 1
prompt_content = backend.requests_messages[0][1].content
assert prompt_content is not None
assert "do something" in prompt_content
assert "## Error" in prompt_content
assert "Rate limit exceeded" in prompt_content
assert "## Assistant Response" not in prompt_content
assert len(received) == 1
assert received[0].summary == "error summary"
@pytest.mark.asyncio
async def test_error_with_partial_response_includes_both(self):
backend = FakeBackend(mock_llm_chunk(content="partial error summary"))
tracker = self._make_tracker(backend)
tracker.start_turn("do something")
tracker.track(AssistantEvent(content="partial response"))
tracker.set_error("connection lost")
tracker.end_turn()
await asyncio.sleep(0.1)
assert len(backend.requests_messages) == 1
prompt_content = backend.requests_messages[0][1].content
assert prompt_content is not None
assert "## Assistant Response" in prompt_content
assert "partial response" in prompt_content
assert "## Error" in prompt_content
assert "connection lost" in prompt_content
@pytest.mark.asyncio
async def test_stale_summary_has_old_generation(self):
backend = FakeBackend(mock_llm_chunk(content="stale summary"))
received: list[TurnSummaryResult] = []
def capture(result: TurnSummaryResult) -> None:
received.append(result)
tracker = self._make_tracker(backend, on_summary=capture)
tracker.start_turn("turn 1")
tracker.end_turn()
tracker.start_turn("turn 2")
await asyncio.sleep(0.1)
assert len(received) == 1
assert received[0].generation == 1
assert tracker.generation == 2
assert received[0].generation != tracker.generation
class TestNoopTurnSummary:
def test_all_methods_callable(self):
noop = NoopTurnSummary()
noop.start_turn("hello")
noop.track(AssistantEvent(content="chunk"))
noop.set_error("some error")
noop.cancel_turn()
noop.end_turn()
def test_generation_is_zero(self):
noop = NoopTurnSummary()
assert noop.generation == 0
@pytest.mark.asyncio
async def test_close_is_noop(self):
noop = NoopTurnSummary()
await noop.close()

54
tests/tools/test_arity.py Normal file
View file

@ -0,0 +1,54 @@
from __future__ import annotations
from vibe.core.tools.arity import build_session_pattern
class TestBuildSessionPattern:
def test_single_token_in_arity(self):
assert build_session_pattern(["mkdir", "foo"]) == "mkdir *"
def test_single_token_not_in_arity(self):
assert build_session_pattern(["whoami"]) == "whoami *"
def test_two_token_arity(self):
assert build_session_pattern(["git", "checkout", "main"]) == "git checkout *"
def test_three_token_arity(self):
assert build_session_pattern(["npm", "run", "dev"]) == "npm run dev *"
def test_longer_prefix_wins(self):
# "git" is arity 2, but "git stash" is arity 3
assert build_session_pattern(["git", "stash", "pop"]) == "git stash pop *"
def test_docker_compose(self):
assert (
build_session_pattern(["docker", "compose", "up", "-d"])
== "docker compose up *"
)
def test_empty_tokens(self):
assert build_session_pattern([]) == ""
def test_unknown_command_returns_first_token(self):
assert build_session_pattern(["mycommand", "arg1", "arg2"]) == "mycommand *"
def test_cat_is_arity_1(self):
assert build_session_pattern(["cat", "file.txt"]) == "cat *"
def test_rm_is_arity_1(self):
assert build_session_pattern(["rm", "-rf", "dir"]) == "rm *"
def test_uv_run(self):
assert build_session_pattern(["uv", "run", "pytest"]) == "uv run pytest *"
def test_pip_install(self):
assert build_session_pattern(["pip", "install", "numpy"]) == "pip install *"
def test_git_remote_add(self):
assert (
build_session_pattern(["git", "remote", "add", "origin", "url"])
== "git remote add *"
)
def test_gh_pr_list(self):
assert build_session_pattern(["gh", "pr", "list"]) == "gh pr list *"

View file

@ -5,6 +5,7 @@ import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
from vibe.core.tools.permissions import PermissionContext
@pytest.fixture
@ -80,7 +81,10 @@ 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
assert (
not isinstance(permission, PermissionContext)
or permission.permission is not ToolPermission.ALWAYS
)
def test_resolve_permission():
@ -92,9 +96,13 @@ def test_resolve_permission():
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
empty = bash_tool.resolve_permission(BashArgs(command=""))
assert allowlisted is ToolPermission.ALWAYS
assert denylisted is ToolPermission.NEVER
assert mixed is None
assert isinstance(allowlisted, PermissionContext)
assert allowlisted.permission is ToolPermission.ALWAYS
assert isinstance(denylisted, PermissionContext)
assert denylisted.permission is ToolPermission.NEVER
assert isinstance(mixed, PermissionContext)
assert mixed.permission is ToolPermission.ASK
assert any(rp.label == "whoami *" for rp in mixed.required_permissions)
assert empty is None
@ -108,80 +116,95 @@ class TestResolvePermissionWindowsSyntax:
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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission == ToolPermission.ASK
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
assert isinstance(result, PermissionContext)
assert result.permission == ToolPermission.ASK
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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission 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
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS

View file

@ -0,0 +1,750 @@
from __future__ import annotations
import os
import pytest
from vibe.core.tools.base import BaseToolState, ToolPermission
from vibe.core.tools.builtins.bash import (
Bash,
BashArgs,
BashToolConfig,
_collect_outside_dirs,
)
from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepToolConfig
from vibe.core.tools.builtins.read_file import (
ReadFile,
ReadFileArgs,
ReadFileState,
ReadFileToolConfig,
)
from vibe.core.tools.builtins.search_replace import (
SearchReplace,
SearchReplaceArgs,
SearchReplaceConfig,
)
from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig
from vibe.core.tools.builtins.write_file import (
WriteFile,
WriteFileArgs,
WriteFileConfig,
)
from vibe.core.tools.permissions import (
ApprovedRule,
PermissionContext,
PermissionScope,
RequiredPermission,
)
from vibe.core.tools.utils import wildcard_match
class TestBashGranularPermissions:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
self.workdir = tmp_path
def _bash(self, **kwargs):
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
def test_allowlisted_command_always(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="git status"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_denylisted_command_never(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="vim file.txt"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_standalone_denylisted_never(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="python"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_standalone_denylisted_with_args_not_denied(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="python script.py"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
def test_unknown_command_returns_permission_context(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="npm install"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
assert len(result.required_permissions) == 1
rp = result.required_permissions[0]
assert rp.scope is PermissionScope.COMMAND_PATTERN
assert rp.session_pattern == "npm install *"
def test_arity_based_prefix(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="docker compose up -d"))
assert isinstance(result, PermissionContext)
rp = result.required_permissions[0]
assert rp.session_pattern == "docker compose up *"
def test_multiple_commands_dedup(self):
bash = self._bash()
result = bash.resolve_permission(
BashArgs(command="npm install foo && npm install bar")
)
assert isinstance(result, PermissionContext)
command_labels = [
rp.label
for rp in result.required_permissions
if rp.scope is PermissionScope.COMMAND_PATTERN
]
assert command_labels == ["npm install *"]
def test_cd_excluded_from_command_patterns(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="cd /tmp"))
assert isinstance(result, PermissionContext)
assert all(
rp.scope is not PermissionScope.COMMAND_PATTERN
for rp in result.required_permissions
)
def test_outside_directory_detection(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/test"))
assert isinstance(result, PermissionContext)
outside = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert len(outside) >= 1
def test_outside_directory_has_glob_pattern(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/test"))
assert isinstance(result, PermissionContext)
outside = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert any("/tmp" in rp.session_pattern for rp in outside)
def test_in_workdir_no_outside_directory(self):
bash = self._bash()
(self.workdir / "subdir").mkdir()
result = bash.resolve_permission(BashArgs(command="mkdir subdir/child"))
assert isinstance(result, PermissionContext)
outside = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert len(outside) == 0
def test_rm_uses_arity_based_pattern(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something"))
assert isinstance(result, PermissionContext)
cmd_perms = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.COMMAND_PATTERN
]
assert len(cmd_perms) == 1
assert cmd_perms[0].session_pattern == "rm *"
def test_sensitive_sudo_exact_pattern(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="sudo apt install foo"))
assert isinstance(result, PermissionContext)
cmd_perms = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.COMMAND_PATTERN
]
assert cmd_perms[0].session_pattern == "sudo apt install foo"
def test_rmdir_uses_arity_based_pattern(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="rmdir foo"))
assert isinstance(result, PermissionContext)
cmd_perms = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.COMMAND_PATTERN
]
assert cmd_perms[0].session_pattern == "rmdir *"
def test_sensitive_bypasses_allowlist(self):
bash = self._bash(allowlist=["sudo"])
result = bash.resolve_permission(BashArgs(command="sudo ls"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
def test_allowlisted_outside_dir_still_asks(self):
bash = self._bash()
# cat is allowlisted but /etc/passwd is outside workdir
result = bash.resolve_permission(BashArgs(command="cat /etc/passwd"))
assert isinstance(result, PermissionContext)
outside = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert len(outside) == 1
def test_allowlisted_relative_traversal_outside_dir_still_asks(self):
bash = self._bash()
(self.workdir / "src").mkdir()
result = bash.resolve_permission(
BashArgs(command="cat src/../../../etc/passwd")
)
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
outside = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert len(outside) >= 1
def test_allowlisted_in_workdir_subdir_auto_approves(self):
bash = self._bash()
(self.workdir / "foo").mkdir()
(self.workdir / "foo" / "bar.txt").touch()
result = bash.resolve_permission(BashArgs(command="cat foo/bar.txt"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_allowlisted_in_workdir_auto_approves(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="cat README.md"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_mixed_allowlisted_and_not(self):
bash = self._bash()
result = bash.resolve_permission(
BashArgs(command="echo hello && npm install foo")
)
assert isinstance(result, PermissionContext)
cmd_perms = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.COMMAND_PATTERN
]
assert len(cmd_perms) == 1
assert cmd_perms[0].session_pattern == "npm install *"
def test_empty_command_returns_none(self):
bash = self._bash()
assert bash.resolve_permission(BashArgs(command="")) is None
def test_chmod_plus_skipped_as_flag(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="chmod +x /tmp/script.sh"))
assert isinstance(result, PermissionContext)
outside = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert len(outside) >= 1
class TestReadFileGranularPermissions:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
self.workdir = tmp_path
def _read_file(self, **kwargs):
config = ReadFileToolConfig(**kwargs)
return ReadFile(config=config, state=ReadFileState())
def test_in_workdir_normal_file_returns_none(self):
(self.workdir / "test.py").touch()
tool = self._read_file()
assert tool.resolve_permission(ReadFileArgs(path="test.py")) is None
def test_outside_workdir_returns_permission_context(self):
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path="/tmp/file.txt"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
outside = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert len(outside) == 1
def test_sensitive_env_file_returns_permission_context(self):
(self.workdir / ".env").touch()
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path=".env"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
sensitive = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.FILE_PATTERN
]
assert len(sensitive) == 1
assert sensitive[0].label.startswith("accessing sensitive files")
def test_sensitive_env_local_file(self):
(self.workdir / ".env.local").touch()
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path=".env.local"))
assert isinstance(result, PermissionContext)
sensitive = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.FILE_PATTERN
]
assert len(sensitive) == 1
def test_sensitive_outside_both_permissions(self):
tool = self._read_file()
result = tool.resolve_permission(ReadFileArgs(path="/tmp/.env"))
assert isinstance(result, PermissionContext)
scopes = {rp.scope for rp in result.required_permissions}
assert PermissionScope.FILE_PATTERN in scopes
assert PermissionScope.OUTSIDE_DIRECTORY in scopes
def test_denylisted_returns_never(self):
tool = self._read_file(denylist=["*/secret*"])
result = tool.resolve_permission(ReadFileArgs(path="secret.key"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_allowlisted_returns_always(self):
tool = self._read_file(allowlist=["*/README*"])
result = tool.resolve_permission(
ReadFileArgs(path=str(self.workdir / "README.md"))
)
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_custom_sensitive_patterns(self):
(self.workdir / "credentials.json").touch()
tool = self._read_file(sensitive_patterns=["*/credentials*"])
result = tool.resolve_permission(ReadFileArgs(path="credentials.json"))
assert isinstance(result, PermissionContext)
class TestWriteFileGranularPermissions:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
self.workdir = tmp_path
def _write_file(self):
config = WriteFileConfig()
return WriteFile(config=config, state=BaseToolState())
def test_in_workdir_returns_none(self):
tool = self._write_file()
assert (
tool.resolve_permission(WriteFileArgs(path="test.py", content="x")) is None
)
def test_outside_workdir_returns_permission_context(self):
tool = self._write_file()
result = tool.resolve_permission(
WriteFileArgs(path="/tmp/file.txt", content="x")
)
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
def test_sensitive_env_file_asks(self):
(self.workdir / ".env").touch()
tool = self._write_file()
result = tool.resolve_permission(
WriteFileArgs(path=".env", content="x", overwrite=True)
)
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
class TestSearchReplaceGranularPermissions:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
def test_outside_workdir_returns_permission_context(self):
config = SearchReplaceConfig()
tool = SearchReplace(config=config, state=BaseToolState())
result = tool.resolve_permission(
SearchReplaceArgs(file_path="/tmp/file.py", content="x")
)
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
class TestGrepGranularPermissions:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
self.workdir = tmp_path
def _grep(self):
config = GrepToolConfig()
return Grep(config=config, state=BaseToolState())
def test_in_workdir_normal_path_returns_none(self):
tool = self._grep()
assert tool.resolve_permission(GrepArgs(pattern="foo", path=".")) is None
def test_outside_workdir_returns_permission_context(self):
tool = self._grep()
result = tool.resolve_permission(GrepArgs(pattern="foo", path="/tmp"))
assert isinstance(result, PermissionContext)
def test_sensitive_env_directory(self):
(self.workdir / ".env").touch()
tool = self._grep()
result = tool.resolve_permission(GrepArgs(pattern="foo", path=".env"))
assert isinstance(result, PermissionContext)
sensitive = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.FILE_PATTERN
]
assert len(sensitive) == 1
class TestApprovalFlowSimulation:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
def _is_covered(
self, tool_name: str, rp: RequiredPermission, rules: list[ApprovedRule]
) -> bool:
return any(
rule.tool_name == tool_name
and rule.scope == rp.scope
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
for rule in rules
)
def test_mkdir_approved_covers_subsequent_mkdir(self):
rules = [
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="mkdir *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="mkdir another_dir"))
assert isinstance(result, PermissionContext)
uncovered = [
rp
for rp in result.required_permissions
if not self._is_covered("bash", rp, rules)
]
assert not any(rp.scope is PermissionScope.COMMAND_PATTERN for rp in uncovered)
def test_mkdir_approved_does_not_cover_npm(self):
rules = [
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="mkdir *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="npm install"))
assert isinstance(result, PermissionContext)
uncovered = [
rp
for rp in result.required_permissions
if not self._is_covered("bash", rp, rules)
]
assert len(uncovered) == 1
assert uncovered[0].session_pattern == "npm install *"
def test_outside_dir_approved_covers_subsequent(self):
bash = Bash(config=BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/newdir"))
assert isinstance(result, PermissionContext)
outside_rps = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
]
assert len(outside_rps) == 1
# Resolved pattern may differ per OS (e.g. /private/tmp/* on macOS)
rules = [
ApprovedRule(
tool_name="bash",
scope=PermissionScope.OUTSIDE_DIRECTORY,
session_pattern=outside_rps[0].session_pattern,
),
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="mkdir *",
),
]
uncovered = [
rp
for rp in result.required_permissions
if not self._is_covered("bash", rp, rules)
]
assert len(uncovered) == 0
def test_rm_approved_covers_subsequent_rm(self):
rules = [
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="rm *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something"))
assert isinstance(result, PermissionContext)
cmd_perms = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.COMMAND_PATTERN
]
assert cmd_perms[0].session_pattern == "rm *"
uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)]
assert len(uncovered) == 0
def test_sudo_exact_approval_doesnt_cover_different_invocation(self):
rules = [
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="sudo apt install foo",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="sudo apt install bar"))
assert isinstance(result, PermissionContext)
cmd_perms = [
rp
for rp in result.required_permissions
if rp.scope is PermissionScope.COMMAND_PATTERN
]
uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)]
assert len(uncovered) == 1
def test_read_file_sensitive_approved_covers_subsequent(self):
rules = [
ApprovedRule(
tool_name="read_file",
scope=PermissionScope.FILE_PATTERN,
session_pattern="*",
)
]
rp = RequiredPermission(
scope=PermissionScope.FILE_PATTERN,
invocation_pattern=".env.production",
session_pattern="*",
label="reading sensitive files (read_file)",
)
assert self._is_covered("read_file", rp, rules)
def test_different_tool_rule_doesnt_cover(self):
rules = [
ApprovedRule(
tool_name="bash",
scope=PermissionScope.COMMAND_PATTERN,
session_pattern="mkdir *",
)
]
rp = RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="mkdir foo",
session_pattern="mkdir *",
label="mkdir *",
)
assert not self._is_covered("grep", rp, rules)
class TestWebFetchPermissions:
def _make_webfetch(self) -> WebFetch:
return WebFetch(config=WebFetchConfig(), state=BaseToolState())
def test_returns_url_pattern_with_domain(self):
wf = self._make_webfetch()
result = wf.resolve_permission(
WebFetchArgs(url="https://docs.python.org/3/library")
)
assert isinstance(result, PermissionContext)
assert len(result.required_permissions) == 1
rp = result.required_permissions[0]
assert rp.scope is PermissionScope.URL_PATTERN
assert rp.invocation_pattern == "docs.python.org"
assert rp.session_pattern == "docs.python.org"
assert "docs.python.org" in rp.label
def test_http_url(self):
wf = self._make_webfetch()
result = wf.resolve_permission(WebFetchArgs(url="http://example.com/page"))
assert isinstance(result, PermissionContext)
rp = result.required_permissions[0]
assert rp.invocation_pattern == "example.com"
def test_url_without_scheme(self):
wf = self._make_webfetch()
result = wf.resolve_permission(WebFetchArgs(url="github.com/anthropics"))
assert isinstance(result, PermissionContext)
rp = result.required_permissions[0]
assert rp.invocation_pattern == "github.com"
def test_url_with_port(self):
wf = self._make_webfetch()
result = wf.resolve_permission(WebFetchArgs(url="http://localhost:8080/api"))
assert isinstance(result, PermissionContext)
rp = result.required_permissions[0]
assert rp.invocation_pattern == "localhost:8080"
def test_url_without_scheme_with_port(self):
wf = self._make_webfetch()
result = wf.resolve_permission(WebFetchArgs(url="example.com:3000/path"))
assert isinstance(result, PermissionContext)
rp = result.required_permissions[0]
assert rp.invocation_pattern == "example.com:3000"
def test_different_domains_not_covered(self):
rules = [
ApprovedRule(
tool_name="web_fetch",
scope=PermissionScope.URL_PATTERN,
session_pattern="docs.python.org",
)
]
rp = RequiredPermission(
scope=PermissionScope.URL_PATTERN,
invocation_pattern="evil.com",
session_pattern="evil.com",
label="fetching from evil.com",
)
covered = any(
rule.tool_name == "web_fetch"
and rule.scope == rp.scope
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
for rule in rules
)
assert not covered
def test_same_domain_covered(self):
rules = [
ApprovedRule(
tool_name="web_fetch",
scope=PermissionScope.URL_PATTERN,
session_pattern="docs.python.org",
)
]
rp = RequiredPermission(
scope=PermissionScope.URL_PATTERN,
invocation_pattern="docs.python.org",
session_pattern="docs.python.org",
label="fetching from docs.python.org",
)
covered = any(
rule.tool_name == "web_fetch"
and rule.scope == rp.scope
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
for rule in rules
)
assert covered
def test_double_slash_url(self):
wf = self._make_webfetch()
result = wf.resolve_permission(WebFetchArgs(url="//cdn.example.com/lib.js"))
assert isinstance(result, PermissionContext)
rp = result.required_permissions[0]
assert rp.invocation_pattern == "cdn.example.com"
def test_config_permission_always_honored(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.ALWAYS),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_config_permission_never_honored(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.NEVER),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_config_permission_ask_falls_through_to_domain(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.ASK), state=BaseToolState()
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
assert isinstance(result, PermissionContext)
assert result.required_permissions[0].invocation_pattern == "example.com"
class TestCollectOutsideDirs:
"""Tests for _collect_outside_dirs helper."""
@pytest.fixture(autouse=True)
def _setup(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
self.workdir = tmp_path
def test_relative_path_resolving_outside_workdir(self):
dirs = _collect_outside_dirs(["cat ../../etc/passwd"])
# The relative path resolves outside workdir, should collect parent dir
assert len(dirs) >= 1
def test_multiple_targets_in_one_command(self):
dirs = _collect_outside_dirs(["cp /tmp/a /var/b"])
assert len(dirs) == 2
def test_chmod_skips_plus_x_token(self):
dirs = _collect_outside_dirs(["chmod +x /tmp/script.sh"])
# +x should be skipped, only /tmp/script.sh should be considered
assert len(dirs) >= 1
# Verify no dir was created from the "+x" token
for d in dirs:
assert "+x" not in d
def test_empty_command_list(self):
assert _collect_outside_dirs([]) == set()
def test_home_relative_path(self):
home = os.path.expanduser("~")
dirs = _collect_outside_dirs(["cat ~/some_file"])
# ~/some_file resolves to home directory, which is likely outside workdir
if home != str(self.workdir):
assert len(dirs) >= 1
def test_in_workdir_path_not_collected(self):
(self.workdir / "local_file").touch()
dirs = _collect_outside_dirs(["cat ./local_file"])
assert len(dirs) == 0
def test_traversal_path_without_dot_prefix(self):
"""Paths like src/../../../etc/passwd don't start with . but contain /."""
(self.workdir / "src").mkdir()
dirs = _collect_outside_dirs(["cat src/../../../etc/passwd"])
assert len(dirs) >= 1
def test_in_workdir_subdir_path_not_collected(self):
"""foo/bar inside workdir should not be flagged."""
(self.workdir / "foo").mkdir()
(self.workdir / "foo" / "bar").touch()
dirs = _collect_outside_dirs(["cat foo/bar"])
assert len(dirs) == 0

View file

@ -46,7 +46,10 @@ class TestInvokeContext:
def test_approval_callback_can_be_set(self) -> None:
async def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
_tool_name: str,
_args: BaseModel,
_tool_call_id: str,
_rp: list | None = None,
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None
@ -76,7 +79,10 @@ class TestToolInvokeWithContext:
@pytest.mark.asyncio
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
async def dummy_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
_tool_name: str,
_args: BaseModel,
_tool_call_id: str,
_rp: list | None = None,
) -> tuple[ApprovalResponse, str | None]:
return ApprovalResponse.YES, None

View file

@ -36,7 +36,7 @@ def test_merges_user_overrides_with_defaults():
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tools={"bash": BaseToolConfig(permission=ToolPermission.ALWAYS)},
tools={"bash": {"permission": "always"}},
)
manager = ToolManager(lambda: vibe_config)
@ -53,9 +53,9 @@ def test_preserves_tool_specific_fields_from_overrides():
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tools={"bash": BaseToolConfig(permission=ToolPermission.ASK)},
tools={"bash": {"permission": "ask"}},
)
vibe_config.tools["bash"].__pydantic_extra__ = {"default_timeout": 600}
vibe_config.tools["bash"]["default_timeout"] = 600
manager = ToolManager(lambda: vibe_config)
config = manager.get_tool_config("bash")
@ -71,6 +71,23 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager):
assert config.permission == ToolPermission.ASK
def test_partial_override_preserves_tool_defaults():
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tools={"read_file": {"max_read_bytes": 32000}},
)
manager = ToolManager(lambda: vibe_config)
config = manager.get_tool_config("read_file")
assert (
config.permission == ToolPermission.ALWAYS
) # ReadFileToolConfig default, not BaseToolConfig.ASK
assert config.sensitive_patterns == ["**/.env", "**/.env.*"] # type: ignore[attr-defined]
assert config.max_read_bytes == 32000 # type: ignore[attr-defined]
class TestToolManagerFiltering:
def test_enabled_tools_filters_to_only_enabled(self):
vibe_config = build_test_vibe_config(

217
tests/tools/test_skill.py Normal file
View file

@ -0,0 +1,217 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from tests.mock.utils import collect_result
from vibe.core.skills.manager import SkillManager
from vibe.core.skills.models import SkillInfo
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.skill import (
Skill,
SkillArgs,
SkillResult,
SkillToolConfig,
)
from vibe.core.tools.permissions import PermissionScope
def _make_skill_dir(
tmp_path: Path,
name: str = "my-skill",
description: str = "A test skill",
body: str = "## Instructions\n\nDo the thing.",
extra_files: list[str] | None = None,
) -> SkillInfo:
skill_dir = tmp_path / name
skill_dir.mkdir(parents=True, exist_ok=True)
content = f"---\nname: {name}\ndescription: {description}\n---\n\n{body}"
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
for f in extra_files or []:
file_path = skill_dir / f
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(f"content of {f}", encoding="utf-8")
return SkillInfo(
name=name, description=description, skill_path=skill_dir / "SKILL.md"
)
def _make_skill_manager(skills: dict[str, SkillInfo]) -> SkillManager:
manager = MagicMock(spec=SkillManager)
manager.available_skills = skills
manager.get_skill.side_effect = lambda n: skills.get(n)
return manager
def _make_ctx(skill_manager: SkillManager | None = None) -> InvokeContext:
return InvokeContext(tool_call_id="test-call", skill_manager=skill_manager)
@pytest.fixture
def skill_tool() -> Skill:
return Skill(config=SkillToolConfig(), state=BaseToolState())
class TestSkillRun:
@pytest.mark.asyncio
async def test_loads_skill_content(self, tmp_path: Path, skill_tool: Skill) -> None:
info = _make_skill_dir(tmp_path, body="Follow these steps:\n1. Do A\n2. Do B")
manager = _make_skill_manager({"my-skill": info})
ctx = _make_ctx(manager)
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
assert isinstance(result, SkillResult)
assert result.name == "my-skill"
assert "Follow these steps:" in result.content
assert "1. Do A" in result.content
assert '<skill_content name="my-skill">' in result.content
assert "# Skill: my-skill" in result.content
assert "</skill_content>" in result.content
@pytest.mark.asyncio
async def test_lists_bundled_files(self, tmp_path: Path, skill_tool: Skill) -> None:
info = _make_skill_dir(
tmp_path, extra_files=["scripts/run.sh", "references/guide.md"]
)
manager = _make_skill_manager({"my-skill": info})
ctx = _make_ctx(manager)
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
assert "<skill_files>" in result.content
assert "<file>scripts/run.sh</file>" in result.content
assert "<file>references/guide.md</file>" in result.content
assert f"<file>{info.skill_dir / 'scripts/run.sh'}</file>" not in result.content
@pytest.mark.asyncio
async def test_excludes_skill_md_from_file_list(
self, tmp_path: Path, skill_tool: Skill
) -> None:
info = _make_skill_dir(tmp_path, extra_files=["helper.py"])
manager = _make_skill_manager({"my-skill": info})
ctx = _make_ctx(manager)
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
assert "SKILL.md" not in result.content.split("<skill_files>")[1]
assert "helper.py" in result.content
@pytest.mark.asyncio
async def test_caps_file_list_at_ten(
self, tmp_path: Path, skill_tool: Skill
) -> None:
files = [f"file{i:02d}.txt" for i in range(15)]
info = _make_skill_dir(tmp_path, extra_files=files)
manager = _make_skill_manager({"my-skill": info})
ctx = _make_ctx(manager)
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
file_section = result.content.split("<skill_files>")[1].split("</skill_files>")[
0
]
assert file_section.count("<file>") == 10
@pytest.mark.asyncio
async def test_empty_skill_directory(
self, tmp_path: Path, skill_tool: Skill
) -> None:
info = _make_skill_dir(tmp_path)
manager = _make_skill_manager({"my-skill": info})
ctx = _make_ctx(manager)
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
assert "<skill_files>\n\n</skill_files>" in result.content
@pytest.mark.asyncio
async def test_returns_skill_dir(self, tmp_path: Path, skill_tool: Skill) -> None:
info = _make_skill_dir(tmp_path)
manager = _make_skill_manager({"my-skill": info})
ctx = _make_ctx(manager)
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
assert result.skill_dir == str(info.skill_dir)
@pytest.mark.asyncio
async def test_includes_base_directory(
self, tmp_path: Path, skill_tool: Skill
) -> None:
info = _make_skill_dir(tmp_path)
manager = _make_skill_manager({"my-skill": info})
ctx = _make_ctx(manager)
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
assert f"Base directory for this skill: {info.skill_dir}" in result.content
class TestSkillErrors:
@pytest.mark.asyncio
async def test_no_context(self, skill_tool: Skill) -> None:
with pytest.raises(ToolError, match="Skill manager not available"):
await collect_result(skill_tool.run(SkillArgs(name="test"), ctx=None))
@pytest.mark.asyncio
async def test_no_skill_manager(self, skill_tool: Skill) -> None:
ctx = _make_ctx(skill_manager=None)
with pytest.raises(ToolError, match="Skill manager not available"):
await collect_result(skill_tool.run(SkillArgs(name="test"), ctx=ctx))
@pytest.mark.asyncio
async def test_skill_not_found(self, skill_tool: Skill) -> None:
manager = _make_skill_manager({"alpha": MagicMock(), "beta": MagicMock()})
ctx = _make_ctx(manager)
with pytest.raises(ToolError, match='Skill "missing" not found'):
await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx))
@pytest.mark.asyncio
async def test_skill_not_found_lists_available(self, skill_tool: Skill) -> None:
manager = _make_skill_manager({"alpha": MagicMock(), "beta": MagicMock()})
ctx = _make_ctx(manager)
with pytest.raises(ToolError, match="alpha, beta"):
await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx))
@pytest.mark.asyncio
async def test_unreadable_skill_file(
self, tmp_path: Path, skill_tool: Skill
) -> None:
info = SkillInfo(
name="broken",
description="Broken skill",
skill_path=tmp_path / "nonexistent" / "SKILL.md",
)
manager = _make_skill_manager({"broken": info})
ctx = _make_ctx(manager)
with pytest.raises(ToolError, match="Cannot load skill file"):
await collect_result(skill_tool.run(SkillArgs(name="broken"), ctx=ctx))
class TestSkillPermission:
def test_resolve_permission_returns_file_pattern(self, skill_tool: Skill) -> None:
perm = skill_tool.resolve_permission(SkillArgs(name="my-skill"))
assert perm is not None
assert len(perm.required_permissions) == 1
assert perm.required_permissions[0].scope == PermissionScope.FILE_PATTERN
assert perm.required_permissions[0].invocation_pattern == "my-skill"
assert perm.required_permissions[0].session_pattern == "my-skill"
class TestSkillMeta:
def test_tool_name(self) -> None:
assert Skill.get_name() == "skill"
def test_description_is_set(self) -> None:
assert "skill" in Skill.description.lower()
assert len(Skill.description) > 20

View file

@ -10,6 +10,7 @@ from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
from vibe.core.tools.permissions import PermissionContext
from vibe.core.types import AssistantEvent, LLMMessage, Role
@ -80,7 +81,8 @@ class TestTaskToolResolvePermission:
def test_explore_allowed_by_default(self, task_tool: Task) -> None:
args = TaskArgs(task="do something", agent="explore")
result = task_tool.resolve_permission(args)
assert result == ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_unknown_agent_returns_none(self, task_tool: Task) -> None:
args = TaskArgs(task="do something", agent="custom_agent")
@ -92,21 +94,24 @@ class TestTaskToolResolvePermission:
tool = Task(config=config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert result == ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_glob_pattern_in_allowlist(self) -> None:
config = TaskToolConfig(allowlist=["exp*"])
tool = Task(config=config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert result == ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_glob_pattern_in_denylist(self) -> None:
config = TaskToolConfig(denylist=["danger*"])
tool = Task(config=config, state=BaseToolState())
args = TaskArgs(task="do something", agent="dangerous_agent")
result = tool.resolve_permission(args)
assert result == ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_empty_lists_returns_none(self) -> None:
config = TaskToolConfig(allowlist=[], denylist=[])

View file

@ -14,9 +14,10 @@ from mistralai.client.models import (
import pytest
from tests.mock.utils import collect_result
from vibe.core.config import Backend, ProviderConfig
from vibe.core.config import ProviderConfig
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
from vibe.core.types import Backend
def _make_response(

View file

@ -0,0 +1,66 @@
from __future__ import annotations
from vibe.core.tools.utils import wildcard_match
class TestWildcardMatch:
def test_exact_match(self):
assert wildcard_match("hello", "hello")
def test_exact_no_match(self):
assert not wildcard_match("hello", "world")
def test_star_matches_any(self):
assert wildcard_match("hello world", "hello *")
def test_star_matches_empty_trailing(self):
assert wildcard_match("mkdir", "mkdir *")
def test_star_matches_with_args(self):
assert wildcard_match("mkdir hello", "mkdir *")
def test_star_matches_long_trailing(self):
assert wildcard_match("git commit -m hello world", "git commit *")
def test_star_in_middle(self):
assert wildcard_match("fooXbar", "foo*bar")
def test_question_mark_single_char(self):
assert wildcard_match("cat", "c?t")
def test_question_mark_no_match(self):
assert not wildcard_match("ct", "c?t")
def test_glob_path_pattern(self):
assert wildcard_match("/tmp/dir/file.txt", "/tmp/dir/*")
def test_glob_nested_path(self):
assert wildcard_match("/tmp/dir/sub/file.txt", "/tmp/dir/*")
def test_glob_no_match(self):
assert not wildcard_match("/home/user/file.txt", "/tmp/*")
def test_special_regex_chars_in_text(self):
assert wildcard_match("echo (hello)", "echo *")
def test_special_regex_chars_in_pattern(self):
assert wildcard_match(".env", ".env")
assert not wildcard_match("xenv", ".env")
def test_fnmatch_character_class(self):
assert wildcard_match("vache", "[bcghlmstv]ache")
def test_empty_pattern_empty_text(self):
assert wildcard_match("", "")
def test_star_only(self):
assert wildcard_match("anything goes here", "*")
def test_trailing_space_star_is_optional(self):
assert wildcard_match("ls", "ls *")
assert wildcard_match("ls -la", "ls *")
assert wildcard_match("ls -la /tmp", "ls *")
def test_non_trailing_star_is_greedy(self):
assert wildcard_match("abc123def", "abc*def")
assert not wildcard_match("abc123de", "abc*def")

View file

@ -39,10 +39,17 @@ async def _empty_audio_stream() -> AsyncIterator[bytes]:
yield
def _make_sdk_session_created() -> MagicMock:
from mistralai.client.models import RealtimeTranscriptionSessionCreated
def _make_sdk_session_created(request_id: str = "test-request-id") -> MagicMock:
from mistralai.client.models import (
RealtimeTranscriptionSession,
RealtimeTranscriptionSessionCreated,
)
return MagicMock(spec=RealtimeTranscriptionSessionCreated)
session = MagicMock(spec=RealtimeTranscriptionSession)
session.request_id = request_id
mock = MagicMock(spec=RealtimeTranscriptionSessionCreated)
mock.session = session
return mock
def _make_sdk_text_delta(text: str) -> MagicMock:
@ -104,6 +111,7 @@ class TestEventMapping:
assert len(events) == 1
assert isinstance(events[0], TranscribeSessionCreated)
assert events[0].request_id == "test-request-id"
@pytest.mark.asyncio
async def test_text_delta(self) -> None:

View file

@ -0,0 +1,100 @@
from __future__ import annotations
import base64
import httpx
import pytest
from vibe.core.config import TTSModelConfig, TTSProviderConfig
from vibe.core.tts import MistralTTSClient, TTSResult
def _make_provider() -> TTSProviderConfig:
return TTSProviderConfig(
name="mistral",
api_base="https://api.mistral.ai",
api_key_env_var="MISTRAL_API_KEY",
)
def _make_model() -> TTSModelConfig:
return TTSModelConfig(
name="voxtral-mini-tts-latest",
alias="voxtral-tts",
provider="mistral",
voice="gb_jane_neutral",
)
class TestMistralTTSClientInit:
def test_client_configured_with_base_url_and_auth(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
client = MistralTTSClient(_make_provider(), _make_model())
assert str(client._client.base_url) == "https://api.mistral.ai/v1/"
assert client._client.headers["authorization"] == "Bearer test-key"
class TestMistralTTSClient:
@pytest.mark.asyncio
async def test_speak_returns_decoded_audio(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
raw_audio = b"fake-audio-data-for-testing"
encoded_audio = base64.b64encode(raw_audio).decode()
async def mock_post(self_client, url, **kwargs):
assert url == "/audio/speech"
body = kwargs["json"]
assert body["model"] == "voxtral-mini-tts-latest"
assert body["input"] == "Hello"
assert body["voice_id"] == "gb_jane_neutral"
assert body["stream"] is False
assert body["response_format"] == "wav"
return httpx.Response(
status_code=200,
json={"audio_data": encoded_audio},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
client = MistralTTSClient(_make_provider(), _make_model())
result = await client.speak("Hello")
assert isinstance(result, TTSResult)
assert result.audio_data == raw_audio
await client.close()
@pytest.mark.asyncio
async def test_speak_raises_on_http_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
async def mock_post(self_client, url, **kwargs):
return httpx.Response(
status_code=500,
json={"error": "Internal Server Error"},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
client = MistralTTSClient(_make_provider(), _make_model())
with pytest.raises(httpx.HTTPStatusError):
await client.speak("Hello")
await client.close()
@pytest.mark.asyncio
async def test_close_closes_underlying_client(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
client = MistralTTSClient(_make_provider(), _make_model())
await client.close()
assert client._client.is_closed

View file

@ -0,0 +1,44 @@
from __future__ import annotations
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
class TestTranscriptionTrackingState:
def test_reset_clears_accumulated_state(self) -> None:
state = TranscriptionTrackingState()
state.set_recording_id("req-1")
state.record_text("hello")
state.set_recording_duration(5.0)
state.reset()
assert state.recording_id == ""
assert state.accumulated_transcript_length == 0
assert state.last_recording_duration_ms is None
def test_set_recording_id(self) -> None:
state = TranscriptionTrackingState()
state.set_recording_id("req-abc")
assert state.recording_id == "req-abc"
def test_record_text_accumulates_length(self) -> None:
state = TranscriptionTrackingState()
state.reset()
state.record_text("hello ")
state.record_text("world")
assert state.accumulated_transcript_length == 11
def test_elapsed_ms_returns_positive_value(self) -> None:
state = TranscriptionTrackingState()
state.reset()
assert state.elapsed_ms() >= 0
def test_set_recording_duration_converts_seconds_to_ms(self) -> None:
state = TranscriptionTrackingState()
state.set_recording_duration(2.5)
assert state.last_recording_duration_ms == 2500.0
def test_default_state(self) -> None:
state = TranscriptionTrackingState()
assert state.recording_id == ""
assert state.accumulated_transcript_length == 0
assert state.last_recording_duration_ms is None

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@ -47,12 +47,16 @@ def _make_manager(
*,
voice_mode_enabled: bool = True,
transcribe_client: FakeTranscribeClient | None = None,
telemetry_client: MagicMock | 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
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=client,
telemetry_client=telemetry_client,
)
return manager, recorder, client
@ -175,7 +179,7 @@ class TestStopRecording:
async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeError(
message="Cannot flush audio before sending any audio bytes"
),
@ -304,7 +308,7 @@ class TestTranscription:
async def test_text_deltas_notify_listeners(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="hello "),
TranscribeTextDelta(text="world"),
TranscribeDone(),
@ -323,7 +327,7 @@ class TestTranscription:
async def test_transcription_error_does_not_crash(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(),
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="partial"),
TranscribeError(message="something broke"),
TranscribeDone(),
@ -341,7 +345,10 @@ class TestTranscription:
@pytest.mark.asyncio
async def test_cancel_during_transcription(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")]
events=[
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="hello"),
]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
@ -355,7 +362,10 @@ class TestTranscription:
@pytest.mark.asyncio
async def test_session_created_is_silent(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(), TranscribeDone()]
events=[
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeDone(),
]
)
manager, _, _ = _make_manager(transcribe_client=client)
listener = StateListener()
@ -392,3 +402,178 @@ class TestTranscription:
assert manager.transcribe_state == TranscribeState.IDLE
assert not recorder.is_recording
def _find_telemetry_calls(
mock: MagicMock, event_name: str
) -> list[dict[str, str | int | float | None]]:
"""Return the properties dicts for all calls matching a given event name."""
results: list[dict[str, str | int | float | None]] = []
for call in mock.send_telemetry_event.call_args_list:
if call[0][0] == event_name:
results.append(call[0][1])
return results
class TestTelemetryTracking:
@pytest.mark.asyncio
async def test_start_sends_transcription_start_event(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(request_id="req-123"), TranscribeDone()]
)
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(
transcribe_client=client, telemetry_client=mock_telemetry
)
manager.start_recording()
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start")
assert len(calls) == 1
assert calls[0]["recording_id"] == "req-123"
@pytest.mark.asyncio
async def test_cancel_sends_cancel_event(self) -> None:
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(telemetry_client=mock_telemetry)
manager.start_recording()
manager.cancel_recording()
calls = _find_telemetry_calls(
mock_telemetry, "vibe.audio.transcription.cancel_recording"
)
assert len(calls) == 1
recording_duration_ms = calls[0]["recording_duration_ms"]
assert isinstance(recording_duration_ms, (int, float))
assert recording_duration_ms >= 0
@pytest.mark.asyncio
async def test_done_sends_done_event(self) -> None:
client = FakeTranscribeClient(
events=[
TranscribeSessionCreated(request_id="test-req-id"),
TranscribeTextDelta(text="hello "),
TranscribeTextDelta(text="world"),
TranscribeDone(),
]
)
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(
transcribe_client=client, telemetry_client=mock_telemetry
)
manager.start_recording()
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.done")
assert len(calls) == 1
assert calls[0]["recording_id"] == "test-req-id"
assert calls[0]["transcript_length"] == len("hello ") + len("world")
transcription_duration_ms = calls[0]["transcription_duration_ms"]
assert isinstance(transcription_duration_ms, (int, float))
assert transcription_duration_ms >= 0
recording_duration_ms = calls[0]["recording_duration_ms"]
assert isinstance(recording_duration_ms, (int, float))
assert recording_duration_ms >= 0
@pytest.mark.asyncio
async def test_error_sends_error_event(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
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
mock_telemetry = MagicMock()
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=CrashingTranscribeClient(),
telemetry_client=mock_telemetry,
)
manager.start_recording()
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error")
assert len(calls) == 1
error_message = calls[0]["error_message"]
assert isinstance(error_message, str)
assert "network error" in error_message
transcription_duration_ms = calls[0]["transcription_duration_ms"]
assert isinstance(transcription_duration_ms, (int, float))
assert transcription_duration_ms >= 0
@pytest.mark.asyncio
async def test_no_telemetry_when_client_is_none(self) -> None:
manager, _, _ = _make_manager() # no telemetry_client
manager.start_recording()
manager.cancel_recording()
# No error raised — tracking is silently skipped
@pytest.mark.asyncio
async def test_each_recording_uses_session_request_id(self) -> None:
client = FakeTranscribeClient(
events=[TranscribeSessionCreated(request_id="req-first"), TranscribeDone()]
)
mock_telemetry = MagicMock()
manager, _, _ = _make_manager(
transcribe_client=client, telemetry_client=mock_telemetry
)
manager.start_recording()
await manager.stop_recording()
client.set_events([
TranscribeSessionCreated(request_id="req-second"),
TranscribeDone(),
])
manager.start_recording()
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start")
assert len(calls) == 2
assert calls[0]["recording_id"] == "req-first"
assert calls[1]["recording_id"] == "req-second"
@pytest.mark.asyncio
async def test_timeout_sends_error_event(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)
mock_telemetry = MagicMock()
manager = VoiceManager(
config_getter=lambda: config,
audio_recorder=recorder,
transcribe_client=HangingTranscribeClient(),
telemetry_client=mock_telemetry,
)
manager.start_recording()
with patch(
"vibe.cli.voice_manager.voice_manager.TRANSCRIPTION_DRAIN_TIMEOUT", 0.01
):
await manager.stop_recording()
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error")
assert len(calls) == 1
error_message = calls[0]["error_message"]
assert isinstance(error_message, str)
assert "timed out" in error_message.lower()