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

View File

@@ -64,10 +64,8 @@ jobs:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Install Python
run: uv python install 3.12
- name: Sync dependencies
run: uv sync --no-dev --group build
@@ -86,19 +84,20 @@ jobs:
shell: pwsh
run: python -c "import subprocess; version = subprocess.check_output(['uv', 'version']).decode().split()[1]; print(f'version={version}')" >> $env:GITHUB_OUTPUT
- name: Upload binary as artifact (Unix)
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
- name: Smoke test bundled binary (Unix)
if: ${{ matrix.os != 'windows' }}
with:
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_unix.outputs.version }}
path: dist/vibe-acp
run: ./dist/vibe-acp-dir/vibe-acp --version
- name: Upload binary as artifact (Windows)
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
- name: Smoke test bundled binary (Windows)
if: ${{ matrix.os == 'windows' }}
shell: pwsh
run: .\dist\vibe-acp-dir\vibe-acp.exe --version
- name: Upload binary as artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_windows.outputs.version }}
path: dist/vibe-acp.exe
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.os == 'windows' && steps.get_version_windows.outputs.version || steps.get_version_unix.outputs.version }}
path: dist/vibe-acp-dir/
attach-to-release:
needs: build-and-upload
@@ -117,11 +116,9 @@ jobs:
mkdir release-assets
for dir in artifacts/*; do
name=$(basename "$dir")
if [ -f "$dir/vibe-acp" ]; then
chmod +x "$dir/vibe-acp"
zip -j "release-assets/${name}.zip" "$dir/vibe-acp"
elif [ -f "$dir/vibe-acp.exe" ]; then
zip -j "release-assets/${name}.zip" "$dir/vibe-acp.exe"
if [ -f "$dir/vibe-acp" ] || [ -f "$dir/vibe-acp.exe" ]; then
chmod -f +x "$dir/vibe-acp" 2>/dev/null || true
(cd "$dir" && zip -r "../../release-assets/${name}.zip" .)
fi
done

2
.vscode/launch.json vendored
View File

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

View File

@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.6.0] - 2026-03-23
### Added
- OTEL tracing support for observability
- Skill tool for managing task lists and workflows
- Text-to-speech (TTS) functionality
- Standalone --resume command for session picker
- BFS for vibe folders to improve startup performance
- List-based model picker for /model command
- is_user_prompt flag to Mistral metadata header
- Correlation ID in user feedback calls
- Current date added to system prompt in vibe-work
- TypeScript type inference for large tool outputs in vibe-work-harness
### Changed
- Updated agent-client-protocol to 0.9.0a1
- Changed inline code color from yellow to green
- Removed "You have no internet access" from CLI prompt
- Fine-grained permission system improvements
- Inject system certs into vibe-acp frozen binary via truststore
### Fixed
- Streaming for currently streamed message when switching agents
- Proper UI updates when tools switch current agents
- Space key functionality when holding shift
- Empty TextChunk not appended when reasoning has no text content
- Messages removed from user feedback event
- Bash allowlist/denylist activation on Windows
- Improved scrolling performance
- ACP error handling in webview
- Context usage updates sent via ACP
- Include `exit_plan_mode` tool only in plan mode
## [2.5.0] - 2026-03-16
### Added

View File

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

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
import truststore
# Inject system certificates into ssl before the frozen app starts.
truststore.inject_into_ssl()

View File

@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.5.0"
version = "2.6.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -27,7 +27,7 @@ classifiers = [
"Topic :: Utilities",
]
dependencies = [
"agent-client-protocol==0.8.1",
"agent-client-protocol==0.9.0a1",
"anyio>=4.12.0",
"cachetools>=5.5.0",
"cryptography>=44.0.0,<=46.0.3",
@@ -39,6 +39,10 @@ dependencies = [
"markdownify>=1.2.2",
"mcp>=1.14.0",
"mistralai==2.0.0",
"opentelemetry-api>=1.39.1",
"opentelemetry-exporter-otlp-proto-http>=1.39.1",
"opentelemetry-sdk>=1.39.1",
"opentelemetry-semantic-conventions>=0.60b1",
"packaging>=24.1",
"pexpect>=4.9.0",
"pydantic>=2.12.4",
@@ -103,18 +107,21 @@ dev = [
"vulture>=2.14",
]
build = ["pyinstaller>=6.17.0"]
build = ["pyinstaller>=6.17.0", "truststore>=0.10.4"]
[tool.pyright]
pythonVersion = "3.12"
reportMissingTypeStubs = false
reportPrivateImportUsage = false
include = ["vibe/**/*.py", "tests/**/*.py"]
exclude = ["pyinstaller/"]
venvPath = "."
venv = ".venv"
[tool.ruff]
include = ["vibe/**/*.py", "tests/**/*.py"]
exclude = ["pyinstaller/"]
line-length = 88
target-version = "py312"
preview = true

View File

@@ -130,7 +130,9 @@ def create_release_branch(version: str) -> None:
print(f"Created and switched to branch {branch_name}")
def cherry_pick_commits(previous_version: str, current_version: str) -> None:
def cherry_pick_commits(
previous_version: str, current_version: str, squash: bool
) -> None:
previous_tag = f"v{previous_version}-private"
current_tag = f"v{current_version}-private"
@@ -150,6 +152,53 @@ def cherry_pick_commits(previous_version: str, current_version: str) -> None:
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
print("Successfully cherry-picked all commits")
if squash:
squash_commits(previous_version, current_version, previous_tag, current_tag)
def squash_commits(
previous_version: str, current_version: str, previous_tag: str, current_tag: str
) -> None:
print("Squashing commits into a single release commit...")
run_git_command("reset", "--soft", f"v{previous_version}")
# Get all contributors between previous and current private tags
result = run_git_command(
"log",
f"{previous_tag}..{current_tag}",
"--format=%aN <%aE>",
capture_output=True,
)
contributors = result.stdout.strip().split("\n")
# Get current user
current_user_result = run_git_command("config", "user.email", capture_output=True)
current_user_email = current_user_result.stdout.strip()
# Filter out current user and create co-authored lines
vibe_marker = "vibe@mistral.ai"
unique_coauthors = {
f"Co-authored-by: {contributor}"
for contributor in contributors
if contributor
and current_user_email not in contributor
and vibe_marker not in contributor
}
# Add Mistral Vibe as co-author
coauthored_lines = sorted(unique_coauthors) + [
"Co-authored-by: Mistral Vibe <vibe@mistral.ai>"
]
# Create commit message
commit_message = f"v{current_version}\n"
for line in coauthored_lines:
commit_message += f"\n{line}"
# Create the commit
run_git_command("commit", "-m", commit_message)
print("Successfully created release commit with co-authors")
def get_commits_summary(previous_version: str, current_version: str) -> str:
previous_tag = f"v{previous_version}-private"
@@ -182,6 +231,7 @@ def print_summary(
previous_version: str,
commits_summary: str,
changelog_entry: str,
squash: bool,
) -> None:
print("\n" + "=" * 80)
print("RELEASE PREPARATION SUMMARY")
@@ -204,14 +254,15 @@ def print_summary(
print(changelog_entry)
print("\n" + "-" * 80)
print("NEXT STEPS")
print("-" * 80)
print(
f"To review/edit commits before publishing, use interactive rebase:\n"
f" git rebase -i v{previous_version}"
)
if not squash:
print("NEXT STEPS")
print("-" * 80)
print(
f"To review/edit commits before publishing, use interactive rebase:\n"
f" git rebase -i v{previous_version}"
)
print("\n" + "-" * 80)
print("\n" + "-" * 80)
print("REMINDERS")
print("-" * 80)
print("Before publishing the release:")
@@ -231,9 +282,17 @@ def main() -> None:
)
parser.add_argument("version", help="Version to prepare release for (e.g., 1.1.3)")
parser.add_argument(
"--no-squash",
action="store_false",
dest="squash",
default=True,
help="Disable squashing of commits into a single release commit",
)
args = parser.parse_args()
current_version = args.version
squash = args.squash
try:
# Step 1: Ensure public remote exists
@@ -264,7 +323,7 @@ def main() -> None:
create_release_branch(current_version)
# Step 7: Cherry-pick commits
cherry_pick_commits(previous_version, current_version)
cherry_pick_commits(previous_version, current_version, squash)
# Step 8: Get summary information
commits_summary = get_commits_summary(previous_version, current_version)
@@ -272,7 +331,7 @@ def main() -> None:
# Step 9: Print summary
print_summary(
current_version, previous_version, commits_summary, changelog_entry
current_version, previous_version, commits_summary, changelog_entry, squash
)
except Exception as e:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -180,13 +180,13 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type&#160;</text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type&#160;</text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r2" x="24.4" y="654.4" textLength="73.2" clip-path="url(#terminal-line-26)">/voice</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="73.2" clip-path="url(#terminal-line-25)">/voice</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Voice&#160;settings&#160;opened...</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="244" clip-path="url(#terminal-line-27)">Voice&#160;mode&#160;disabled.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -180,13 +180,13 @@
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type&#160;</text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type&#160;</text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r2" x="24.4" y="654.4" textLength="73.2" clip-path="url(#terminal-line-26)">/voice</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r2" x="24.4" y="630" textLength="73.2" clip-path="url(#terminal-line-25)">/voice</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Voice&#160;settings&#160;opened...</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="634.4" clip-path="url(#terminal-line-27)">Voice&#160;mode&#160;enabled.&#160;Press&#160;ctrl+r&#160;to&#160;start&#160;recording.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

371
tests/test_tracing.py Normal file
View File

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

349
tests/test_turn_summary.py Normal file
View File

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

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

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

View File

@@ -5,6 +5,7 @@ import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
from vibe.core.tools.permissions import PermissionContext
@pytest.fixture
@@ -80,7 +81,10 @@ def test_find_not_in_default_allowlist():
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
# find -exec runs arbitrary commands; must not be allowlisted by default
permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
assert permission is not ToolPermission.ALWAYS
assert (
not isinstance(permission, PermissionContext)
or permission.permission is not ToolPermission.ALWAYS
)
def test_resolve_permission():
@@ -92,9 +96,13 @@ def test_resolve_permission():
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
empty = bash_tool.resolve_permission(BashArgs(command=""))
assert allowlisted is ToolPermission.ALWAYS
assert denylisted is ToolPermission.NEVER
assert mixed is None
assert isinstance(allowlisted, PermissionContext)
assert allowlisted.permission is ToolPermission.ALWAYS
assert isinstance(denylisted, PermissionContext)
assert denylisted.permission is ToolPermission.NEVER
assert isinstance(mixed, PermissionContext)
assert mixed.permission is ToolPermission.ASK
assert any(rp.label == "whoami *" for rp in mixed.required_permissions)
assert empty is None
@@ -108,80 +116,95 @@ class TestResolvePermissionWindowsSyntax:
def test_dir_with_windows_flags_allowlisted(self):
bash_tool = self._make_bash(allowlist=["dir"])
result = bash_tool.resolve_permission(BashArgs(command="dir /s /b"))
assert result is ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_type_command_allowlisted(self):
bash_tool = self._make_bash(allowlist=["type"])
result = bash_tool.resolve_permission(BashArgs(command="type file.txt"))
assert result is ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_findstr_allowlisted(self):
bash_tool = self._make_bash(allowlist=["findstr"])
result = bash_tool.resolve_permission(
BashArgs(command="findstr /s pattern *.txt")
)
assert result is ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_ver_allowlisted(self):
bash_tool = self._make_bash(allowlist=["ver"])
result = bash_tool.resolve_permission(BashArgs(command="ver"))
assert result is ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_where_allowlisted(self):
bash_tool = self._make_bash(allowlist=["where"])
result = bash_tool.resolve_permission(BashArgs(command="where python"))
assert result is ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_cmd_k_denylisted(self):
bash_tool = self._make_bash(denylist=["cmd /k"])
result = bash_tool.resolve_permission(BashArgs(command="cmd /k something"))
assert result is ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_powershell_noexit_denylisted(self):
bash_tool = self._make_bash(denylist=["powershell -NoExit"])
result = bash_tool.resolve_permission(BashArgs(command="powershell -NoExit"))
assert result is ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_notepad_denylisted(self):
bash_tool = self._make_bash(denylist=["notepad"])
result = bash_tool.resolve_permission(BashArgs(command="notepad file.txt"))
assert result is ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_cmd_standalone_denylisted(self):
bash_tool = self._make_bash(denylist_standalone=["cmd"])
result = bash_tool.resolve_permission(BashArgs(command="cmd"))
assert result is ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_powershell_standalone_denylisted(self):
bash_tool = self._make_bash(denylist_standalone=["powershell"])
result = bash_tool.resolve_permission(BashArgs(command="powershell"))
assert result is ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_powershell_cmdlet_asks(self):
bash_tool = self._make_bash(allowlist=["dir", "echo"])
result = bash_tool.resolve_permission(BashArgs(command="Get-ChildItem -Path ."))
assert result is None
assert isinstance(result, PermissionContext)
assert result.permission == ToolPermission.ASK
def test_mixed_allowed_and_unknown_asks(self):
bash_tool = self._make_bash(allowlist=["git status"])
result = bash_tool.resolve_permission(
BashArgs(command="git status && npm install")
)
assert result is None
assert isinstance(result, PermissionContext)
assert result.permission == ToolPermission.ASK
def test_chained_windows_commands_all_allowed(self):
bash_tool = self._make_bash(allowlist=["dir", "echo"])
result = bash_tool.resolve_permission(BashArgs(command="dir /s && echo done"))
assert result is ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS
def test_chained_commands_one_denied(self):
bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"])
result = bash_tool.resolve_permission(BashArgs(command="dir /s && rm -rf /"))
assert result is ToolPermission.NEVER
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.NEVER
def test_piped_windows_commands(self):
bash_tool = self._make_bash(allowlist=["findstr", "type"])
result = bash_tool.resolve_permission(
BashArgs(command="type file.txt | findstr pattern")
)
assert result is ToolPermission.ALWAYS
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

116
uv.lock generated
View File

@@ -4,14 +4,14 @@ requires-python = ">=3.12"
[[package]]
name = "agent-client-protocol"
version = "0.8.1"
version = "0.9.0a1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/7b/7cdac86db388809d9e3bc58cac88cc7dfa49b7615b98fab304a828cd7f8a/agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd", size = 68866, upload-time = "2026-02-13T15:34:54.567Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/dc/1ec56897b461fbdb844c9bff3abbbe225bfe8cda020dc2449101a6d76592/agent_client_protocol-0.9.0a1.tar.gz", hash = "sha256:9e6fc8b72df465279470920d679c871e0c658f69212e345563cc69b17906b606", size = 70423, upload-time = "2026-03-19T18:44:47.117Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/f3/219eeca0ad4a20843d4b9eaac5532f87018b9d25730a62a16f54f6c52d1a/agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e", size = 54529, upload-time = "2026-02-13T15:34:53.344Z" },
{ url = "https://files.pythonhosted.org/packages/cd/59/b794d5247aac2693a562ddd6eb2092581331496815967a6bca6bbf086b4f/agent_client_protocol-0.9.0a1-py3-none-any.whl", hash = "sha256:3e0962df15c3c7dd2957daea2f47db5e644fd897e77180e492f0a27d9fdb7bf4", size = 55945, upload-time = "2026-03-19T18:44:45.924Z" },
]
[[package]]
@@ -398,6 +398,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.73.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -758,7 +770,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.5.0"
version = "2.6.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@@ -773,6 +785,10 @@ dependencies = [
{ name = "markdownify" },
{ name = "mcp" },
{ name = "mistralai" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "pexpect" },
{ name = "pydantic" },
@@ -796,6 +812,7 @@ dependencies = [
[package.dev-dependencies]
build = [
{ name = "pyinstaller" },
{ name = "truststore" },
]
dev = [
{ name = "debugpy" },
@@ -815,7 +832,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "agent-client-protocol", specifier = "==0.8.1" },
{ name = "agent-client-protocol", specifier = "==0.9.0a1" },
{ name = "anyio", specifier = ">=4.12.0" },
{ name = "cachetools", specifier = ">=5.5.0" },
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
@@ -827,6 +844,10 @@ requires-dist = [
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" },
{ name = "mistralai", specifier = "==2.0.0" },
{ name = "opentelemetry-api", specifier = ">=1.39.1" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.1" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.1" },
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.60b1" },
{ name = "packaging", specifier = ">=24.1" },
{ name = "pexpect", specifier = ">=4.9.0" },
{ name = "pydantic", specifier = ">=2.12.4" },
@@ -848,7 +869,10 @@ requires-dist = [
]
[package.metadata.requires-dev]
build = [{ name = "pyinstaller", specifier = ">=6.17.0" }]
build = [
{ name = "pyinstaller", specifier = ">=6.17.0" },
{ name = "truststore", specifier = ">=0.10.4" },
]
dev = [
{ name = "debugpy", specifier = ">=1.8.19" },
{ name = "pre-commit", specifier = ">=4.2.0" },
@@ -948,6 +972,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.60b1"
@@ -1025,6 +1105,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]]
name = "protobuf"
version = "6.33.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"
@@ -1865,6 +1960,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/75/4ca1a9fabd8fb5aea78cea70f7837ce4dbf2afae115f62051e5fa99cba1c/tree_sitter_bash-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:59115057ec2bae319e8082ff29559861045002964c3431ccb0fc92aa4bc9bccb", size = 191196, upload-time = "2025-12-02T17:01:07.486Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "twine"
version = "6.2.0"

View File

@@ -1,4 +1,7 @@
# -*- mode: python ; coding: utf-8 -*-
# Onedir build for vibe-acp — no per-launch extraction overhead.
# Build: uv run --group build pyinstaller vibe-acp.spec
# Output: dist/vibe-acp-dir/vibe-acp (+ dist/vibe-acp-dir/_internal/)
from PyInstaller.utils.hooks import collect_all
@@ -7,7 +10,7 @@ core_builtins_deps = collect_all('vibe.core.tools.builtins')
acp_builtins_deps = collect_all('vibe.acp.tools.builtins')
# Extract hidden imports and binaries, filtering to ensure only strings are in hiddenimports
hidden_imports = []
hidden_imports = ["truststore"]
for item in core_builtins_deps[2] + acp_builtins_deps[2]:
if isinstance(item, str):
hidden_imports.append(item)
@@ -31,7 +34,7 @@ a = Analysis(
hiddenimports=hidden_imports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
runtime_hooks=["pyinstaller/runtime_hook_truststore.py"],
excludes=[],
noarchive=False,
optimize=0,
@@ -41,8 +44,6 @@ pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='vibe-acp',
debug=False,
@@ -50,7 +51,6 @@ exe = EXE(
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
@@ -58,3 +58,13 @@ exe = EXE(
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='vibe-acp-dir',
)

View File

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

View File

@@ -26,11 +26,14 @@ from acp.schema import (
AgentThoughtChunk,
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
AuthMethodAgent,
AvailableCommand,
AvailableCommandInput,
ClientCapabilities,
CloseSessionResponse,
ContentToolCallContent,
Cost,
EnvVarAuthMethod,
ForkSessionResponse,
HttpMcpServer,
Implementation,
@@ -43,11 +46,14 @@ from acp.schema import (
SessionListCapabilities,
SetSessionConfigOptionResponse,
SseMcpServer,
TerminalAuthMethod,
TextContentBlock,
TextResourceContents,
ToolCallProgress,
ToolCallUpdate,
UnstructuredCommandInput,
Usage,
UsageUpdate,
UserMessageChunk,
)
from pydantic import BaseModel, ConfigDict
@@ -71,8 +77,8 @@ from vibe.acp.tools.session_update import (
tool_result_session_update,
)
from vibe.acp.utils import (
TOOL_OPTIONS,
ToolOption,
build_permission_options,
create_assistant_message_replay,
create_compact_end_session_update,
create_compact_start_session_update,
@@ -101,8 +107,9 @@ from vibe.core.proxy_setup import (
unset_proxy_var,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import (
AgentProfileChangedEvent,
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
@@ -173,9 +180,10 @@ class VibeAcpAgentLoop(AcpAgent):
and self.client_capabilities.field_meta.get("terminal-auth") is True
)
auth_methods = (
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = (
[
AuthMethod(
TerminalAuthMethod(
type="terminal",
id="vibe-setup",
name="Register your API Key",
description="Register your API Key inside Mistral Vibe",
@@ -228,9 +236,7 @@ class VibeAcpAgentLoop(AcpAgent):
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(
disabled_tools=["ask_user_question", "exit_plan_mode"]
)
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
@@ -263,18 +269,22 @@ class VibeAcpAgentLoop(AcpAgent):
config = self._load_config()
agent_loop = AgentLoop(
config=config,
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
# We should just use agent_loop.session_id everywhere, but it can still change during
# session lifetime (e.g. agent_loop.compact is called).
# We should refactor agent_loop.session_id to make it immutable in ACP context.
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
try:
agent_loop = AgentLoop(
config=config,
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
# We should just use agent_loop.session_id everywhere, but it can still change during
# session lifetime (e.g. agent_loop.compact is called).
# We should refactor agent_loop.session_id to make it immutable in ACP context.
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
except Exception as e:
raise ConfigurationError(str(e)) from e
agent_loop.emit_new_session_telemetry()
modes_state, modes_config = make_mode_response(
@@ -314,17 +324,15 @@ class VibeAcpAgentLoop(AcpAgent):
session = self._get_session(session_id)
def _handle_permission_selection(
option_id: str, tool_name: str
option_id: str,
tool_name: str,
required_permissions: list[RequiredPermission] | None,
) -> tuple[ApprovalResponse, str | None]:
match option_id:
case ToolOption.ALLOW_ONCE:
return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS:
if tool_name not in session.agent_loop.config.tools:
session.agent_loop.config.tools[tool_name] = BaseToolConfig()
session.agent_loop.config.tools[
tool_name
].permission = ToolPermission.ALWAYS
session.agent_loop.approve_always(tool_name, required_permissions)
return (ApprovalResponse.YES, None)
case ToolOption.REJECT_ONCE:
return (
@@ -335,19 +343,33 @@ class VibeAcpAgentLoop(AcpAgent):
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
async def approval_callback(
tool_name: str, args: BaseModel, tool_call_id: str
tool_name: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list | None = None,
) -> tuple[ApprovalResponse, str | None]:
# Create the tool call update
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
response = await self.client.request_permission(
session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS
typed_permissions: list[RequiredPermission] | None = (
[
rp
for rp in required_permissions
if isinstance(rp, RequiredPermission)
]
if required_permissions
else None
)
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
options = build_permission_options(typed_permissions)
response = await self.client.request_permission(
session_id=session_id, tool_call=tool_call, options=options
)
# Parse the response using isinstance for proper type narrowing
if response.outcome.outcome == "selected":
outcome = cast(AllowedOutcome, response.outcome)
return _handle_permission_selection(outcome.option_id, tool_name)
return _handle_permission_selection(
outcome.option_id, tool_name, typed_permissions
)
else:
return (
ApprovalResponse.NO,
@@ -365,6 +387,39 @@ class VibeAcpAgentLoop(AcpAgent):
raise SessionNotFoundError(session_id)
return self.sessions[session_id]
def _build_usage(self, session: AcpSessionLoop) -> Usage:
stats = session.agent_loop.stats
return Usage(
input_tokens=stats.session_prompt_tokens,
output_tokens=stats.session_completion_tokens,
total_tokens=stats.session_total_llm_tokens,
)
def _build_usage_update(self, session: AcpSessionLoop) -> UsageUpdate:
stats = session.agent_loop.stats
active_model = session.agent_loop.config.get_active_model()
cost = (
Cost(amount=stats.session_cost, currency="USD")
if stats.input_price_per_million > 0 or stats.output_price_per_million > 0
else None
)
return UsageUpdate(
session_update="usage_update",
used=stats.context_tokens,
size=active_model.auto_compact_threshold,
cost=cost,
)
def _send_usage_update(self, session: AcpSessionLoop) -> None:
async def _send() -> None:
try:
update = self._build_usage_update(session)
await self.client.session_update(session_id=session.id, update=update)
except Exception:
pass
asyncio.create_task(_send())
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
if not msg.tool_calls:
return
@@ -463,7 +518,7 @@ class VibeAcpAgentLoop(AcpAgent):
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
@@ -492,7 +547,7 @@ class VibeAcpAgentLoop(AcpAgent):
})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
@@ -549,6 +604,7 @@ class VibeAcpAgentLoop(AcpAgent):
session = await self._create_acp_session(session_id, agent_loop)
await self._replay_conversation_history(session_id, non_system_messages)
self._send_usage_update(session)
modes_state, modes_config = make_mode_response(
list(agent_loop.agent_manager.available_agents.values()),
@@ -589,7 +645,7 @@ class VibeAcpAgentLoop(AcpAgent):
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
@@ -675,7 +731,11 @@ class VibeAcpAgentLoop(AcpAgent):
@override
async def prompt(
self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
self,
prompt: list[ContentBlock],
session_id: str,
message_id: str | None = None,
**kwargs: Any,
) -> PromptResponse:
session = self._get_session(session_id)
@@ -708,7 +768,10 @@ class VibeAcpAgentLoop(AcpAgent):
await session.task
except asyncio.CancelledError:
return PromptResponse(stop_reason="cancelled")
self._send_usage_update(session)
return PromptResponse(
stop_reason="cancelled", usage=self._build_usage(session)
)
except CoreRateLimitError as e:
raise RateLimitError.from_core(e) from e
@@ -722,7 +785,8 @@ class VibeAcpAgentLoop(AcpAgent):
finally:
session.task = None
return PromptResponse(stop_reason="end_turn")
self._send_usage_update(session)
return PromptResponse(stop_reason="end_turn", usage=self._build_usage(session))
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
text_prompt = ""
@@ -841,6 +905,15 @@ class VibeAcpAgentLoop(AcpAgent):
elif isinstance(event, CompactEndEvent):
yield create_compact_end_session_update(event)
elif isinstance(event, AgentProfileChangedEvent):
pass
@override
async def close_session(
self, session_id: str, **kwargs: Any
) -> CloseSessionResponse | None:
raise NotImplementedMethodError("close_session")
@override
async def cancel(self, session_id: str, **kwargs: Any) -> None:
session = self._get_session(session_id)

View File

@@ -8,7 +8,7 @@ import sys
import tomli_w
from vibe import __version__
from vibe.core.config import VibeConfig
from vibe.core.config import MissingAPIKeyError, VibeConfig
from vibe.core.config.harness_files import (
get_harness_files_manager,
init_harness_files_manager,
@@ -78,13 +78,23 @@ def main() -> None:
init_harness_files_manager("user", "project")
from vibe.acp.acp_agent_loop import run_acp_server
from vibe.core.config import VibeConfig, load_dotenv_values
from vibe.core.tracing import setup_tracing
from vibe.setup.onboarding import run_onboarding
load_dotenv_values()
bootstrap_config_files()
args = parse_arguments()
if args.setup:
run_onboarding()
sys.exit(0)
try:
config = VibeConfig.load()
setup_tracing(config)
except MissingAPIKeyError:
pass # tracing disabled, but server can still handle the error properly in new_session
run_acp_server()

View File

@@ -9,7 +9,6 @@ from acp.schema import (
ContentToolCallContent,
ModelInfo,
PermissionOption,
SessionConfigOption,
SessionConfigOptionSelect,
SessionConfigSelectOption,
SessionMode,
@@ -23,6 +22,7 @@ from acp.schema import (
from vibe.core.agents.models import AgentProfile, AgentType
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display
@@ -45,7 +45,7 @@ TOOL_OPTIONS = [
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name="Allow always",
name="Allow for this session",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
),
PermissionOption(
@@ -56,6 +56,44 @@ TOOL_OPTIONS = [
]
def build_permission_options(
required_permissions: list[RequiredPermission] | None,
) -> list[PermissionOption]:
"""Build ACP permission options, including granular labels when available."""
if not required_permissions:
return TOOL_OPTIONS
labels = ", ".join(rp.label for rp in required_permissions)
permissions_meta = [
{
"scope": rp.scope,
"invocation_pattern": rp.invocation_pattern,
"session_pattern": rp.session_pattern,
"label": rp.label,
}
for rp in required_permissions
]
return [
PermissionOption(
option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name=f"Allow for this session: {labels}",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
field_meta={"required_permissions": permissions_meta},
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
),
]
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
return any(
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
@@ -64,7 +102,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
def make_mode_response(
profiles: list[AgentProfile], current_mode_id: str
) -> tuple[SessionModeState, SessionConfigOption]:
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
session_modes: list[SessionMode] = []
config_options: list[SessionConfigSelectOption] = []
@@ -89,22 +127,20 @@ def make_mode_response(
state = SessionModeState(
current_mode_id=current_mode_id, available_modes=session_modes
)
config = SessionConfigOption(
root=SessionConfigOptionSelect(
id="mode",
name="Session Mode",
current_value=current_mode_id,
category="mode",
type="select",
options=config_options,
)
config = SessionConfigOptionSelect(
id="mode",
name="Session Mode",
current_value=current_mode_id,
category="mode",
type="select",
options=config_options,
)
return state, config
def make_model_response(
models: list[ModelConfig], current_model_id: str
) -> tuple[SessionModelState, SessionConfigOption]:
) -> tuple[SessionModelState, SessionConfigOptionSelect]:
model_infos: list[ModelInfo] = []
config_options: list[SessionConfigSelectOption] = []
@@ -119,15 +155,13 @@ def make_model_response(
state = SessionModelState(
current_model_id=current_model_id, available_models=model_infos
)
config_option = SessionConfigOption(
root=SessionConfigOptionSelect(
id="model",
name="Model",
current_value=current_model_id,
category="model",
type="select",
options=config_options,
)
config_option = SessionConfigOptionSelect(
id="model",
name="Model",
current_value=current_model_id,
category="model",
type="select",
options=config_options,
)
return state, config_option

View File

@@ -8,7 +8,7 @@ from rich import print as rprint
import tomli_w
from vibe import __version__
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
@@ -22,6 +22,7 @@ from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tracing import setup_tracing
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
@@ -104,6 +105,8 @@ def load_session(
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
elif args.resume is True:
return None
else:
session_to_load = SessionLoader.find_session_by_id(
args.resume, config.session_logging
@@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None:
try:
initial_agent_name = get_initial_agent_name(args)
config = load_config_or_exit()
setup_tracing(config)
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
@@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
run_textual_ui(
agent_loop=agent_loop,
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
startup=StartupOptions(
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
show_resume_picker=args.resume is True,
),
)
except (KeyboardInterrupt, EOFError):

View File

@@ -22,10 +22,15 @@ class CommandRegistry:
handler="_show_help",
),
"config": Command(
aliases=frozenset(["/config", "/model"]),
aliases=frozenset(["/config"]),
description="Edit config settings",
handler="_show_config",
),
"model": Command(
aliases=frozenset(["/model"]),
description="Select active model",
handler="_show_model",
),
"reload": Command(
aliases=frozenset(["/reload"]),
description="Reload configuration from disk",
@@ -79,8 +84,8 @@ class CommandRegistry:
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Toggle voice mode on/off",
handler="_toggle_voice_mode",
description="Configure voice settings",
handler="_show_voice_settings",
),
"leanstall": Command(
aliases=frozenset(["/leanstall"]),

View File

@@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace:
)
continuation_group.add_argument(
"--resume",
nargs="?",
const=True,
default=None,
metavar="SESSION_ID",
help="Resume a specific session by its ID (supports partial matching)",
help="Resume a session. Without SESSION_ID, shows an interactive picker.",
)
return parser.parse_args()

View File

@@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
from vibe.core.types import Backend
logger = logging.getLogger(__name__)

View File

@@ -9,6 +9,8 @@ import platform
import subprocess
from typing import Any, Literal
from vibe.core.utils.io import read_safe
class Terminal(Enum):
VSCODE = "vscode"
@@ -189,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = keybindings_path.read_text()
content = read_safe(keybindings_path)
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum, auto
import gc
import os
@@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import (
NotificationPort,
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.messages import (
@@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import (
WarningMessage,
WhatsNewMessage,
)
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
@@ -65,6 +72,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
from vibe.cli.textual_ui.windowing import (
HISTORY_RESUME_TAIL_MESSAGES,
LOAD_MORE_BATCH_SIZE,
@@ -76,6 +84,13 @@ from vibe.cli.textual_ui.windowing import (
should_resume_history,
sync_backfill_state,
)
from vibe.cli.turn_summary import (
NoopTurnSummary,
TurnSummaryPort,
TurnSummaryResult,
TurnSummaryTracker,
create_narrator_backend,
)
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
PyPIUpdateGateway,
@@ -92,9 +107,11 @@ from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_player.audio_player_port import AudioFormat
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import Backend, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
@@ -109,17 +126,20 @@ from vibe.core.teleport.types import (
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
)
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
AskUserQuestionResult,
Choice,
Question,
)
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.tts.factory import make_tts_client
from vibe.core.tts.tts_client_port import TTSClientPort
from vibe.core.types import (
AgentStats,
ApprovalResponse,
Backend,
LLMMessage,
RateLimitError,
Role,
@@ -129,6 +149,7 @@ from vibe.core.utils import (
get_user_cancellation_message,
is_dangerous_directory,
)
from vibe.core.utils.io import read_safe
class BottomApp(StrEnum):
@@ -142,9 +163,11 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
Input = auto()
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
SessionPicker = auto()
Voice = auto()
class ChatScroll(VerticalScroll):
@@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll):
def is_at_bottom(self) -> bool:
return self.scroll_target_y >= (self.max_scroll_y - 3)
def _check_anchor(self) -> None:
if self._anchored and self._anchor_released and self.is_at_bottom:
self._anchor_released = False
_reanchor_pending: bool = False
_scrolling_down: bool = False
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
super().watch_scroll_y(old_value, new_value)
self._scrolling_down = new_value >= old_value
def release_anchor(self) -> None:
super().release_anchor()
# Textual's MRO dispatch calls Widget._on_mouse_scroll_down AFTER
# our override, so any re-anchor we do gets immediately undone.
# Defer the re-check until all handlers for this event have finished.
if not self._reanchor_pending:
self._reanchor_pending = True
self.call_later(self._maybe_reanchor)
def _maybe_reanchor(self) -> None:
self._reanchor_pending = False
if (
self._anchored
and self._anchor_released
and self.is_at_bottom
and self._scrolling_down
):
self.anchor()
def update_node_styles(self, animate: bool = True) -> None:
pass
@@ -202,6 +247,13 @@ async def prune_oldest_children(
return True
@dataclass(frozen=True, slots=True)
class StartupOptions:
initial_prompt: str | None = None
teleport_on_start: bool = False
show_resume_picker: bool = False
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
@@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904
def __init__(
self,
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
startup: StartupOptions | None = None,
update_notifier: UpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
@@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904
self._update_cache_repository = update_cache_repository
self._current_version = current_version
self._plan_offer_gateway = plan_offer_gateway
self._initial_prompt = initial_prompt
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled
self._show_resume_picker = opts.show_resume_picker
self._last_escape_time: float | None = None
self._banner: Banner | None = None
self._whats_new_message: WhatsNewMessage | None = None
@@ -287,6 +340,12 @@ class VibeApp(App): # noqa: PLR0904
self._cached_loading_area: Widget | None = None
self._switch_agent_generation = 0
self._plan_info: PlanInfo | None = None
self._turn_summary: TurnSummaryPort = self._make_turn_summary()
self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set()
self._tts_client: TTSClientPort | None = self._make_tts_client()
self._audio_player = AudioPlayer()
self._speak_task: asyncio.Task[None] | None = None
self._cancel_summary: Callable[[], bool] | None = None
@property
def config(self) -> VibeConfig:
@@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904
yield VerticalGroup(id="messages")
with Horizontal(id="loading-area"):
yield NarratorStatus()
yield Static(id="loading-area-content")
yield FeedbackBar()
with Static(id="bottom-app-container"):
yield ChatInputContainer(
@@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904
self._cached_messages_area = self.query_one("#messages")
self._cached_chat = self.query_one("#chat", ChatScroll)
self._cached_loading_area = self.query_one("#loading-area-content")
self._feedback_bar = self.query_one(FeedbackBar)
self.event_handler = EventHandler(
mount_callback=self._mount_and_scroll,
get_tools_collapsed=lambda: self._tools_collapsed,
on_profile_changed=self._on_profile_changed,
)
self._chat_input_container = self.query_one(ChatInputContainer)
@@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(self._refresh_banner)
if self._initial_prompt or self._teleport_on_start:
if self._show_resume_picker:
self.run_worker(self._show_session_picker(), exclusive=False)
elif self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
gc.collect()
@@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_approval_app_approval_granted_always_tool(
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
) -> None:
self._set_tool_permission_always(
message.tool_name, save_permanently=message.save_permanently
)
self.agent_loop.approve_always(message.tool_name, message.required_permissions)
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
@@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
def on_chat_text_area_feedback_key_pressed(
self, message: ChatTextArea.FeedbackKeyPressed
) -> None:
self._feedback_bar.handle_feedback_key(message.rating)
def on_chat_text_area_non_feedback_key_pressed(
self, message: ChatTextArea.NonFeedbackKeyPressed
) -> None:
self._feedback_bar.hide()
def on_feedback_bar_feedback_given(
self, message: FeedbackBar.FeedbackGiven
) -> None:
self.agent_loop.telemetry_client.send_user_rating_feedback(
rating=message.rating, model=self.config.active_model
)
async def _remove_loading_widget(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
self._loading_widget = None
async def on_config_app_open_model_picker(
self, _message: ConfigApp.OpenModelPicker
) -> None:
config_app = self.query_one(ConfigApp)
changes = config_app._convert_changes_for_save()
if changes:
VibeConfig.save_updates(changes)
await self._reload_config()
await self._switch_to_input_app()
await self._switch_to_model_picker_app()
async def on_config_app_config_closed(
self, message: ConfigApp.ConfigClosed
) -> None:
if message.changes:
VibeConfig.save_updates(message.changes)
await self._handle_config_settings_closed(message.changes)
await self._switch_to_input_app()
async def on_voice_app_config_closed(self, message: VoiceApp.ConfigClosed) -> None:
await self._handle_voice_settings_closed(message.changes)
await self._switch_to_input_app()
async def _handle_config_settings_closed(
self, changes: dict[str, str | bool]
) -> None:
if changes:
VibeConfig.save_updates(changes)
await self._reload_config()
else:
await self._mount_and_scroll(
UserCommandMessage("Configuration closed (no changes saved).")
)
async def _handle_voice_settings_closed(
self, changes: dict[str, str | bool]
) -> None:
if not changes:
await self._mount_and_scroll(
UserCommandMessage("Voice settings closed (no changes saved).")
)
return
if "voice_mode_enabled" in changes:
current = self._voice_manager.is_enabled
desired = changes["voice_mode_enabled"]
if current != desired:
self._voice_manager.toggle_voice_mode()
self.agent_loop.telemetry_client.send_telemetry_event(
"vibe.voice_mode_toggled", {"enabled": desired}
)
self.agent_loop.refresh_config()
if desired:
await self._mount_and_scroll(
UserCommandMessage(
"Voice mode enabled. Press ctrl+r to start recording."
)
)
else:
await self._mount_and_scroll(
UserCommandMessage("Voice mode disabled.")
)
non_voice_changes = {
k: v for k, v in changes.items() if k != "voice_mode_enabled"
}
if non_voice_changes:
VibeConfig.save_updates(non_voice_changes)
self.agent_loop.refresh_config()
self._sync_turn_summary()
async def on_model_picker_app_model_selected(
self, message: ModelPickerApp.ModelSelected
) -> None:
VibeConfig.save_updates({"active_model": message.alias})
await self._reload_config()
await self._switch_to_input_app()
async def on_model_picker_app_cancelled(
self, _event: ModelPickerApp.Cancelled
) -> None:
await self._switch_to_input_app()
async def on_proxy_setup_app_proxy_setup_closed(
@@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904
for widget in children[:compact_index]:
await widget.remove()
def _set_tool_permission_always(
self, tool_name: str, save_permanently: bool = False
) -> None:
self.agent_loop.set_tool_permission(
tool_name, ToolPermission.ALWAYS, save_permanently
)
async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input):
if cmd_name := self.commands.get_command_name(user_input):
@@ -557,7 +698,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
skill_content = read_safe(skill_info.skill_path)
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
@@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904
user_message = UserMessage(message)
await self._mount_and_scroll(user_message)
if self.agent_loop.telemetry_client.is_active():
self._feedback_bar.maybe_show()
if not self._agent_running:
self._agent_task = asyncio.create_task(
@@ -682,7 +825,11 @@ class VibeApp(App): # noqa: PLR0904
return tool in self.agent_loop.tool_manager.available_tools
async def _approval_callback(
self, tool: str, args: BaseModel, tool_call_id: str
self,
tool: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list[RequiredPermission] | None,
) -> tuple[ApprovalResponse, str | None]:
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
# This ensures subagents respect the main agent's tool restrictions
@@ -695,7 +842,7 @@ class VibeApp(App): # noqa: PLR0904
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
await self._switch_to_approval_app(tool, args, required_permissions)
result = await self._pending_approval
return result
finally:
@@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904
self._pending_question = None
await self._switch_to_input_app()
async def _handle_turn_error(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
@@ -730,7 +883,10 @@ class VibeApp(App): # noqa: PLR0904
try:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._cancel_speak()
self._turn_summary.start_turn(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._turn_summary.track(event)
if self.event_handler:
await self.event_handler.handle_event(
event,
@@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904
)
except asyncio.CancelledError:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
await self._handle_turn_error()
self._turn_summary.cancel_turn()
raise
except Exception as e:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
await self._handle_turn_error()
message = str(e)
if isinstance(e, RateLimitError):
message = self._rate_limit_message()
self._turn_summary.set_error(message)
await self._mount_and_scroll(
ErrorMessage(message, collapsed=self._tools_collapsed)
)
finally:
cancel_summary = self._turn_summary.end_turn()
if (
cancel_summary is not None
and self.config.narrator_enabled
and self._tts_client is not None
):
self._cancel_summary = cancel_summary
self.query_one(NarratorStatus).state = NarratorState.SUMMARIZING
self._agent_running = False
self._interrupt_requested = False
self._agent_task = None
@@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_config_app()
async def _show_model(self) -> None:
"""Switch to the model picker in the bottom panel."""
if self._current_bottom_app == BottomApp.ModelPicker:
return
await self._switch_to_model_picker_app()
async def _show_proxy_setup(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@@ -1045,6 +1211,7 @@ class VibeApp(App): # noqa: PLR0904
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
await self._resolve_plan()
self._sync_turn_summary()
if self._banner:
self._banner.set_state(
@@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904
lambda: self.config,
audio_recorder=AudioRecorder(),
transcribe_client=transcribe_client,
telemetry_client=self.agent_loop.telemetry_client,
)
async def _toggle_voice_mode(self) -> None:
result = self._voice_manager.toggle_voice_mode()
self.agent_loop.refresh_config()
if result.enabled:
msg = "Voice mode enabled. Press ctrl+r to start recording."
else:
msg = "Voice mode disabled."
await self._mount_and_scroll(UserCommandMessage(msg))
async def _show_voice_settings(self) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._switch_to_voice_app()
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container")
@@ -1249,6 +1413,8 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = False
self._chat_input_container.disabled = True
self._feedback_bar.hide()
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
await bottom_container.mount(widget)
@@ -1263,6 +1429,23 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
await self._switch_from_input(ConfigApp(self.config))
async def _switch_to_voice_app(self) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._mount_and_scroll(UserCommandMessage("Voice settings opened..."))
await self._switch_from_input(VoiceApp(self.config))
async def _switch_to_model_picker_app(self) -> None:
if self._current_bottom_app == BottomApp.ModelPicker:
return
model_aliases = [m.alias for m in self.config.models]
current_model = str(self.config.active_model)
await self._switch_from_input(
ModelPickerApp(model_aliases=model_aliases, current_model=current_model)
)
async def _switch_to_proxy_setup_app(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_from_input(ProxySetupApp())
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission] | None = None,
) -> None:
approval_app = ApprovalApp(
tool_name=tool_name, tool_args=tool_args, config=self.config
tool_name=tool_name,
tool_args=tool_args,
config=self.config,
required_permissions=required_permissions,
)
await self._switch_from_input(approval_app, scroll=True)
@@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ChatInputContainer).focus_input()
case BottomApp.Config:
self.query_one(ConfigApp).focus()
case BottomApp.ModelPicker:
self.query_one(ModelPickerApp).focus()
case BottomApp.ProxySetup:
self.query_one(ProxySetupApp).focus()
case BottomApp.Approval:
@@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case BottomApp.Voice:
self.query_one(VoiceApp).focus()
case app:
assert_never(app)
except Exception:
@@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904
pass
self._last_escape_time = None
def _handle_voice_app_escape(self) -> None:
try:
voice_app = self.query_one(VoiceApp)
voice_app.action_close()
except Exception:
pass
self._last_escape_time = None
def _handle_approval_app_escape(self) -> None:
try:
approval_app = self.query_one(ApprovalApp)
@@ -1345,6 +1546,14 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_model_picker_app_escape(self) -> None:
try:
model_picker = self.query_one(ModelPickerApp)
model_picker.post_message(ModelPickerApp.Cancelled())
except Exception:
pass
self._last_escape_time = None
def _handle_session_picker_app_escape(self) -> None:
try:
session_picker = self.query_one(SessionPickerApp)
@@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_config_app_escape()
return
if self._current_bottom_app == BottomApp.Voice:
self._handle_voice_app_escape()
return
if self._current_bottom_app == BottomApp.ProxySetup:
try:
proxy_setup_app = self.query_one(ProxySetupApp)
@@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_question_app_escape()
return
if self._current_bottom_app == BottomApp.ModelPicker:
self._handle_model_picker_app_escape()
return
if self._current_bottom_app == BottomApp.SessionPicker:
self._handle_session_picker_app_escape()
return
@@ -1405,6 +1622,11 @@ class VibeApp(App): # noqa: PLR0904
self._handle_input_app_escape()
return
narrator_status = self.query_one(NarratorStatus)
if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE:
self._cancel_speak()
return
if self._agent_running:
self._handle_agent_running_escape()
@@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_profile_widgets(self) -> None:
self._update_profile_widgets(self.agent_loop.agent_profile)
def _on_profile_changed(self) -> None:
self._refresh_profile_widgets()
self._refresh_banner()
def _refresh_banner(self) -> None:
if self._banner:
self._banner.set_state(
@@ -1730,31 +1956,99 @@ class VibeApp(App): # noqa: PLR0904
# force a full layout refresh so the UI isn't garbled.
self.refresh(layout=True)
def _make_turn_summary(self) -> TurnSummaryPort:
if not self.config.narrator_enabled:
return NoopTurnSummary()
result = create_narrator_backend(self.config)
if result is None:
return NoopTurnSummary()
backend, model = result
return TurnSummaryTracker(
backend=backend, model=model, on_summary=self._on_turn_summary
)
def _print_session_resume_message(session_id: str | None) -> None:
if not session_id:
return
def _on_turn_summary(self, result: TurnSummaryResult) -> None:
self._cancel_summary = None
if result.generation != self._turn_summary.generation:
self._set_narrator_state(NarratorState.IDLE)
return
if result.summary is None:
self._set_narrator_state(NarratorState.IDLE)
return
if self._tts_client is not None:
self._speak_task = asyncio.create_task(self._speak_summary(result.summary))
else:
self._set_narrator_state(NarratorState.IDLE)
print()
print("To continue this session, run: vibe --continue")
print(f"Or: vibe --resume {session_id}")
async def _speak_summary(self, text: str) -> None:
if self._tts_client is None:
return
try:
loop = asyncio.get_running_loop()
tts_result = await self._tts_client.speak(text)
self._set_narrator_state(NarratorState.SPEAKING)
self._audio_player.play(
tts_result.audio_data,
AudioFormat.WAV,
on_finished=lambda: loop.call_soon_threadsafe(
self._set_narrator_state, NarratorState.IDLE
),
)
except Exception:
logger.warning("TTS speak failed", exc_info=True)
self._set_narrator_state(NarratorState.IDLE)
def _cancel_speak(self) -> None:
if self._cancel_summary is not None:
self._cancel_summary()
self._cancel_summary = None
if self._speak_task is not None and not self._speak_task.done():
self._speak_task.cancel()
self._speak_task = None
self._audio_player.stop()
self._set_narrator_state(NarratorState.IDLE)
def _set_narrator_state(self, state: NarratorState) -> None:
self.query_one(NarratorStatus).state = state
def _make_tts_client(self) -> TTSClientPort | None:
if not self.config.narrator_enabled:
return None
try:
model = self.config.get_active_tts_model()
provider = self.config.get_tts_provider_for_model(model)
return make_tts_client(provider, model)
except (ValueError, KeyError) as exc:
logger.error("Failed to initialize TTS client", exc_info=exc)
return None
def _sync_turn_summary(self) -> None:
self._cancel_speak()
task = asyncio.create_task(self._turn_summary.close())
self._turn_summary_close_tasks.add(task)
task.add_done_callback(self._turn_summary_close_tasks.discard)
self._turn_summary = self._make_turn_summary()
old_tts = self._tts_client
self._tts_client = self._make_tts_client()
if old_tts is not None:
close_task = asyncio.create_task(old_tts.close())
self._turn_summary_close_tasks.add(close_task)
close_task.add_done_callback(self._turn_summary_close_tasks.discard)
def run_textual_ui(
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
agent_loop: AgentLoop, startup: StartupOptions | None = None
) -> None:
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway()
app = VibeApp(
agent_loop=agent_loop,
initial_prompt=initial_prompt,
teleport_on_start=teleport_on_start,
startup=startup,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
)
session_id = app.run()
_print_session_resume_message(session_id)
print_session_resume_message(session_id, agent_loop.stats)

View File

@@ -160,7 +160,7 @@ Markdown {
color: ansi_default;
.code_inline {
color: ansi_yellow;
color: ansi_green;
background: transparent;
text-style: bold;
}
@@ -625,7 +625,8 @@ StatusMessage {
.loading-hint {
width: auto;
height: auto;
color: $foreground;
color: ansi_bright_black;
}
.history-load-more-message {
@@ -655,7 +656,7 @@ StatusMessage {
}
#config-app {
#config-app, #voice-app {
width: 100%;
height: auto;
background: transparent;
@@ -664,7 +665,7 @@ StatusMessage {
margin: 0;
}
#config-content {
#config-content, #voice-content {
width: 100%;
height: auto;
}
@@ -675,41 +676,14 @@ StatusMessage {
color: ansi_blue;
}
.settings-option {
height: auto;
color: ansi_default;
#config-options {
width: 100%;
max-height: 50vh;
border: none;
}
.settings-cursor-selected {
color: ansi_blue;
text-style: bold;
}
.settings-label-selected {
color: ansi_default;
text-style: bold;
}
.settings-value-toggle-on-selected {
color: ansi_green;
text-style: bold;
}
.settings-value-toggle-on-unselected {
color: ansi_green;
}
.settings-value-toggle-off {
color: ansi_bright_black;
}
.settings-value-cycle-selected {
color: ansi_blue;
text-style: bold;
}
.settings-value-cycle-unselected {
color: ansi_blue;
#config-options:focus {
border: none;
}
.settings-help {
@@ -953,6 +927,14 @@ ContextProgress {
color: ansi_bright_black;
}
NarratorStatus {
width: auto;
height: auto;
background: transparent;
padding: 0;
margin: 0 0 0 1;
}
#banner-container {
align: left middle;
padding: 0 1 0 0;
@@ -1083,3 +1065,54 @@ ContextProgress {
color: ansi_bright_black;
margin-top: 1;
}
#modelpicker-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#modelpicker-content {
width: 100%;
height: auto;
}
.modelpicker-title {
height: auto;
text-style: bold;
color: ansi_blue;
}
#modelpicker-options {
width: 100%;
max-height: 50vh;
text-wrap: nowrap;
text-overflow: ellipsis;
border: none;
}
#modelpicker-options:focus {
border: none;
}
.modelpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
margin-top: 1;
}
FeedbackBar {
width: auto;
height: auto;
margin-left: 1;
}
#feedback-text {
width: auto;
height: auto;
color: ansi_default;
}

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from enum import StrEnum
class MistralColors(StrEnum):
RED = "#E10500"
ORANGE_DARK = "#FA500F"
ORANGE = "#FF8205"
ORANGE_LIGHT = "#FFAF00"
YELLOW = "#FFD800"

View File

@@ -6,6 +6,8 @@ import shlex
import subprocess
import tempfile
from vibe.core.utils.io import read_safe
class ExternalEditor:
"""Handles opening an external editor to edit prompt content."""
@@ -24,7 +26,7 @@ class ExternalEditor:
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
content = Path(filepath).read_text().rstrip()
content = read_safe(Path(filepath)).rstrip()
return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError):
return

View File

@@ -9,6 +9,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import (
AgentProfileChangedEvent,
AssistantEvent,
BaseEvent,
CompactEndEvent,
@@ -27,10 +28,14 @@ if TYPE_CHECKING:
class EventHandler:
def __init__(
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
self,
mount_callback: Callable,
get_tools_collapsed: Callable[[], bool],
on_profile_changed: Callable[[], None] | None = None,
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
self.on_profile_changed = on_profile_changed
self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
@@ -62,6 +67,9 @@ class EventHandler:
case CompactEndEvent():
await self.finalize_streaming()
await self._handle_compact_end(event)
case AgentProfileChangedEvent():
if self.on_profile_changed:
self.on_profile_changed()
case UserMessageEvent():
await self.finalize_streaming()
case _:

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from rich import print as rprint
from vibe.core.types import AgentStats
def format_session_usage(stats: AgentStats) -> str:
return (
"Total tokens used this session: "
f"input={stats.session_prompt_tokens:,} "
f"output={stats.session_completion_tokens:,} "
f"(total={stats.session_total_llm_tokens:,})"
)
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
if not session_id:
return
print()
print(format_session_usage(stats))
print()
rprint("To continue this session, run: [bold dark_orange]vibe --continue[/]")
rprint(f"Or: [bold dark_orange]vibe --resume {session_id}[/]")

View File

@@ -13,6 +13,7 @@ from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
from vibe.core.config import VibeConfig
from vibe.core.tools.permissions import RequiredPermission
class ApprovalApp(Container):
@@ -38,12 +39,15 @@ class ApprovalApp(Container):
class ApprovalGrantedAlwaysTool(Message):
def __init__(
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission],
) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
self.save_permanently = save_permanently
self.required_permissions = required_permissions
class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
@@ -52,12 +56,17 @@ class ApprovalApp(Container):
self.tool_args = tool_args
def __init__(
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
self,
tool_name: str,
tool_args: BaseModel,
config: VibeConfig,
required_permissions: list[RequiredPermission] | None = None,
) -> None:
super().__init__(id="approval-app")
self.tool_name = tool_name
self.tool_args = tool_args
self.config = config
self.required_permissions = required_permissions or []
self.selected_option = 0
self.content_container: Vertical | None = None
self.title_widget: Static | None = None
@@ -104,9 +113,15 @@ class ApprovalApp(Container):
await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
always_text = f"Yes and always allow for this session: {labels}"
else:
always_text = f"Yes and always allow {self.tool_name} for this session"
options = [
("Yes", "yes"),
(f"Yes and always allow {self.tool_name} for this session", "yes"),
(always_text, "yes"),
("No and tell the agent what to do instead", "no"),
]
@@ -178,7 +193,7 @@ class ApprovalApp(Container):
self.ApprovalGrantedAlwaysTool(
tool_name=self.tool_name,
tool_args=self.tool_args,
save_permanently=False,
required_permissions=self.required_permissions,
)
)
case 2:

View File

@@ -161,6 +161,16 @@ class ChatTextArea(TextArea):
self.post_message(self.HistoryNext())
return True
class FeedbackKeyPressed(Message):
def __init__(self, rating: int) -> None:
self.rating = rating
super().__init__()
class NonFeedbackKeyPressed(Message):
pass
feedback_active: bool = False
async def _handle_voice_key(self, event: events.Key) -> bool:
if not self._voice_manager:
return False
@@ -193,6 +203,15 @@ class ChatTextArea(TextArea):
self._mark_cursor_moved_if_needed()
if self.feedback_active:
if event.character in {"1", "2", "3"}:
event.prevent_default()
event.stop()
self.post_message(self.FeedbackKeyPressed(int(event.character)))
return
if event.character is not None:
self.post_message(self.NonFeedbackKeyPressed())
manager = self._completion_manager
if manager:
match manager.on_key(

View File

@@ -1,13 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, TypedDict
from typing import TYPE_CHECKING, ClassVar
from textual import events
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.events import DescendantBlur
from textual.message import Message
from textual.widgets import Static
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
@@ -15,22 +17,13 @@ if TYPE_CHECKING:
from vibe.core.config import VibeConfig
class SettingDefinition(TypedDict):
key: str
label: str
type: str
options: list[str]
class ConfigApp(Container):
can_focus = True
can_focus_children = False
"""Settings panel with navigatable option picker."""
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("space", "toggle_setting", "Toggle", show=False),
Binding("enter", "cycle", "Next", show=False),
Binding("escape", "close", "Close", show=False)
]
class SettingChanged(Message):
@@ -44,122 +37,92 @@ class ConfigApp(Container):
super().__init__()
self.changes = changes
class OpenModelPicker(Message):
pass
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="config-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
self.settings: list[SettingDefinition] = [
{
"key": "active_model",
"label": "Model",
"type": "cycle",
"options": [m.alias for m in self.config.models],
},
{
"key": "autocopy_to_clipboard",
"label": "Auto-copy",
"type": "cycle",
"options": ["On", "Off"],
},
{
"key": "file_watcher_for_autocomplete",
"label": "Autocomplete watcher (may delay first autocompletion)",
"type": "cycle",
"options": ["On", "Off"],
},
self._toggle_settings: list[tuple[str, str]] = [
("autocopy_to_clipboard", "Auto-copy"),
(
"file_watcher_for_autocomplete",
"Autocomplete watcher (may delay first autocompletion)",
),
]
self.title_widget: Static | None = None
self.setting_widgets: list[Static] = []
self.help_widget: Static | None = None
def _get_current_model(self) -> str:
return str(getattr(self.config, "active_model", ""))
def compose(self) -> ComposeResult:
with Vertical(id="config-content"):
self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
yield self.title_widget
yield NoMarkupStatic("")
for _ in self.settings:
widget = NoMarkupStatic("", classes="settings-option")
self.setting_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
)
yield self.help_widget
def on_mount(self) -> None:
self._update_display()
self.focus()
def _get_display_value(self, setting: SettingDefinition) -> str:
key = setting["key"]
def _get_toggle_value(self, key: str) -> str:
if key in self.changes:
return self.changes[key]
raw_value = getattr(self.config, key, "")
if isinstance(raw_value, bool):
return "On" if raw_value else "Off"
return str(raw_value)
raw = getattr(self.config, key, False)
if isinstance(raw, bool):
return "On" if raw else "Off"
return str(raw)
def _update_display(self) -> None:
for i, (setting, widget) in enumerate(
zip(self.settings, self.setting_widgets, strict=True)
):
is_selected = i == self.selected_index
cursor = " " if is_selected else " "
def _model_prompt(self) -> Text:
text = Text(no_wrap=True)
text.append("Model: ")
text.append(self._get_current_model(), style="bold")
return text
label: str = setting["label"]
value: str = self._get_display_value(setting)
def _toggle_prompt(self, key: str, label: str) -> Text:
value = self._get_toggle_value(key)
text = Text(no_wrap=True)
text.append(f"{label}: ")
if value == "On":
text.append("On", style="green bold")
else:
text.append("Off", style="dim")
return text
text = f"{cursor}{label}: {value}"
def compose(self) -> ComposeResult:
options: list[Option] = [Option(self._model_prompt(), id="action:active_model")]
for key, label in self._toggle_settings:
options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}"))
widget.update(text)
with Vertical(id="config-content"):
yield NoMarkupStatic("Settings", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(*options, id="config-options")
yield NoMarkupStatic("")
yield NoMarkupStatic(
"↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help"
)
widget.remove_class("settings-cursor-selected")
widget.remove_class("settings-value-cycle-selected")
widget.remove_class("settings-value-cycle-unselected")
def on_mount(self) -> None:
self.query_one(OptionList).focus()
if is_selected:
widget.add_class("settings-value-cycle-selected")
else:
widget.add_class("settings-value-cycle-unselected")
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
def action_move_up(self) -> None:
self.selected_index = (self.selected_index - 1) % len(self.settings)
self._update_display()
def _refresh_options(self) -> None:
option_list = self.query_one(OptionList)
option_list.replace_option_prompt("action:active_model", self._model_prompt())
for key, label in self._toggle_settings:
option_list.replace_option_prompt(
f"toggle:{key}", self._toggle_prompt(key, label)
)
def action_move_down(self) -> None:
self.selected_index = (self.selected_index + 1) % len(self.settings)
self._update_display()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id
if not option_id:
return
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self._get_display_value(setting)
if option_id == "action:active_model":
self.post_message(self.OpenModelPicker())
return
options: list[str] = setting["options"]
new_value = ""
try:
current_idx = options.index(current)
next_idx = (current_idx + 1) % len(options)
new_value = options[next_idx]
except (ValueError, IndexError):
new_value = options[0] if options else current
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._update_display()
def action_cycle(self) -> None:
self.action_toggle_setting()
if option_id.startswith("toggle:"):
key = option_id.removeprefix("toggle:")
current = self._get_toggle_value(key)
new_value = "Off" if current == "On" else "On"
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._refresh_options()
def _convert_changes_for_save(self) -> dict[str, str | bool]:
result: dict[str, str | bool] = {}
@@ -172,6 +135,3 @@ class ConfigApp(Container):
def action_close(self) -> None:
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
import random
from rich.text import Text
from textual.app import ComposeResult
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
FEEDBACK_PROBABILITY = 0.02
THANK_YOU_DURATION = 2.0
class FeedbackBar(Widget):
class FeedbackGiven(Message):
def __init__(self, rating: int) -> None:
super().__init__()
self.rating = rating
@staticmethod
def _prompt_text() -> Text:
text = Text()
text.append("How is Vibe doing so far? ")
text.append("1", style="blue")
text.append(": good ")
text.append("2", style="blue")
text.append(": fine ")
text.append("3", style="blue")
text.append(": bad")
return text
def compose(self) -> ComposeResult:
yield Static(self._prompt_text(), id="feedback-text")
def on_mount(self) -> None:
self.display = False
def maybe_show(self) -> None:
if self.display:
return
if random.random() <= FEEDBACK_PROBABILITY:
self._set_active(True)
def hide(self) -> None:
if self.display:
self._set_active(False)
def handle_feedback_key(self, rating: int) -> None:
try:
self.app.query_one(ChatTextArea).feedback_active = False
except Exception:
pass
self.query_one("#feedback-text", Static).update(
Text("Thank you for your feedback!")
)
self.post_message(self.FeedbackGiven(rating))
self.set_timer(THANK_YOU_DURATION, lambda: self._set_active(False))
def _set_active(self, active: bool) -> None:
if active:
self.query_one("#feedback-text", Static).update(self._prompt_text())
self.display = active
try:
self.app.query_one(ChatTextArea).feedback_active = active
except Exception:
pass

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