v2.6.0 (#524)
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>
|
|
@ -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(
|
||||
|
|
|
|||
254
tests/acp/test_acp_entrypoint_smoke.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
254
tests/acp/test_usage_update.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
206
tests/audio_player/test_audio_player.py
Normal 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()
|
||||
|
|
@ -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!"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
77
tests/cli/test_feedback_bar.py
Normal 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
|
||||
284
tests/cli/test_ui_config_and_model_picker.py
Normal 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
|
||||
45
tests/cli/test_ui_session_exit.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
127
tests/core/test_config_otel.py
Normal 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
|
||||
135
tests/core/test_local_config_walk.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
107
tests/core/test_tts_config.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]]: ...
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
138
tests/e2e/test_cli_tui_session_exit.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="556.8" textLength="146.4" clip-path="url(#terminal-line-22)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="556.8" textLength="122" clip-path="url(#terminal-line-22)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="581.2" textLength="414.8" clip-path="url(#terminal-line-23)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">Type </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)"> for more 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 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 closed (no changes 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)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </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)"> for more 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 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: </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: </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 watcher (may delay first autocompletion): </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)">↑↓ Navigate  Enter Select/Toggle  Esc 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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </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)"> for more 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 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: </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: </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 watcher (may delay first autocompletion): </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)">↑↓ Navigate  Enter Select/Toggle  Esc 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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </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)"> for more 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 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: </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: </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 watcher (may delay first autocompletion): </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)">↑↓ Navigate  Enter Select/Toggle  Esc 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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="329.4" clip-path="url(#terminal-line-27)">Sure, I can help with 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 is Vibe doing so far?  </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)">: good  </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)">: fine  </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)">: 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="605.6" textLength="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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="427" clip-path="url(#terminal-line-25)">5 models · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type </text><text class="terminal-r3" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)"> for more information</text><text class="terminal-r1" x="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)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="427" clip-path="url(#terminal-line-20)">5 models · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </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)"> for more 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 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)">  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)">› </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)">  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)">  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)">  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)">↑↓ Navigate  Enter Select  Esc 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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="427" clip-path="url(#terminal-line-20)">5 models · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </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)"> for more 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 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)">  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)">› </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)">  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)">  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)">  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)">↑↓ Navigate  Enter Select  Esc 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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="581.2" textLength="146.4" clip-path="url(#terminal-line-23)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="581.2" textLength="122" clip-path="url(#terminal-line-23)"> v0.0.0 · </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)"> · [Subscription] 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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="605.6" textLength="414.8" clip-path="url(#terminal-line-24)">1 model · 0 MCP servers · 0 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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="630" textLength="61" clip-path="url(#terminal-line-25)">Type </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)"> for more 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 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)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1,201 @@
|
|||
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #ff8205 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1463.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello! I can help you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello! I can help you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-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)"> speaking </text><text class="terminal-r6" x="170.8" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">esc to 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello! I can help you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-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)"> summarizing </text><text class="terminal-r6" x="183" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">esc to 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)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</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)">></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% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type </text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)"> for more information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-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 settings 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 mode disabled.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -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)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type </text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)"> for more information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-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 settings 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 mode enabled. Press ctrl+r to start recording.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
63
tests/snapshots/test_ui_snapshot_config_app.py
Normal 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,
|
||||
)
|
||||
46
tests/snapshots/test_ui_snapshot_feedback_bar.py
Normal 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,
|
||||
)
|
||||
95
tests/snapshots/test_ui_snapshot_model_picker.py
Normal 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,
|
||||
)
|
||||
148
tests/snapshots/test_ui_snapshot_narrator_flow.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
36
tests/stubs/fake_audio_player.py
Normal 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()
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
21
tests/stubs/fake_tts_client.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 *"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
750
tests/tools/test_granular_permissions.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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=[])
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
66
tests/tools/test_wildcard_match.py
Normal 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")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
100
tests/tts/test_tts_client.py
Normal 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
|
||||
44
tests/voice_manager/test_telemetry.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||