Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Clément Siriex <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
This commit is contained in:
Mathias Gesbert
2026-02-17 16:23:28 +01:00
committed by GitHub
parent 51fecc67d9
commit ec7f3b25ea
107 changed files with 8002 additions and 535 deletions

View File

@@ -32,3 +32,4 @@ repos:
hooks:
- id: typos
args: [--write-changes]
exclude: __snapshots__/.*\.svg$

2
.vscode/launch.json vendored
View File

@@ -1,5 +1,5 @@
{
"version": "2.1.0",
"version": "2.2.0",
"configurations": [
{
"name": "ACP Server",

View File

@@ -133,3 +133,10 @@ guidelines:
- uv sync to install dependencies declared in pyproject.toml and uv.lock
- uv run script.py to run a script within the uv environment
- uv run pytest (or any other python tool) to run the tool within the uv environment
- title: "Imports in Cursor (no Pylance)"
description: >
Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import:
- Use the workspace snippets: type the prefix (e.g. acpschema, acphelpers, vibetypes, vibeconfig) and accept the suggestion to insert the import line, then change the symbol name.
- Or ask Cursor: select the undefined symbol, then Cmd+K and request "Add the missing import for <symbol>".
- Or copy the import from an existing file in the repo (e.g. acp.schema, acp.helpers, vibe.core.*).

View File

@@ -5,6 +5,35 @@ 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.2.0] - 2026-02-17
### Added
- Google Vertex AI support
- Telemetry: user interaction and tool usage events sent to datalake (configurable via `disable_telemetry`)
- Skill discovery from `.agents/skills/` (Agent Skills standard) in addition to `.vibe/skills/`
- ACP: `session/load` and `session/list` for loading and listing sessions
- New model behavior prompts (CLI and explore)
- Proxy Wizard (PoC) for CLI and for ACP
- Proxy setup documentation
- Documentation for JetBrains ACP registry
### Changed
- Trusted folders: presence of `.agents` is now considered trustable content
- Logging handling updated
- Pin `cryptography` to >=44.0.0,<=46.0.3; uv sync for cryptography
### Fixed
- Auto scroll when switching to input
- MCP stdio: redirect stderr to logger to avoid unwanted console output
- Align `pyproject.toml` minimum versions with `uv.lock` for pip installs
- Middleware injection: use standalone user messages instead of mutating flushed messages
- Revert cryptography 46.0.5 bump for compatibility
- Pin banner version in UI snapshot tests for stability
## [2.1.0] - 2026-02-11
### Added

View File

@@ -72,6 +72,22 @@ This section is for developers who want to set up the repository for local devel
Pre-commit hooks will automatically run checks before each commit.
### Logging Configuration
Logs are written to `~/.vibe/logs/vibe.log` by default. Control logging via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `LOG_LEVEL` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) | `WARNING` |
| `LOG_MAX_BYTES` | Max log file size in bytes before rotation | `10485760` (10 MB) |
| `DEBUG_MODE` | When `true`, forces `DEBUG` level | - |
Example:
```bash
LOG_LEVEL=DEBUG uv run vibe
```
### Running Tests
Run all tests:

View File

@@ -84,6 +84,7 @@ pip install mistral-vibe
- [Custom Vibe Home Directory](#custom-vibe-home-directory)
- [Editors/IDEs](#editorsides)
- [Resources](#resources)
- [Data collection & usage](#data-collection--usage)
- [License](#license)
## Features
@@ -328,9 +329,10 @@ This skill helps analyze code quality and suggest improvements.
Vibe discovers skills from multiple locations:
1. **Global skills directory**: `~/.vibe/skills/`
2. **Local project skills**: `.vibe/skills/` in your project
3. **Custom paths**: Configured in `config.toml`
1. **Custom paths**: Configured in `config.toml` via `skill_paths`
2. **Standard Agent Skills path** (project root, trusted folders only): `.agents/skills/` — [Agent Skills](https://agentskills.io) standard
3. **Local project skills** (project root, trusted folders only): `.vibe/skills/` in your project
4. **Global skills directory**: `~/.vibe/skills/`
```toml
skill_paths = ["/path/to/custom/skills"]
@@ -595,6 +597,10 @@ Mistral Vibe can be used in text editors and IDEs that support [Agent Client Pro
- [CHANGELOG](CHANGELOG.md) - See what's new in each version
- [CONTRIBUTING](CONTRIBUTING.md) - Guidelines for feature requests, feedback and bug reports
## Data collection & usage
Use of Vibe is subject to our [Privacy Policy](https://legal.mistral.ai/terms/privacy-policy) and may include the collection and processing of data related to your use of the service, such as usage data, to operate, maintain, and improve Vibe. You can disable telemetry in your `config.toml` by setting `enable_telemetry = false`.
## License
Copyright 2025 Mistral AI

View File

@@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "2.1.0"
version = "2.2.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.1.0/vibe-acp-darwin-aarch64-2.1.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-aarch64-2.2.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-darwin-x86_64-2.1.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-x86_64-2.2.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-aarch64-2.1.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-aarch64-2.2.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-x86_64-2.1.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-x86_64-2.2.0.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-aarch64-2.1.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-aarch64-2.2.0.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-x86_64-2.1.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-x86_64-2.2.0.zip"
cmd = "./vibe-acp.exe"

View File

@@ -5,3 +5,4 @@ Welcome to the Mistral Vibe documentation! For basic setup, see the [main README
## Available Documentation
- **[ACP Setup](acp-setup.md)** - Setup instructions for using Mistral Vibe with various editors and IDEs that support the Agent Client Protocol.
- **[Proxy Setup](proxy-setup.md)** - Configure proxy and SSL certificate settings for corporate networks or firewalls.

View File

@@ -22,10 +22,20 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://
}
```
2. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation.
1. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation.
## JetBrains IDEs
For using Mistral Vibe in JetBrains IDEs, you'll need to have the [Jetbrains AI Assistant extension](https://plugins.jetbrains.com/plugin/22282-jetbrains-ai-assistant) installed
### Version 2025.3 or later
1. Open settings, then go to `Tools > AI Assistant > Agents`. Search for `Mistral Vibe`, click install
2. Open AI Assistant. You should be able to select Mistral Vibe from the agent selector (if you're not authenticated yet, you will be prompted to do so).
### Legacy method
1. Add the following snippet to your JetBrains IDE acp.json ([documentation](https://www.jetbrains.com/help/ai-assistant/acp.html)):
```json
@@ -38,7 +48,7 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://
}
```
2. In the AI Chat agent selector, select the new Mistral Vibe agent and start the conversation.
1. In the AI Chat agent selector, select the new Mistral Vibe agent and start the conversation.
## Neovim (using avante.nvim)

36
docs/proxy-setup.md Normal file
View File

@@ -0,0 +1,36 @@
# Proxy Setup
Mistral Vibe supports proxy configuration for environments that require network traffic to go through a proxy server. Proxy settings are shared between the CLI and ACP — configuring them in one will apply to both.
## Using Mistral Vibe CLI
Configure proxy settings through the interactive form:
1. Type `/proxy-setup` and press Enter
2. Fill in the variables you need, then press **Enter** to save or **Escape** to cancel
3. **Restart the CLI** for changes to take effect
## Through an ACP Client
In ACP, variables must be set one at a time using the `/proxy-setup` command:
```bash
/proxy-setup HTTP_PROXY http://proxy.example.com:8080
```
Once all variables are configured, **restart the conversation** for changes to take effect.
## Supported Environment Variables
Mistral Vibe uses [httpx](https://www.python-httpx.org/environment_variables/) for HTTP requests and supports the same environment variables:
| Variable | Description |
|----------|-------------|
| `HTTP_PROXY` | Proxy URL for HTTP requests |
| `HTTPS_PROXY` | Proxy URL for HTTPS requests |
| `ALL_PROXY` | Proxy URL for all requests (fallback when HTTP_PROXY/HTTPS_PROXY are not set) |
| `NO_PROXY` | Comma-separated list of hosts that should bypass the proxy |
| `SSL_CERT_FILE` | Path to a custom SSL certificate file |
| `SSL_CERT_DIR` | Path to a directory containing SSL certificates |
These variables can also be set directly in your shell environment before launching the CLI (but will be overridden if set through the interactive form).

View File

@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.1.0"
version = "2.2.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -29,28 +29,30 @@ classifiers = [
dependencies = [
"agent-client-protocol==0.8.0",
"anyio>=4.12.0",
"httpx>=0.28.1",
"mcp>=1.14.0",
"mistralai==1.9.11",
"pexpect>=4.9.0",
"packaging>=24.1",
"pydantic>=2.12.4",
"pydantic-settings>=2.12.0",
"pyyaml>=6.0.0",
"python-dotenv>=1.0.0",
"rich>=14.0.0",
"textual>=1.0.0",
"tomli-w>=1.2.0",
"watchfiles>=1.1.1",
"pyperclip>=1.11.0",
"textual-speedups>=0.2.1",
"tree-sitter>=0.25.2",
"tree-sitter-bash>=0.25.1",
"keyring>=25.6.0",
"cryptography>=44.0.0",
"zstandard>=0.25.0",
"cryptography>=44.0.0,<=46.0.3",
"gitpython>=3.1.46",
"giturlparse>=0.14.0",
"google-auth>=2.0.0",
"httpx>=0.28.1",
"keyring>=25.6.0",
"mcp>=1.14.0",
"mistralai==1.9.11",
"packaging>=24.1",
"pexpect>=4.9.0",
"pydantic>=2.12.4",
"pydantic-settings>=2.12.0",
"pyperclip>=1.11.0",
"python-dotenv>=1.0.0",
"pyyaml>=6.0.0",
"requests>=2.20.0",
"rich>=14.0.0",
"textual>=1.0.0",
"textual-speedups>=0.2.1",
"tomli-w>=1.2.0",
"tree-sitter>=0.25.2",
"tree-sitter-bash>=0.25.1",
"watchfiles>=1.1.1",
"zstandard>=0.25.0",
]
[project.urls]
@@ -59,7 +61,6 @@ Repository = "https://github.com/mistralai/mistral-vibe"
Issues = "https://github.com/mistralai/mistral-vibe/issues"
Documentation = "https://github.com/mistralai/mistral-vibe#readme"
[build-system]
requires = ["hatchling", "hatch-vcs", "editables"]
build-backend = "hatchling.build"
@@ -83,19 +84,19 @@ required-version = ">=0.8.0"
[dependency-groups]
dev = [
"debugpy>=1.8.19",
"pre-commit>=4.2.0",
"pyright>=1.1.403",
"pytest>=8.3.5",
"pytest-asyncio>=1.2.0",
"pytest-timeout>=2.4.0",
"pytest-textual-snapshot>=1.1.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"respx>=0.22.0",
"ruff>=0.14.5",
"twine>=5.0.0",
"typos>=1.34.0",
"vulture>=2.14",
"pytest-xdist>=3.8.0",
"debugpy>=1.8.19",
]
build = ["pyinstaller>=6.17.0"]

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from datetime import datetime
import json
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -40,3 +43,55 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
return _create_acp_agent()
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def create_test_session():
"""Create a test session with configurable messages and metadata.
Supports both messages parameter (for load_session tests) and
end_time parameter (for list_sessions tests).
"""
def _create_session(
session_dir: Path,
session_id: str,
cwd: str,
messages: list[dict] | None = None,
title: str | None = None,
end_time: str | None = None,
) -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_folder = session_dir / f"session_{timestamp}_{session_id[:8]}"
session_folder.mkdir(exist_ok=True)
if messages is None:
messages = [{"role": "user", "content": "Hello"}]
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w", encoding="utf-8") as f:
for msg in messages:
f.write(json.dumps(msg) + "\n")
metadata = {
"session_id": session_id,
"start_time": "2024-01-01T12:00:00Z",
"end_time": end_time or "2024-01-01T12:05:00Z",
"environment": {"working_directory": cwd},
"title": title,
}
metadata_file = session_folder / "meta.json"
with metadata_file.open("w", encoding="utf-8") as f:
json.dump(metadata, f)
return session_folder
return _create_session

View File

@@ -451,6 +451,18 @@ class TestSessionUpdates:
),
),
)
commands_response_text = await read_response(process)
assert commands_response_text is not None
commands_response = UpdateJsonRpcNotification.model_validate(
json.loads(commands_response_text)
)
assert commands_response.params is not None
assert (
commands_response.params.update.session_update
== "available_commands_update"
)
user_response_text = await read_response(process)
assert user_response_text is not None
user_response = UpdateJsonRpcNotification.model_validate(

View File

@@ -6,6 +6,8 @@ from acp.schema import (
ClientCapabilities,
Implementation,
PromptCapabilities,
SessionCapabilities,
SessionListCapabilities,
)
import pytest
@@ -19,13 +21,14 @@ class TestACPInitialize:
assert response.protocol_version == PROTOCOL_VERSION
assert response.agent_capabilities == AgentCapabilities(
load_session=False,
load_session=True,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0"
)
assert response.auth_methods == []
@@ -42,13 +45,14 @@ class TestACPInitialize:
assert response.protocol_version == PROTOCOL_VERSION
assert response.agent_capabilities == AgentCapabilities(
load_session=False,
load_session=True,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0"
)
assert response.auth_methods is not None

View File

@@ -0,0 +1,242 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from tests.acp.conftest import _create_acp_agent
from vibe.core.config import MissingAPIKeyError, SessionLoggingConfig
class TestListSessions:
@pytest.mark.asyncio
async def test_list_sessions_empty(self, temp_session_dir: Path) -> None:
acp_agent = _create_acp_agent()
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_config = mock_load.return_value
mock_config.session_logging = session_config
response = await acp_agent.list_sessions()
assert response.sessions == []
@pytest.mark.asyncio
async def test_list_sessions_returns_all_sessions(
self, temp_session_dir: Path, create_test_session
) -> None:
acp_agent = _create_acp_agent()
create_test_session(
temp_session_dir,
"aaaaaaaa-1111",
"/home/user/project1",
title="First session",
end_time="2024-01-01T12:00:00Z",
)
create_test_session(
temp_session_dir,
"bbbbbbbb-2222",
"/home/user/project2",
title="Second session",
end_time="2024-01-01T13:00:00Z",
)
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_config = mock_load.return_value
mock_config.session_logging = session_config
response = await acp_agent.list_sessions()
assert len(response.sessions) == 2
session_ids = {s.session_id for s in response.sessions}
assert "aaaaaaaa-1111" in session_ids
assert "bbbbbbbb-2222" in session_ids
@pytest.mark.asyncio
async def test_list_sessions_filters_by_cwd(
self, temp_session_dir: Path, create_test_session
) -> None:
acp_agent = _create_acp_agent()
create_test_session(
temp_session_dir,
"aaaaaaaa-proj1",
"/home/user/project1",
title="Project 1 session",
)
create_test_session(
temp_session_dir,
"bbbbbbbb-proj2",
"/home/user/project2",
title="Project 2 session",
)
create_test_session(
temp_session_dir,
"cccccccc-proj1",
"/home/user/project1",
title="Another Project 1 session",
)
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_config = mock_load.return_value
mock_config.session_logging = session_config
response = await acp_agent.list_sessions(cwd="/home/user/project1")
assert len(response.sessions) == 2
for session in response.sessions:
assert session.cwd == "/home/user/project1"
@pytest.mark.asyncio
async def test_list_sessions_sorted_by_updated_at(
self, temp_session_dir: Path, create_test_session
) -> None:
acp_agent = _create_acp_agent()
create_test_session(
temp_session_dir,
"oldest-s",
"/home/user/project",
title="Oldest",
end_time="2024-01-01T10:00:00Z",
)
create_test_session(
temp_session_dir,
"newest-s",
"/home/user/project",
title="Newest",
end_time="2024-01-01T14:00:00Z",
)
create_test_session(
temp_session_dir,
"middle-s",
"/home/user/project",
title="Middle",
end_time="2024-01-01T12:00:00Z",
)
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_config = mock_load.return_value
mock_config.session_logging = session_config
response = await acp_agent.list_sessions()
assert len(response.sessions) == 3
assert response.sessions[0].title == "Newest"
assert response.sessions[1].title == "Middle"
assert response.sessions[2].title == "Oldest"
@pytest.mark.asyncio
async def test_list_sessions_includes_session_info_fields(
self, temp_session_dir: Path, create_test_session
) -> None:
acp_agent = _create_acp_agent()
create_test_session(
temp_session_dir,
"test-session-123",
"/home/user/project",
title="Test Session Title",
end_time="2024-01-15T10:30:00Z",
)
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_config = mock_load.return_value
mock_config.session_logging = session_config
response = await acp_agent.list_sessions()
assert len(response.sessions) == 1
session = response.sessions[0]
assert session.session_id == "test-session-123"
assert session.cwd == "/home/user/project"
assert session.title == "Test Session Title"
# updated_at is normalized to UTC
assert session.updated_at is not None
assert session.updated_at.endswith("+00:00")
@pytest.mark.asyncio
async def test_list_sessions_skips_invalid_sessions(
self, temp_session_dir: Path, create_test_session
) -> None:
acp_agent = _create_acp_agent()
create_test_session(
temp_session_dir, "valid-se", "/home/user/project", title="Valid Session"
)
invalid_session = temp_session_dir / "session_20240101_120000_invalid1"
invalid_session.mkdir()
(invalid_session / "meta.json").write_text('{"session_id": "invalid"}')
no_id_session = temp_session_dir / "session_20240101_120001_noid0000"
no_id_session.mkdir()
(no_id_session / "messages.jsonl").write_text(
'{"role": "user", "content": "Hello"}\n'
)
(no_id_session / "meta.json").write_text(
'{"environment": {"working_directory": "/test"}}'
)
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_config = mock_load.return_value
mock_config.session_logging = session_config
response = await acp_agent.list_sessions()
assert len(response.sessions) == 1
assert response.sessions[0].session_id == "valid-se"
@pytest.mark.asyncio
async def test_list_sessions_nonexistent_save_dir(self) -> None:
acp_agent = _create_acp_agent()
session_config = SessionLoggingConfig(
save_dir="/nonexistent/path", session_prefix="session", enabled=True
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_config = mock_load.return_value
mock_config.session_logging = session_config
response = await acp_agent.list_sessions()
assert response.sessions == []
@pytest.mark.asyncio
async def test_list_sessions_without_api_key(self) -> None:
acp_agent = _create_acp_agent()
with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load:
mock_load.side_effect = MissingAPIKeyError("api_key", "mistral")
response = await acp_agent.list_sessions()
assert response.sessions == []

View File

@@ -0,0 +1,301 @@
from __future__ import annotations
from pathlib import Path
from acp import RequestError
from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
ToolCallProgress,
ToolCallStart,
UserMessageChunk,
)
import pytest
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.agents.models import BuiltinAgentName
from vibe.core.config import ModelConfig, SessionLoggingConfig
from vibe.core.types import Role
@pytest.fixture
def acp_agent_with_session_config(
backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> tuple[VibeAcpAgentLoop, FakeClient]:
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
config = build_test_vibe_config(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
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()
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent, client
class TestLoadSession:
@pytest.mark.asyncio
async def test_load_session_returns_response_with_models_and_modes(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, _client = acp_agent_with_session_config
session_id = "test-sess-12345678"
cwd = str(Path.cwd())
create_test_session(temp_session_dir, session_id, cwd)
response = await acp_agent.load_session(
cwd=cwd, mcp_servers=[], session_id=session_id
)
assert response is not None
assert response.models is not None
assert response.models.current_model_id == "devstral-latest"
assert response.modes is not None
assert response.modes.current_mode_id == BuiltinAgentName.DEFAULT
@pytest.mark.asyncio
async def test_load_session_registers_session_with_original_id(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, _client = acp_agent_with_session_config
session_id = "orig-id-12345678"
cwd = str(Path.cwd())
create_test_session(temp_session_dir, session_id, cwd)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
assert session_id in acp_agent.sessions
assert acp_agent.sessions[session_id].id == session_id
@pytest.mark.asyncio
async def test_load_session_injects_messages_into_agent_loop(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, _client = acp_agent_with_session_config
session_id = "msg-test-12345678"
cwd = str(Path.cwd())
messages = [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
{"role": "assistant", "content": "Second answer"},
]
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
session = acp_agent.sessions[session_id]
non_system = [m for m in session.agent_loop.messages if m.role != Role.system]
assert len(non_system) == 4
@pytest.mark.asyncio
async def test_load_session_replays_user_messages(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
session_id = "replay-usr-123456"
cwd = str(Path.cwd())
messages = [{"role": "user", "content": "Hello world"}]
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
user_updates = [
u for u in client._session_updates if isinstance(u.update, UserMessageChunk)
]
assert len(user_updates) == 1
assert user_updates[0].update.content.text == "Hello world"
@pytest.mark.asyncio
async def test_load_session_replays_assistant_messages(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
session_id = "replay-ast-123456"
cwd = str(Path.cwd())
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello! How can I help?"},
]
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
agent_updates = [
u
for u in client._session_updates
if isinstance(u.update, AgentMessageChunk)
]
assert len(agent_updates) == 1
assert agent_updates[0].update.content.text == "Hello! How can I help?"
@pytest.mark.asyncio
async def test_load_session_replays_tool_calls(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
session_id = "replay-tool-12345"
cwd = str(Path.cwd())
messages = [
{"role": "user", "content": "Read the file"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": '{"path": "/tmp/test.txt"}',
},
}
],
},
{"role": "tool", "tool_call_id": "call_123", "content": "file contents"},
]
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
tool_call_starts = [
u for u in client._session_updates if isinstance(u.update, ToolCallStart)
]
assert len(tool_call_starts) == 1
assert tool_call_starts[0].update.title == "read_file"
assert tool_call_starts[0].update.tool_call_id == "call_123"
tool_results = [
u for u in client._session_updates if isinstance(u.update, ToolCallProgress)
]
assert len(tool_results) == 1
assert tool_results[0].update.tool_call_id == "call_123"
assert tool_results[0].update.status == "completed"
@pytest.mark.asyncio
async def test_load_session_replays_reasoning_content(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
session_id = "replay-reason-123"
cwd = str(Path.cwd())
messages = [
{"role": "user", "content": "Think about this"},
{
"role": "assistant",
"content": "Here is my answer",
"reasoning_content": "Let me think step by step...",
},
]
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
thought_updates = [
u
for u in client._session_updates
if isinstance(u.update, AgentThoughtChunk)
]
assert len(thought_updates) == 1
assert thought_updates[0].update.content.text == "Let me think step by step..."
@pytest.mark.asyncio
async def test_load_session_not_found_raises_error(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent, _client = acp_agent_with_session_config
with pytest.raises(RequestError):
await acp_agent.load_session(
cwd=str(Path.cwd()), mcp_servers=[], session_id="nonexistent-session"
)
@pytest.mark.asyncio
async def test_load_session_replays_full_conversation(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
session_id = "full-conv-1234567"
cwd = str(Path.cwd())
messages = [
{"role": "user", "content": "First message"},
{"role": "assistant", "content": "First response"},
{"role": "user", "content": "Second message"},
{"role": "assistant", "content": "Second response"},
]
create_test_session(temp_session_dir, session_id, cwd, messages=messages)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id)
user_updates = [
u for u in client._session_updates if isinstance(u.update, UserMessageChunk)
]
agent_updates = [
u
for u in client._session_updates
if isinstance(u.update, AgentMessageChunk)
]
assert len(user_updates) == 2
assert len(agent_updates) == 2
assert user_updates[0].update.content.text == "First message"
assert user_updates[1].update.content.text == "Second message"
assert agent_updates[0].update.content.text == "First response"
assert agent_updates[1].update.content.text == "Second response"

View File

@@ -41,12 +41,18 @@ def acp_agent_loop(backend) -> VibeAcpAgentLoop:
class TestACPNewSession:
@pytest.mark.asyncio
async def test_new_session_response_structure(
self, acp_agent_loop: VibeAcpAgentLoop
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict]
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
new_session_events = [
e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
]
assert len(new_session_events) == 1
assert new_session_events[0]["properties"]["entrypoint"] == "acp"
assert session_response.session_id is not None
acp_session = next(
(

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp.schema import AgentMessageChunk, AvailableCommandsUpdate, TextContentBlock
import pytest
from tests.acp.conftest import _create_acp_agent
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
@pytest.fixture
def acp_agent_loop(backend) -> 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(acp_agent_loop: VibeAcpAgentLoop) -> FakeClient:
assert isinstance(acp_agent_loop.client, FakeClient)
return acp_agent_loop.client
class TestAvailableCommandsUpdate:
@pytest.mark.asyncio
async def test_available_commands_sent_on_new_session(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
import asyncio
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
await asyncio.sleep(0)
updates = _get_fake_client(acp_agent_loop)._session_updates
available_commands_updates = [
u for u in updates if isinstance(u.update, AvailableCommandsUpdate)
]
assert len(available_commands_updates) == 1
update = available_commands_updates[0].update
assert len(update.available_commands) == 1
assert update.available_commands[0].name == "proxy-setup"
assert "proxy" in update.available_commands[0].description.lower()
class TestProxySetupCommand:
@pytest.mark.asyncio
async def test_proxy_setup_shows_help_when_no_args(
self,
acp_agent_loop: VibeAcpAgentLoop,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env_file = tmp_path / ".env"
class FakeGlobalEnvFile:
path = env_file
monkeypatch.setattr(
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
)
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.session_id
_get_fake_client(acp_agent_loop)._session_updates.clear()
response = await acp_agent_loop.prompt(
prompt=[TextContentBlock(type="text", text="/proxy-setup")],
session_id=session_id,
)
assert response.stop_reason == "end_turn"
updates = _get_fake_client(acp_agent_loop)._session_updates
message_updates = [
u for u in updates if isinstance(u.update, AgentMessageChunk)
]
assert len(message_updates) == 1
content = message_updates[0].update.content.text
assert "## Proxy Configuration" in content
assert "HTTP_PROXY" in content
@pytest.mark.asyncio
async def test_proxy_setup_sets_value(
self,
acp_agent_loop: VibeAcpAgentLoop,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env_file = tmp_path / ".env"
class FakeGlobalEnvFile:
path = env_file
monkeypatch.setattr(
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
)
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.session_id
_get_fake_client(acp_agent_loop)._session_updates.clear()
response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(
type="text", text="/proxy-setup HTTP_PROXY http://localhost:8080"
)
],
session_id=session_id,
)
assert response.stop_reason == "end_turn"
updates = _get_fake_client(acp_agent_loop)._session_updates
message_updates = [
u for u in updates if isinstance(u.update, AgentMessageChunk)
]
assert len(message_updates) == 1
content = message_updates[0].update.content.text
assert "HTTP_PROXY" in content
assert "http://localhost:8080" in content
assert env_file.exists()
env_content = env_file.read_text()
assert "HTTP_PROXY" in env_content
assert "http://localhost:8080" in env_content
@pytest.mark.asyncio
async def test_proxy_setup_unsets_value(
self,
acp_agent_loop: VibeAcpAgentLoop,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env_file = tmp_path / ".env"
env_file.write_text("HTTP_PROXY=http://old-proxy.com\n")
class FakeGlobalEnvFile:
path = env_file
monkeypatch.setattr(
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
)
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.session_id
_get_fake_client(acp_agent_loop)._session_updates.clear()
response = await acp_agent_loop.prompt(
prompt=[TextContentBlock(type="text", text="/proxy-setup HTTP_PROXY")],
session_id=session_id,
)
assert response.stop_reason == "end_turn"
updates = _get_fake_client(acp_agent_loop)._session_updates
message_updates = [
u for u in updates if isinstance(u.update, AgentMessageChunk)
]
assert len(message_updates) == 1
content = message_updates[0].update.content.text
assert "Removed" in content
assert "HTTP_PROXY" in content
env_content = env_file.read_text()
assert "HTTP_PROXY" not in env_content
@pytest.mark.asyncio
async def test_proxy_setup_invalid_key_returns_error(
self,
acp_agent_loop: VibeAcpAgentLoop,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env_file = tmp_path / ".env"
class FakeGlobalEnvFile:
path = env_file
monkeypatch.setattr(
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
)
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.session_id
_get_fake_client(acp_agent_loop)._session_updates.clear()
response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(type="text", text="/proxy-setup INVALID_KEY value")
],
session_id=session_id,
)
assert response.stop_reason == "end_turn"
updates = _get_fake_client(acp_agent_loop)._session_updates
message_updates = [
u for u in updates if isinstance(u.update, AgentMessageChunk)
]
assert len(message_updates) == 1
content = message_updates[0].update.content.text
assert "Error" in content
assert "Unknown key" in content
@pytest.mark.asyncio
async def test_proxy_setup_case_insensitive(
self,
acp_agent_loop: VibeAcpAgentLoop,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
env_file = tmp_path / ".env"
class FakeGlobalEnvFile:
path = env_file
monkeypatch.setattr(
"vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile()
)
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.session_id
_get_fake_client(acp_agent_loop)._session_updates.clear()
response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(
type="text", text="/PROXY-SETUP http_proxy http://localhost:8080"
)
],
session_id=session_id,
)
assert response.stop_reason == "end_turn"
assert env_file.exists()
env_content = env_file.read_text()
assert "HTTP_PROXY" in env_content

55
tests/acp/test_utils.py Normal file
View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from vibe.acp.utils import get_proxy_help_text
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
def _write_env_file(content: str) -> None:
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
GLOBAL_ENV_FILE.path.write_text(content, encoding="utf-8")
class TestGetProxyHelpText:
def test_returns_string(self) -> None:
result = get_proxy_help_text()
assert isinstance(result, str)
def test_includes_proxy_configuration_header(self) -> None:
result = get_proxy_help_text()
assert "## Proxy Configuration" in result
def test_includes_usage_section(self) -> None:
result = get_proxy_help_text()
assert "### Usage:" in result
assert "/proxy-setup" in result
def test_includes_all_supported_variables(self) -> None:
result = get_proxy_help_text()
for key in SUPPORTED_PROXY_VARS:
assert f"`{key}`" in result
def test_shows_none_configured_when_no_settings(self) -> None:
result = get_proxy_help_text()
assert "(none configured)" in result
def test_shows_current_settings_when_configured(self) -> None:
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
result = get_proxy_help_text()
assert "HTTP_PROXY=http://proxy:8080" in result
assert "(none configured)" not in result
def test_shows_only_set_values(self) -> None:
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
result = get_proxy_help_text()
assert "HTTP_PROXY=http://proxy:8080" in result
assert "HTTPS_PROXY=" not in result

View File

@@ -100,7 +100,7 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) ->
@pytest.mark.asyncio
async def test_pressing_enter_submits_selected_command_and_hides_popup(
vibe_app: VibeApp,
vibe_app: VibeApp, telemetry_events: list[dict]
) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
@@ -115,6 +115,17 @@ async def test_pressing_enter_submits_selected_command_and_hides_popup(
message_content = message.query_one(Markdown)
assert "Show help message" in message_content.source
slash_used = [
e
for e in telemetry_events
if e.get("event_name") == "vibe/slash_command_used"
]
assert any(
e.get("properties", {}).get("command") == "help"
and e.get("properties", {}).get("command_type") == "builtin"
for e in slash_used
)
@pytest.fixture()
def file_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:

View File

@@ -0,0 +1,586 @@
from __future__ import annotations
import json
import pytest
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper
from vibe.core.types import (
AvailableFunction,
AvailableTool,
FunctionCall,
LLMMessage,
Role,
ToolCall,
)
@pytest.fixture
def mapper():
return AnthropicMapper()
@pytest.fixture
def adapter():
return AnthropicAdapter()
@pytest.fixture
def provider():
return ProviderConfig(
name="anthropic",
api_base="https://api.anthropic.com",
api_key_env_var="ANTHROPIC_API_KEY",
api_style="anthropic",
)
class TestMapperPrepareMessages:
def test_system_extracted(self, mapper):
messages = [
LLMMessage(role=Role.system, content="You are helpful."),
LLMMessage(role=Role.user, content="Hi"),
]
system, converted = mapper.prepare_messages(messages)
assert system == "You are helpful."
assert len(converted) == 1
assert converted[0]["role"] == "user"
def test_user_message(self, mapper):
messages = [LLMMessage(role=Role.user, content="Hello")]
_, converted = mapper.prepare_messages(messages)
assert converted[0]["content"] == [{"type": "text", "text": "Hello"}]
def test_assistant_text(self, mapper):
messages = [LLMMessage(role=Role.assistant, content="Sure")]
_, converted = mapper.prepare_messages(messages)
assert converted[0]["role"] == "assistant"
content = converted[0]["content"]
assert any(b.get("type") == "text" and b.get("text") == "Sure" for b in content)
def test_assistant_with_reasoning_content_and_signature(self, mapper):
messages = [
LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="hmm",
reasoning_signature="sig",
)
]
_, converted = mapper.prepare_messages(messages)
content = converted[0]["content"]
assert content[0] == {"type": "thinking", "thinking": "hmm", "signature": "sig"}
assert content[1]["type"] == "text"
def test_assistant_with_reasoning_content(self, mapper):
messages = [
LLMMessage(
role=Role.assistant, content="Answer", reasoning_content="thinking..."
)
]
_, converted = mapper.prepare_messages(messages)
content = converted[0]["content"]
assert content[0] == {"type": "thinking", "thinking": "thinking..."}
def test_assistant_with_tool_calls(self, mapper):
messages = [
LLMMessage(
role=Role.assistant,
content="Let me search.",
tool_calls=[
ToolCall(
id="tc_1",
index=0,
function=FunctionCall(name="search", arguments='{"q": "test"}'),
)
],
)
]
_, converted = mapper.prepare_messages(messages)
content = converted[0]["content"]
tool_block = [b for b in content if b["type"] == "tool_use"][0]
assert tool_block["name"] == "search"
assert tool_block["input"] == {"q": "test"}
def test_tool_result_appended_to_user(self, mapper):
messages = [
LLMMessage(role=Role.user, content="Do it"),
LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1"),
]
_, converted = mapper.prepare_messages(messages)
# tool_result is merged into the preceding user message
assert len(converted) == 1
assert converted[0]["role"] == "user"
blocks = converted[0]["content"]
assert any(b.get("type") == "tool_result" for b in blocks)
def test_tool_result_new_user_when_no_prior(self, mapper):
messages = [LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1")]
_, converted = mapper.prepare_messages(messages)
assert converted[0]["role"] == "user"
assert converted[0]["content"][0]["type"] == "tool_result"
class TestMapperPrepareTools:
def test_none_returns_none(self, mapper):
assert mapper.prepare_tools(None) is None
def test_empty_returns_none(self, mapper):
assert mapper.prepare_tools([]) is None
def test_converts_tools(self, mapper):
tools = [
AvailableTool(
function=AvailableFunction(
name="search",
description="Search things",
parameters={"type": "object"},
)
)
]
result = mapper.prepare_tools(tools)
assert len(result) == 1
assert result[0]["name"] == "search"
assert result[0]["input_schema"] == {"type": "object"}
class TestMapperToolChoice:
def test_none(self, mapper):
assert mapper.prepare_tool_choice(None) is None
def test_auto(self, mapper):
assert mapper.prepare_tool_choice("auto") == {"type": "auto"}
def test_none_str(self, mapper):
assert mapper.prepare_tool_choice("none") == {"type": "none"}
def test_any(self, mapper):
assert mapper.prepare_tool_choice("any") == {"type": "any"}
def test_required(self, mapper):
assert mapper.prepare_tool_choice("required") == {"type": "any"}
def test_specific_tool(self, mapper):
tool = AvailableTool(
function=AvailableFunction(name="search", description="", parameters={})
)
assert mapper.prepare_tool_choice(tool) == {"type": "tool", "name": "search"}
class TestMapperParseResponse:
def test_text(self, mapper):
data = {
"content": [{"type": "text", "text": "Hello"}],
"usage": {"input_tokens": 10, "output_tokens": 5},
}
chunk = mapper.parse_response(data)
assert chunk.message.content == "Hello"
assert chunk.usage.prompt_tokens == 10
def test_thinking(self, mapper):
data = {
"content": [
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
{"type": "text", "text": "Answer"},
],
"usage": {"input_tokens": 1, "output_tokens": 1},
}
chunk = mapper.parse_response(data)
assert chunk.message.content == "Answer"
assert chunk.message.reasoning_content == "hmm"
assert chunk.message.reasoning_signature == "sig"
def test_redacted_thinking(self, mapper):
data = {
"content": [
{"type": "redacted_thinking", "data": "xyz"},
{"type": "text", "text": "Answer"},
],
"usage": {"input_tokens": 1, "output_tokens": 1},
}
chunk = mapper.parse_response(data)
assert chunk.message.content == "Answer"
assert chunk.message.reasoning_content is None
def test_tool_use(self, mapper):
data = {
"content": [
{"type": "tool_use", "id": "t1", "name": "search", "input": {"q": "hi"}}
],
"usage": {"input_tokens": 1, "output_tokens": 1},
}
chunk = mapper.parse_response(data)
assert chunk.message.tool_calls[0].function.name == "search"
assert json.loads(chunk.message.tool_calls[0].function.arguments) == {"q": "hi"}
def test_cache_tokens(self, mapper):
data = {
"content": [{"type": "text", "text": "x"}],
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 5,
"cache_read_input_tokens": 3,
"output_tokens": 7,
},
}
chunk = mapper.parse_response(data)
assert chunk.usage.prompt_tokens == 18
assert chunk.usage.completion_tokens == 7
class TestMapperStreamingEvents:
def test_text_delta(self, mapper):
chunk, idx = mapper.parse_streaming_event(
"content_block_delta",
{"delta": {"type": "text_delta", "text": "hi"}, "index": 0},
0,
)
assert chunk.message.content == "hi"
def test_thinking_delta(self, mapper):
chunk, _ = mapper.parse_streaming_event(
"content_block_delta",
{"delta": {"type": "thinking_delta", "thinking": "hmm"}, "index": 0},
0,
)
assert chunk.message.reasoning_content == "hmm"
def test_tool_use_start(self, mapper):
chunk, idx = mapper.parse_streaming_event(
"content_block_start",
{
"content_block": {"type": "tool_use", "id": "t1", "name": "search"},
"index": 2,
},
0,
)
assert chunk.message.tool_calls[0].id == "t1"
assert idx == 2
def test_input_json_delta(self, mapper):
chunk, _ = mapper.parse_streaming_event(
"content_block_delta",
{
"delta": {"type": "input_json_delta", "partial_json": '{"q":'},
"index": 1,
},
0,
)
assert chunk.message.tool_calls[0].function.arguments == '{"q":'
def test_message_start_usage(self, mapper):
chunk, _ = mapper.parse_streaming_event(
"message_start",
{"message": {"usage": {"input_tokens": 50, "cache_read_input_tokens": 10}}},
0,
)
assert chunk.usage.prompt_tokens == 60
def test_message_delta_usage(self, mapper):
chunk, _ = mapper.parse_streaming_event(
"message_delta", {"usage": {"output_tokens": 42}}, 0
)
assert chunk.usage.completion_tokens == 42
def test_unknown_event(self, mapper):
chunk, idx = mapper.parse_streaming_event("ping", {}, 5)
assert chunk is None
assert idx == 5
def test_signature_delta(self, mapper):
chunk, _ = mapper.parse_streaming_event(
"content_block_delta",
{"delta": {"type": "signature_delta", "signature": "sig"}, "index": 0},
0,
)
assert chunk is not None
assert chunk.message.reasoning_signature == "sig"
class TestAdapterPrepareRequest:
def test_basic(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["model"] == "claude-sonnet-4-20250514"
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 0.5
assert req.endpoint == "/v1/messages"
assert req.headers["anthropic-version"] == "2023-06-01"
def test_beta_features(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
assert "prompt-caching-2024-07-31" in req.headers["anthropic-beta"]
assert "interleaved-thinking-2025-05-14" in req.headers["anthropic-beta"]
assert "fine-grained-tool-streaming-2025-05-14" in req.headers["anthropic-beta"]
def test_api_key_header(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
api_key="sk-test-key",
)
assert req.headers["x-api-key"] == "sk-test-key"
def test_streaming(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=True,
provider=provider,
)
assert json.loads(req.body)["stream"] is True
def test_default_max_tokens(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
assert json.loads(req.body)["max_tokens"] == AnthropicAdapter.DEFAULT_MAX_TOKENS
def test_with_thinking(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking="medium",
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 1
def test_system_cached(self, adapter, provider):
messages = [
LLMMessage(role=Role.system, content="Be helpful."),
LLMMessage(role=Role.user, content="Hello"),
]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["system"][0]["cache_control"] == {"type": "ephemeral"}
def test_with_tools(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
tools = [
AvailableTool(
function=AvailableFunction(
name="test_tool",
description="A test tool",
parameters={"type": "object", "properties": {}},
)
)
]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=tools,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert len(payload["tools"]) == 1
assert payload["tools"][0]["name"] == "test_tool"
@pytest.mark.parametrize(
"level,expected_budget", [("low", 1024), ("medium", 10_000), ("high", 32_000)]
)
def test_thinking_levels_budget_model(
self, adapter, provider, level, expected_budget
):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking=level,
)
payload = json.loads(req.body)
assert payload["thinking"] == {
"type": "enabled",
"budget_tokens": expected_budget,
}
assert payload["temperature"] == 1
assert payload["max_tokens"] == expected_budget + 8192
@pytest.mark.parametrize("level", ["low", "medium", "high"])
def test_thinking_levels_adaptive_model(self, adapter, provider, level):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-opus-4-6-20260101",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking=level,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "adaptive"}
assert payload["output_config"] == {"effort": level}
assert payload["temperature"] == 1
assert payload["max_tokens"] == 32_768
def test_history_forced_thinking_budget_model(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="thinking...",
reasoning_signature="sig",
),
LLMMessage(role=Role.user, content="Follow up"),
]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10_000}
assert payload["temperature"] == 1
assert payload["max_tokens"] == 18_192
def test_history_forced_thinking_adaptive_model(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="thinking...",
reasoning_signature="sig",
),
LLMMessage(role=Role.user, content="Follow up"),
]
req = adapter.prepare_request(
model_name="claude-opus-4-6-20260101",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "adaptive"}
assert payload["output_config"] == {"effort": "medium"}
assert payload["max_tokens"] == 32_768
class TestAdapterParseResponse:
def test_non_streaming(self, adapter, provider):
data = {
"content": [{"type": "text", "text": "Hello!"}],
"usage": {"input_tokens": 10, "output_tokens": 5},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content == "Hello!"
assert chunk.usage.prompt_tokens == 10
def test_streaming_text_delta(self, adapter, provider):
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hi"},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content == "Hi"
def test_streaming_message_start(self, adapter, provider):
data = {"type": "message_start", "message": {"usage": {"input_tokens": 100}}}
chunk = adapter.parse_response(data, provider)
assert chunk.usage.prompt_tokens == 100
def test_streaming_unknown_returns_empty(self, adapter, provider):
data = {"type": "ping"}
chunk = adapter.parse_response(data, provider)
assert chunk.message.role == Role.assistant
assert chunk.message.content is None
def test_cache_control_last_user_message(self, adapter):
messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]
adapter._add_cache_control_to_last_user_message(messages)
assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
def test_cache_control_skips_non_user(self, adapter):
messages = [
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
]
adapter._add_cache_control_to_last_user_message(messages)
assert "cache_control" not in messages[0]["content"][0]
def test_cache_control_empty(self, adapter):
messages: list[dict] = []
adapter._add_cache_control_to_last_user_message(messages)
assert messages == []

View File

@@ -0,0 +1,591 @@
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.vertex import (
VertexAnthropicAdapter,
build_vertex_base_url,
build_vertex_endpoint,
)
from vibe.core.types import AvailableFunction, AvailableTool, LLMMessage, Role
@pytest.fixture
def adapter():
return VertexAnthropicAdapter()
@pytest.fixture
def provider():
return ProviderConfig(
name="vertex",
api_base="",
project_id="test-project",
region="us-central1",
api_style="vertex-anthropic",
)
class TestBuildVertexEndpoint:
def test_non_streaming(self):
endpoint = build_vertex_endpoint(
"us-central1", "my-project", "claude-3-5-sonnet"
)
assert endpoint == (
"/v1/projects/my-project/locations/us-central1/"
"publishers/anthropic/models/claude-3-5-sonnet:rawPredict"
)
def test_streaming(self):
endpoint = build_vertex_endpoint(
"us-central1", "my-project", "claude-3-5-sonnet", streaming=True
)
assert endpoint == (
"/v1/projects/my-project/locations/us-central1/"
"publishers/anthropic/models/claude-3-5-sonnet:streamRawPredict"
)
def test_base_url(self):
base = build_vertex_base_url("us-central1")
assert base == "https://us-central1-aiplatform.googleapis.com"
def test_global_endpoint(self):
endpoint = build_vertex_endpoint("global", "my-project", "claude-3-5-sonnet")
assert endpoint == (
"/v1/projects/my-project/locations/global/"
"publishers/anthropic/models/claude-3-5-sonnet:rawPredict"
)
def test_global_base_url(self):
base = build_vertex_base_url("global")
assert base == "https://aiplatform.googleapis.com"
class TestPrepareRequest:
@patch(
"vibe.core.llm.backend.vertex.get_vertex_access_token",
return_value="fake-token",
)
def test_basic_request(self, mock_token, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["anthropic_version"] == "vertex-2023-10-16"
assert "model" not in payload
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 0.5
assert req.headers["Authorization"] == "Bearer fake-token"
assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES
assert "rawPredict" in req.endpoint
assert "streamRawPredict" not in req.endpoint
assert req.base_url == "https://us-central1-aiplatform.googleapis.com"
@patch(
"vibe.core.llm.backend.vertex.get_vertex_access_token",
return_value="fake-token",
)
def test_streaming_request(self, mock_token, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=True,
provider=provider,
)
payload = json.loads(req.body)
assert payload.get("stream") is True
assert "streamRawPredict" in req.endpoint
@patch(
"vibe.core.llm.backend.vertex.get_vertex_access_token",
return_value="fake-token",
)
def test_no_beta_features_for_vertex(self, mock_token, adapter, provider):
"""Vertex AI doesn't support the same beta features as direct Anthropic API."""
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
# Vertex AI doesn't support prompt-caching or other beta features
assert req.headers.get("anthropic-beta", "") == ""
@patch(
"vibe.core.llm.backend.vertex.get_vertex_access_token",
return_value="fake-token",
)
def test_with_extended_thinking(self, mock_token, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking="medium",
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 1
@patch(
"vibe.core.llm.backend.vertex.get_vertex_access_token",
return_value="fake-token",
)
def test_with_tools(self, mock_token, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
tools = [
AvailableTool(
function=AvailableFunction(
name="test_tool",
description="A test tool",
parameters={"type": "object", "properties": {}},
)
)
]
req = adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=messages,
temperature=0.5,
tools=tools,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert len(payload["tools"]) == 1
assert payload["tools"][0]["name"] == "test_tool"
def test_missing_project_id(self, adapter):
provider = ProviderConfig(
name="vertex",
api_base="",
region="us-central1",
api_style="vertex-anthropic",
)
with pytest.raises(ValueError, match="project_id"):
adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=[LLMMessage(role=Role.user, content="Hello")],
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
def test_missing_region(self, adapter):
provider = ProviderConfig(
name="vertex",
api_base="",
project_id="test-project",
api_style="vertex-anthropic",
)
with pytest.raises(ValueError, match="region"):
adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=[LLMMessage(role=Role.user, content="Hello")],
temperature=0.5,
tools=None,
max_tokens=1024,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
@patch(
"vibe.core.llm.backend.vertex.get_vertex_access_token",
return_value="fake-token",
)
def test_default_max_tokens(self, mock_token, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-3-5-sonnet",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["max_tokens"] == adapter.DEFAULT_MAX_TOKENS
class TestParseFullResponse:
def test_simple_text_response(self, adapter, provider):
data = {
"content": [{"type": "text", "text": "Hello there!"}],
"usage": {"input_tokens": 10, "output_tokens": 5},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content == "Hello there!"
assert chunk.usage.prompt_tokens == 10
assert chunk.usage.completion_tokens == 5
def test_response_with_tool_calls(self, adapter, provider):
data = {
"content": [
{"type": "text", "text": "Let me help."},
{
"type": "tool_use",
"id": "tool_123",
"name": "search",
"input": {"query": "test"},
},
],
"usage": {"input_tokens": 20, "output_tokens": 15},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content == "Let me help."
assert len(chunk.message.tool_calls) == 1
assert chunk.message.tool_calls[0].id == "tool_123"
assert chunk.message.tool_calls[0].function.name == "search"
assert json.loads(chunk.message.tool_calls[0].function.arguments) == {
"query": "test"
}
def test_response_with_thinking(self, adapter, provider):
data = {
"content": [
{
"type": "thinking",
"thinking": "Let me think...",
"signature": "sig123",
},
{"type": "text", "text": "Here's my answer."},
],
"usage": {"input_tokens": 30, "output_tokens": 20},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content == "Here's my answer."
assert chunk.message.reasoning_content == "Let me think..."
assert chunk.message.reasoning_signature == "sig123"
def test_response_with_cache_tokens(self, adapter, provider):
data = {
"content": [{"type": "text", "text": "Hello"}],
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 5,
"cache_read_input_tokens": 3,
"output_tokens": 7,
},
}
chunk = adapter.parse_response(data, provider)
assert chunk.usage.prompt_tokens == 18
assert chunk.usage.completion_tokens == 7
def test_response_with_redacted_thinking(self, adapter, provider):
data = {
"content": [
{"type": "redacted_thinking", "data": "redacted_data_here"},
{"type": "text", "text": "Answer."},
],
"usage": {"input_tokens": 10, "output_tokens": 5},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content == "Answer."
assert chunk.message.reasoning_content is None
def test_response_empty_usage(self, adapter, provider):
data = {"content": [{"type": "text", "text": "Hello"}], "usage": {}}
chunk = adapter.parse_response(data, provider)
assert chunk.usage.prompt_tokens == 0
assert chunk.usage.completion_tokens == 0
class TestStreamingEvents:
def test_message_start(self, adapter, provider):
data = {
"type": "message_start",
"message": {
"usage": {
"input_tokens": 100,
"cache_creation_input_tokens": 20,
"cache_read_input_tokens": 10,
}
},
}
chunk = adapter.parse_response(data, provider)
assert chunk.usage is not None
assert chunk.usage.prompt_tokens == 130
assert chunk.usage.completion_tokens == 0
def test_message_start_without_usage(self, adapter, provider):
data = {"type": "message_start", "message": {}}
chunk = adapter.parse_response(data, provider)
assert chunk.message.role == Role.assistant
def test_content_block_start_tool_use(self, adapter, provider):
data = {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "tool_use", "id": "tool_abc", "name": "search"},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.tool_calls is not None
assert len(chunk.message.tool_calls) == 1
assert chunk.message.tool_calls[0].id == "tool_abc"
assert chunk.message.tool_calls[0].function.name == "search"
assert chunk.message.tool_calls[0].index == 0
def test_content_block_start_thinking(self, adapter, provider):
data = {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": ""},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.reasoning_content is not None
def test_content_block_start_redacted_thinking(self, adapter, provider):
data = {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "redacted_thinking", "data": "abc"},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content is None
assert chunk.message.reasoning_content is None
def test_content_block_delta_text(self, adapter, provider):
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hello"},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content == "Hello"
def test_content_block_delta_thinking(self, adapter, provider):
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "I think..."},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.reasoning_content == "I think..."
def test_content_block_delta_input_json(self, adapter, provider):
data = {
"type": "content_block_delta",
"index": 1,
"delta": {"type": "input_json_delta", "partial_json": '{"key":'},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.tool_calls is not None
assert chunk.message.tool_calls[0].function.arguments == '{"key":'
def test_content_block_stop(self, adapter, provider):
data = {"type": "content_block_stop", "index": 0}
chunk = adapter.parse_response(data, provider)
assert chunk.message.content is None
assert chunk.message.reasoning_content is None
def test_message_delta_with_usage(self, adapter, provider):
data = {"type": "message_delta", "usage": {"output_tokens": 42}}
chunk = adapter.parse_response(data, provider)
assert chunk.usage is not None
assert chunk.usage.completion_tokens == 42
assert chunk.usage.prompt_tokens == 0
def test_message_delta_without_usage(self, adapter, provider):
data = {"type": "message_delta", "usage": {}}
chunk = adapter.parse_response(data, provider)
assert chunk.message.role == Role.assistant
def test_unknown_event_returns_empty_chunk(self, adapter, provider):
data = {"type": "ping"}
chunk = adapter.parse_response(data, provider)
assert chunk.message.role == Role.assistant
assert chunk.message.content is None
def test_signature_delta(self, adapter, provider):
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "signature_delta", "signature": "sig_abc"},
}
chunk = adapter.parse_response(data, provider)
assert chunk.message.reasoning_signature == "sig_abc"
def test_message_start_resets_state(self, adapter, provider):
adapter._current_index = 5
data = {"type": "message_start", "message": {"usage": {"input_tokens": 10}}}
adapter.parse_response(data, provider)
assert adapter._current_index == 0
def test_full_streaming_sequence(self, adapter, provider):
chunks = []
# message_start
chunks.append(
adapter.parse_response(
{"type": "message_start", "message": {"usage": {"input_tokens": 50}}},
provider,
)
)
assert chunks[-1].usage.prompt_tokens == 50
# thinking block
adapter.parse_response(
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": ""},
},
provider,
)
chunks.append(
adapter.parse_response(
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "Analyzing..."},
},
provider,
)
)
assert chunks[-1].message.reasoning_content == "Analyzing..."
adapter.parse_response({"type": "content_block_stop", "index": 0}, provider)
# text block
chunks.append(
adapter.parse_response(
{
"type": "content_block_delta",
"index": 1,
"delta": {"type": "text_delta", "text": "Here's the result."},
},
provider,
)
)
assert chunks[-1].message.content == "Here's the result."
# tool use
chunks.append(
adapter.parse_response(
{
"type": "content_block_start",
"index": 2,
"content_block": {
"type": "tool_use",
"id": "tool_1",
"name": "search",
},
},
provider,
)
)
assert chunks[-1].message.tool_calls[0].function.name == "search"
# message_delta with final usage
chunks.append(
adapter.parse_response(
{"type": "message_delta", "usage": {"output_tokens": 100}}, provider
)
)
assert chunks[-1].usage.completion_tokens == 100
class TestHelperMethods:
def test_has_thinking_content_true(self, adapter):
messages = [
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me think..."},
{"type": "text", "text": "Answer"},
],
},
]
assert adapter._has_thinking_content(messages) is True
def test_has_thinking_content_false(self, adapter):
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Just text"},
]
assert adapter._has_thinking_content(messages) is False
def test_has_thinking_content_empty(self, adapter):
assert adapter._has_thinking_content([]) is False
def test_has_thinking_content_non_list_content(self, adapter):
messages = [
{"role": "assistant", "content": [{"type": "text", "text": "no thinking"}]}
]
assert adapter._has_thinking_content(messages) is False
def test_add_cache_control_to_last_user_message(self, adapter):
messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]
adapter._add_cache_control_to_last_user_message(messages)
assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
def test_add_cache_control_skips_non_user(self, adapter):
messages = [
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
]
adapter._add_cache_control_to_last_user_message(messages)
assert "cache_control" not in messages[0]["content"][0]
def test_add_cache_control_skips_string_content(self, adapter):
messages = [{"role": "user", "content": "Hello"}]
adapter._add_cache_control_to_last_user_message(messages)
assert messages[0]["content"] == "Hello"
def test_add_cache_control_tool_result(self, adapter):
messages = [
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "123", "content": "result"}
],
}
]
adapter._add_cache_control_to_last_user_message(messages)
assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
def test_add_cache_control_empty_messages(self, adapter):
messages: list[dict] = []
adapter._add_cache_control_to_last_user_message(messages)
assert messages == []

View File

@@ -74,7 +74,8 @@ def test_copy_selection_to_clipboard_no_notification(
del widgets[0].text_selection
mock_app.query.return_value = widgets
copy_selection_to_clipboard(mock_app)
result = copy_selection_to_clipboard(mock_app)
assert result is None
mock_app.notify.assert_not_called()
@@ -87,8 +88,9 @@ def test_copy_selection_to_clipboard_success(
)
mock_app.query.return_value = [widget]
copy_selection_to_clipboard(mock_app)
result = copy_selection_to_clipboard(mock_app)
assert result == "selected text"
mock_copy_osc52.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard',
@@ -109,8 +111,9 @@ def test_copy_selection_to_clipboard_failure(
mock_copy_osc52.side_effect = Exception("OSC52 failed")
copy_selection_to_clipboard(mock_app)
result = copy_selection_to_clipboard(mock_app)
assert result is None
mock_copy_osc52.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
"Failed to copy - clipboard not available", severity="warning", timeout=3
@@ -129,8 +132,9 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
mock_app.query.return_value = [widget1, widget2, widget3]
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
copy_selection_to_clipboard(mock_app)
result = copy_selection_to_clipboard(mock_app)
assert result == "first selection\nsecond selection"
mock_copy_osc52.assert_called_once_with("first selection\nsecond selection")
mock_app.notify.assert_called_once_with(
'"first selection\u23cesecond selection" copied to clipboard',
@@ -148,8 +152,9 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) ->
mock_app.query.return_value = [widget]
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
copy_selection_to_clipboard(mock_app)
result = copy_selection_to_clipboard(mock_app)
assert result == long_text
mock_copy_osc52.assert_called_once_with(long_text)
notification_call = mock_app.notify.call_args
assert notification_call is not None

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from vibe.cli.commands import Command, CommandRegistry
class TestCommandRegistry:
def test_get_command_name_returns_canonical_name_for_alias(self) -> None:
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("/clear") == "clear"
assert registry.get_command_name("/exit") == "exit"
def test_get_command_name_normalizes_input(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name(" /help ") == "help"
assert registry.get_command_name("/HELP") == "help"
def test_get_command_name_returns_none_for_unknown(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/unknown") is None
assert registry.get_command_name("hello") is None
assert registry.get_command_name("") is None
def test_find_command_returns_command_when_alias_matches(self) -> None:
registry = CommandRegistry()
cmd = registry.find_command("/help")
assert cmd is not None
assert cmd.handler == "_show_help"
assert isinstance(cmd, Command)
def test_find_command_returns_none_when_no_match(self) -> None:
registry = CommandRegistry()
assert registry.find_command("/nonexistent") is None
def test_find_command_uses_get_command_name(self) -> None:
"""find_command and get_command_name stay in sync for same input."""
registry = CommandRegistry()
for alias in ["/help", "/config", "/clear", "/exit"]:
cmd_name = registry.get_command_name(alias)
cmd = registry.find_command(alias)
if cmd_name is None:
assert cmd is None
else:
assert cmd is not None
assert cmd_name in registry.commands
assert registry.commands[cmd_name] is cmd
def test_excluded_commands_not_in_registry(self) -> None:
registry = CommandRegistry(excluded_commands=["exit"])
assert registry.get_command_name("/exit") is None
assert registry.find_command("/exit") is None
assert registry.get_command_name("/help") == "help"

View File

@@ -94,6 +94,22 @@ def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"])
@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]
) -> None:
events.append({"event_name": event_name, "properties": properties})
monkeypatch.setattr(
"vibe.core.telemetry.send.TelemetryClient.send_telemetry_event",
record_telemetry,
)
return events
@pytest.fixture
def vibe_app() -> VibeApp:
return build_test_vibe_app()

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from vibe.core.paths.config_paths import resolve_local_skills_dirs
class TestResolveLocalSkillsDirs:
def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = False
assert resolve_local_skills_dirs(tmp_path) == []
def test_returns_empty_list_when_trusted_but_no_skills_dirs(
self, tmp_path: Path
) -> None:
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
assert resolve_local_skills_dirs(tmp_path) == []
def test_returns_vibe_skills_only_when_only_it_exists(self, tmp_path: Path) -> None:
vibe_skills = tmp_path / ".vibe" / "skills"
vibe_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
result = resolve_local_skills_dirs(tmp_path)
assert result == [vibe_skills]
def test_returns_agents_skills_only_when_only_it_exists(
self, tmp_path: Path
) -> None:
agents_skills = tmp_path / ".agents" / "skills"
agents_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
result = resolve_local_skills_dirs(tmp_path)
assert result == [agents_skills]
def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None:
vibe_skills = tmp_path / ".vibe" / "skills"
agents_skills = tmp_path / ".agents" / "skills"
vibe_skills.mkdir(parents=True)
agents_skills.mkdir(parents=True)
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
result = resolve_local_skills_dirs(tmp_path)
assert result == [vibe_skills, agents_skills]
def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
(tmp_path / ".vibe" / "skills").write_text("", encoding="utf-8")
with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm:
mock_tfm.is_trusted.return_value = True
result = resolve_local_skills_dirs(tmp_path)
assert result == []

View File

@@ -0,0 +1,280 @@
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from textwrap import dedent
from unittest.mock import MagicMock, patch
import pytest
from vibe.core.utils import StructuredLogFormatter, apply_logging_config
@pytest.fixture
def mock_log_dir(tmp_path: Path):
"""Mock LOG_DIR and LOG_FILE to use tmp_path for testing."""
mock_dir = MagicMock()
mock_dir.path = tmp_path
mock_file = MagicMock()
mock_file.path = tmp_path / "vibe.log"
with (
patch("vibe.core.utils.LOG_DIR", mock_dir),
patch("vibe.core.utils.LOG_FILE", mock_file),
):
yield tmp_path
class TestStructuredFormatter:
def test_format_contains_required_fields(self) -> None:
formatter = StructuredLogFormatter()
record = logging.LogRecord(
name="test_logger",
level=logging.INFO,
pathname="test.py",
lineno=1,
msg="Test message",
args=(),
exc_info=None,
)
output = formatter.format(record)
parts = output.split(" ", 4)
assert len(parts) == 5
assert "T" in parts[0]
assert parts[1].isdigit()
assert parts[2].isdigit()
assert parts[3] == "INFO"
assert parts[4] == "Test message"
def test_format_includes_exception(self) -> None:
formatter = StructuredLogFormatter()
try:
raise ValueError("test error")
except ValueError:
import sys
exc_info = sys.exc_info()
record = logging.LogRecord(
name="test_logger",
level=logging.ERROR,
pathname="test.py",
lineno=1,
msg="Error occurred",
args=(),
exc_info=exc_info,
)
output = formatter.format(record)
assert "Error occurred" in output
assert "ValueError" in output
assert "test error" in output
def test_format_escapes_newlines_in_message(self) -> None:
formatter = StructuredLogFormatter()
multiline_msg = dedent("""
Line one
Line two
Line three""")
record = logging.LogRecord(
name="test_logger",
level=logging.INFO,
pathname="test.py",
lineno=1,
msg=multiline_msg,
args=(),
exc_info=None,
)
output = formatter.format(record)
assert "\n" not in output
assert "Line one\\nLine two\\nLine three" in output
def test_format_escapes_newlines_in_exception(self) -> None:
formatter = StructuredLogFormatter()
try:
raise ValueError("test error")
except ValueError:
import sys
exc_info = sys.exc_info()
record = logging.LogRecord(
name="test_logger",
level=logging.ERROR,
pathname="test.py",
lineno=1,
msg="Error occurred",
args=(),
exc_info=exc_info,
)
output = formatter.format(record)
assert "\n" not in output
assert "ValueError" in output
assert "test error" in output
assert "\\n" in output
def test_format_output_is_single_line(self) -> None:
formatter = StructuredLogFormatter()
try:
error_msg = dedent("""
multi
line
error""")
raise RuntimeError(error_msg)
except RuntimeError:
import sys
exc_info = sys.exc_info()
multiline_msg = dedent("""
Something
went
wrong""")
record = logging.LogRecord(
name="test_logger",
level=logging.ERROR,
pathname="test.py",
lineno=1,
msg=multiline_msg,
args=(),
exc_info=exc_info,
)
output = formatter.format(record)
lines = output.split("\n")
assert len(lines) == 1
class TestApplyLoggingConfig:
def test_adds_handler_to_logger(
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
test_logger = logging.getLogger("test_apply_logging")
initial_handler_count = len(test_logger.handlers)
apply_logging_config(test_logger)
assert len(test_logger.handlers) == initial_handler_count + 1
def test_creates_log_file(
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
test_logger = logging.getLogger("test_log_file")
test_logger.setLevel(logging.DEBUG)
apply_logging_config(test_logger)
test_logger.info("Test log entry")
log_file = mock_log_dir / "vibe.log"
assert log_file.exists()
def test_log_entry_format(
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
test_logger = logging.getLogger("test_format")
test_logger.setLevel(logging.DEBUG)
apply_logging_config(test_logger)
test_logger.warning("Test warning message")
log_file = mock_log_dir / "vibe.log"
content = log_file.read_text()
assert "WARNING" in content
assert "Test warning message" in content
def test_respects_log_level(
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOG_LEVEL", "WARNING")
test_logger = logging.getLogger("test_level_filter")
test_logger.setLevel(logging.DEBUG)
apply_logging_config(test_logger)
test_logger.debug("Debug message")
test_logger.info("Info message")
test_logger.warning("Warning message")
log_file = mock_log_dir / "vibe.log"
content = log_file.read_text()
assert "Debug message" not in content
assert "Info message" not in content
assert "Warning message" in content
def test_creates_log_directory_if_missing(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
log_dir = tmp_path / "nested" / "logs"
mock_dir = MagicMock()
mock_dir.path = log_dir
mock_file = MagicMock()
mock_file.path = log_dir / "vibe.log"
with (
patch("vibe.core.utils.LOG_DIR", mock_dir),
patch("vibe.core.utils.LOG_FILE", mock_file),
):
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
test_logger = logging.getLogger("test_mkdir")
apply_logging_config(test_logger)
assert log_dir.exists()
def test_debug_mode_overrides_log_level(
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOG_LEVEL", "WARNING")
monkeypatch.setenv("DEBUG_MODE", "true")
test_logger = logging.getLogger("test_debug_mode")
test_logger.setLevel(logging.DEBUG)
apply_logging_config(test_logger)
test_logger.debug("Debug message")
log_file = mock_log_dir / "vibe.log"
content = log_file.read_text()
assert "Debug message" in content
def test_invalid_log_level_defaults_to_warning(
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOG_LEVEL", "INVALID")
test_logger = logging.getLogger("test_invalid_level")
test_logger.setLevel(logging.DEBUG)
apply_logging_config(test_logger)
test_logger.info("Info message")
test_logger.warning("Warning message")
log_file = mock_log_dir / "vibe.log"
content = log_file.read_text()
assert "Info message" not in content
assert "Warning message" in content
def test_log_max_bytes_from_env(
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LOG_MAX_BYTES", "5242880") # 5 MB
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
test_logger = logging.getLogger("test_max_bytes")
apply_logging_config(test_logger)
# Verify handler was added with correct maxBytes
handler = test_logger.handlers[-1]
assert isinstance(handler, RotatingFileHandler)
assert handler.maxBytes == 5242880

View File

@@ -0,0 +1,304 @@
from __future__ import annotations
import pytest
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.core.proxy_setup import (
SUPPORTED_PROXY_VARS,
ProxySetupError,
get_current_proxy_settings,
parse_proxy_command,
set_proxy_var,
unset_proxy_var,
)
def _write_env_file(content: str) -> None:
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
GLOBAL_ENV_FILE.path.write_text(content, encoding="utf-8")
class TestProxySetupError:
def test_inherits_from_exception(self) -> None:
assert issubclass(ProxySetupError, Exception)
def test_preserves_message(self) -> None:
error = ProxySetupError("test message")
assert str(error) == "test message"
class TestSupportedProxyVars:
def test_contains_all_expected_keys(self) -> None:
expected_keys = {
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
}
assert set(SUPPORTED_PROXY_VARS.keys()) == expected_keys
def test_all_keys_are_uppercase(self) -> None:
for key in SUPPORTED_PROXY_VARS:
assert key == key.upper()
def test_all_values_are_non_empty_strings(self) -> None:
for value in SUPPORTED_PROXY_VARS.values():
assert isinstance(value, str)
assert len(value) > 0
class TestGetCurrentProxySettings:
def test_returns_all_none_when_env_file_does_not_exist(self) -> None:
result = get_current_proxy_settings()
assert all(value is None for value in result.values())
def test_returns_dict_with_all_supported_keys(self) -> None:
result = get_current_proxy_settings()
assert set(result.keys()) == set(SUPPORTED_PROXY_VARS.keys())
def test_returns_values_from_env_file(self) -> None:
_write_env_file(
"HTTP_PROXY=http://proxy:8080\nHTTPS_PROXY=https://proxy:8443\n"
)
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://proxy:8080"
assert result["HTTPS_PROXY"] == "https://proxy:8443"
def test_returns_none_for_unset_keys(self) -> None:
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://proxy:8080"
assert result["HTTPS_PROXY"] is None
assert result["ALL_PROXY"] is None
def test_ignores_non_proxy_vars_in_env_file(self) -> None:
_write_env_file("HTTP_PROXY=http://proxy:8080\nOTHER_VAR=ignored\n")
result = get_current_proxy_settings()
assert "OTHER_VAR" not in result
assert result["HTTP_PROXY"] == "http://proxy:8080"
def test_handles_values_with_special_characters(self) -> None:
_write_env_file("HTTP_PROXY=http://user:p@ss@proxy:8080\n")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://user:p@ss@proxy:8080"
def test_returns_all_none_when_env_file_read_fails(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_write_env_file("HTTP_PROXY=http://proxy:8080\n")
def raise_error(*args, **kwargs):
raise OSError("Permission denied")
monkeypatch.setattr("vibe.core.proxy_setup.dotenv_values", raise_error)
result = get_current_proxy_settings()
assert all(value is None for value in result.values())
class TestSetProxyVar:
def test_sets_valid_proxy_var(self) -> None:
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://proxy:8080"
@pytest.mark.parametrize("key", SUPPORTED_PROXY_VARS.keys())
def test_sets_all_supported_vars(self, key: str) -> None:
set_proxy_var(key, "test-value")
result = get_current_proxy_settings()
assert result[key] == "test-value"
def test_uppercases_key_before_validation(self) -> None:
set_proxy_var("http_proxy", "http://proxy:8080")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://proxy:8080"
def test_raises_error_for_unknown_key(self) -> None:
with pytest.raises(ProxySetupError) as exc_info:
set_proxy_var("UNKNOWN_KEY", "value")
assert "Unknown key 'UNKNOWN_KEY'" in str(exc_info.value)
def test_error_message_contains_supported_keys(self) -> None:
with pytest.raises(ProxySetupError) as exc_info:
set_proxy_var("UNKNOWN_KEY", "value")
error_msg = str(exc_info.value)
assert "HTTP_PROXY" in error_msg
assert "HTTPS_PROXY" in error_msg
def test_creates_env_file_if_missing(self) -> None:
assert not GLOBAL_ENV_FILE.path.exists()
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
assert GLOBAL_ENV_FILE.path.exists()
def test_overwrites_existing_value(self) -> None:
set_proxy_var("HTTP_PROXY", "http://old:8080")
set_proxy_var("HTTP_PROXY", "http://new:8080")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://new:8080"
def test_preserves_other_values(self) -> None:
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
set_proxy_var("HTTPS_PROXY", "https://proxy:8443")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://proxy:8080"
assert result["HTTPS_PROXY"] == "https://proxy:8443"
def test_handles_value_with_spaces(self) -> None:
set_proxy_var("NO_PROXY", "localhost, 127.0.0.1, .local")
result = get_current_proxy_settings()
assert result["NO_PROXY"] == "localhost, 127.0.0.1, .local"
def test_handles_url_with_credentials(self) -> None:
set_proxy_var("HTTP_PROXY", "http://user:password@proxy:8080")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] == "http://user:password@proxy:8080"
class TestUnsetProxyVar:
def test_removes_existing_var(self) -> None:
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
unset_proxy_var("HTTP_PROXY")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] is None
def test_uppercases_key_before_validation(self) -> None:
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
unset_proxy_var("http_proxy")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] is None
def test_raises_error_for_unknown_key(self) -> None:
with pytest.raises(ProxySetupError) as exc_info:
unset_proxy_var("UNKNOWN_KEY")
assert "Unknown key 'UNKNOWN_KEY'" in str(exc_info.value)
def test_error_message_contains_supported_keys(self) -> None:
with pytest.raises(ProxySetupError) as exc_info:
unset_proxy_var("UNKNOWN_KEY")
error_msg = str(exc_info.value)
assert "HTTP_PROXY" in error_msg
def test_no_op_when_env_file_does_not_exist(self) -> None:
assert not GLOBAL_ENV_FILE.path.exists()
unset_proxy_var("HTTP_PROXY")
assert not GLOBAL_ENV_FILE.path.exists()
def test_no_op_when_key_not_in_file(self) -> None:
set_proxy_var("HTTPS_PROXY", "https://proxy:8443")
unset_proxy_var("HTTP_PROXY")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] is None
assert result["HTTPS_PROXY"] == "https://proxy:8443"
def test_preserves_other_values(self) -> None:
set_proxy_var("HTTP_PROXY", "http://proxy:8080")
set_proxy_var("HTTPS_PROXY", "https://proxy:8443")
unset_proxy_var("HTTP_PROXY")
result = get_current_proxy_settings()
assert result["HTTP_PROXY"] is None
assert result["HTTPS_PROXY"] == "https://proxy:8443"
@pytest.mark.parametrize("key", SUPPORTED_PROXY_VARS.keys())
def test_all_supported_vars_can_be_unset(self, key: str) -> None:
set_proxy_var(key, "test-value")
unset_proxy_var(key)
result = get_current_proxy_settings()
assert result[key] is None
class TestParseProxyCommand:
def test_parses_key_only(self) -> None:
key, value = parse_proxy_command("HTTP_PROXY")
assert key == "HTTP_PROXY"
assert value is None
def test_parses_key_and_value(self) -> None:
key, value = parse_proxy_command("HTTP_PROXY http://proxy:8080")
assert key == "HTTP_PROXY"
assert value == "http://proxy:8080"
def test_uppercases_key(self) -> None:
key, value = parse_proxy_command("http_proxy")
assert key == "HTTP_PROXY"
def test_preserves_value_case(self) -> None:
key, value = parse_proxy_command("HTTP_PROXY http://Proxy:8080")
assert value == "http://Proxy:8080"
def test_strips_leading_whitespace(self) -> None:
key, value = parse_proxy_command(" HTTP_PROXY")
assert key == "HTTP_PROXY"
def test_strips_trailing_whitespace(self) -> None:
key, value = parse_proxy_command("HTTP_PROXY ")
assert key == "HTTP_PROXY"
assert value is None
def test_splits_on_first_space_only(self) -> None:
key, value = parse_proxy_command("NO_PROXY localhost, 127.0.0.1, .local")
assert key == "NO_PROXY"
assert value == "localhost, 127.0.0.1, .local"
def test_raises_error_for_empty_string(self) -> None:
with pytest.raises(ProxySetupError) as exc_info:
parse_proxy_command("")
assert "No key provided" in str(exc_info.value)
def test_raises_error_for_whitespace_only(self) -> None:
with pytest.raises(ProxySetupError) as exc_info:
parse_proxy_command(" ")
assert "No key provided" in str(exc_info.value)
def test_handles_tab_as_separator(self) -> None:
key, value = parse_proxy_command("HTTP_PROXY\thttp://proxy:8080")
assert key == "HTTP_PROXY"
assert value == "http://proxy:8080"
def test_handles_multiple_spaces_as_separator(self) -> None:
key, value = parse_proxy_command("HTTP_PROXY http://proxy:8080")
assert key == "HTTP_PROXY"
assert value == "http://proxy:8080"

View File

@@ -0,0 +1,269 @@
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
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.utils import get_user_agent
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
from vibe.core.tools.builtins.write_file import WriteFile, WriteFileArgs
def _make_resolved_tool_call(
tool_name: str, args_dict: dict[str, Any]
) -> ResolvedToolCall:
if tool_name == "write_file":
validated = WriteFileArgs(
path="foo.txt", content="x", overwrite=args_dict.get("overwrite", False)
)
cls: type[BaseTool] = WriteFile
else:
validated = FakeToolArgs()
cls = FakeTool
return ResolvedToolCall(
tool_name=tool_name, tool_class=cls, validated_args=validated, call_id="call_1"
)
def _run_telemetry_tasks() -> None:
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(asyncio.sleep(0))
finally:
loop.close()
class TestTelemetryClient:
def test_send_telemetry_event_does_nothing_when_api_key_is_none(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
monkeypatch.delenv(env_key, raising=False)
client = TelemetryClient(config_getter=lambda: config)
assert client._get_mistral_api_key() is None
client._client = MagicMock()
client._client.post = AsyncMock()
client.send_telemetry_event("vibe/test", {})
_run_telemetry_tasks()
client._client.post.assert_not_called()
def test_send_telemetry_event_does_nothing_when_disabled(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(disable_telemetry=True)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
monkeypatch.setenv(env_key, "sk-test")
client = TelemetryClient(config_getter=lambda: config)
client._client = MagicMock()
client._client.post = AsyncMock()
client.send_telemetry_event("vibe/test", {})
_run_telemetry_tasks()
client._client.post.assert_not_called()
@pytest.mark.asyncio
async def test_send_telemetry_event_posts_when_enabled(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
)
config = build_test_vibe_config(disable_telemetry=False)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
monkeypatch.setenv(env_key, "sk-test")
client = TelemetryClient(config_getter=lambda: config)
mock_post = AsyncMock(return_value=MagicMock(status_code=204))
client._client = MagicMock()
client._client.post = mock_post
client._client.aclose = AsyncMock()
client.send_telemetry_event("vibe/test_event", {"key": "value"})
await client.aclose()
mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL,
json={"event": "vibe/test_event", "properties": {"key": "value"}},
headers={
"Content-Type": "application/json",
"Authorization": "Bearer sk-test",
"User-Agent": get_user_agent(Backend.MISTRAL),
},
)
def test_send_tool_call_finished_payload_shape(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("todo", {})
decision = ToolDecision(
verdict=ToolExecutionResponse.EXECUTE, approval_type=ToolPermission.ALWAYS
)
client.send_tool_call_finished(
tool_call=tool_call,
status="success",
decision=decision,
agent_profile_name="default",
)
assert len(telemetry_events) == 1
event_name = telemetry_events[0]["event_name"]
assert event_name == "vibe/tool_call_finished"
properties = telemetry_events[0]["properties"]
assert properties["tool_name"] == "todo"
assert properties["status"] == "success"
assert properties["decision"] == "execute"
assert properties["approval_type"] == "always"
assert properties["agent_profile_name"] == "default"
assert properties["nb_files_created"] == 0
assert properties["nb_files_modified"] == 0
def test_send_tool_call_finished_nb_files_created_write_file_new(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": False})
client.send_tool_call_finished(
tool_call=tool_call,
status="success",
decision=None,
agent_profile_name="default",
result={"file_existed": False},
)
assert telemetry_events[0]["properties"]["nb_files_created"] == 1
assert telemetry_events[0]["properties"]["nb_files_modified"] == 0
def test_send_tool_call_finished_nb_files_modified_write_file_overwrite(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("write_file", {"overwrite": True})
client.send_tool_call_finished(
tool_call=tool_call,
status="success",
decision=None,
agent_profile_name="default",
result={"file_existed": True},
)
assert telemetry_events[0]["properties"]["nb_files_created"] == 0
assert telemetry_events[0]["properties"]["nb_files_modified"] == 1
def test_send_tool_call_finished_decision_none(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
tool_call = _make_resolved_tool_call("todo", {})
client.send_tool_call_finished(
tool_call=tool_call,
status="skipped",
decision=None,
agent_profile_name="default",
)
assert telemetry_events[0]["properties"]["decision"] is None
assert telemetry_events[0]["properties"]["approval_type"] is None
def test_send_user_copied_text_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
client.send_user_copied_text("hello world")
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe/user_copied_text"
assert telemetry_events[0]["properties"]["text_length"] == 11
def test_send_user_cancelled_action_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
client.send_user_cancelled_action("interrupt_agent")
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe/user_cancelled_action"
assert telemetry_events[0]["properties"]["action"] == "interrupt_agent"
def test_send_auto_compact_triggered_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
client.send_auto_compact_triggered()
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe/auto_compact_triggered"
def test_send_slash_command_used_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
client.send_slash_command_used("help", "builtin")
client.send_slash_command_used("my_skill", "skill")
assert len(telemetry_events) == 2
assert telemetry_events[0]["event_name"] == "vibe/slash_command_used"
assert telemetry_events[0]["properties"]["command"] == "help"
assert telemetry_events[0]["properties"]["command_type"] == "builtin"
assert telemetry_events[1]["properties"]["command"] == "my_skill"
assert telemetry_events[1]["properties"]["command_type"] == "skill"
def test_send_new_session_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(disable_telemetry=False)
client = TelemetryClient(config_getter=lambda: config)
client.send_new_session(
has_agents_md=True,
nb_skills=2,
nb_mcp_servers=1,
nb_models=3,
entrypoint="cli",
)
assert len(telemetry_events) == 1
event_name = telemetry_events[0]["event_name"]
assert event_name == "vibe/new_session"
properties = telemetry_events[0]["properties"]
assert properties["has_agents_md"] is True
assert properties["nb_skills"] == 2
assert properties["nb_mcp_servers"] == 1
assert properties["nb_models"] == 3
assert properties["entrypoint"] == "cli"
assert "version" in properties

View File

@@ -8,7 +8,12 @@ import pytest
import tomli_w
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import TrustedFoldersManager
from vibe.core.trusted_folders import (
AGENTS_MD_FILENAMES,
TrustedFoldersManager,
has_agents_md_file,
has_trustable_content,
)
class TestTrustedFoldersManager:
@@ -203,3 +208,48 @@ class TestTrustedFoldersManager:
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
class TestHasAgentsMdFile:
def test_returns_false_for_empty_directory(self, tmp_path: Path) -> None:
assert has_agents_md_file(tmp_path) is False
def test_returns_true_when_agents_md_exists(self, tmp_path: Path) -> None:
(tmp_path / "AGENTS.md").write_text("# Agents", encoding="utf-8")
assert has_agents_md_file(tmp_path) is True
def test_returns_true_when_vibe_md_exists(self, tmp_path: Path) -> None:
(tmp_path / "VIBE.md").write_text("# Vibe", encoding="utf-8")
assert has_agents_md_file(tmp_path) is True
def test_returns_true_when_dot_vibe_md_exists(self, tmp_path: Path) -> None:
(tmp_path / ".vibe.md").write_text("# Vibe", encoding="utf-8")
assert has_agents_md_file(tmp_path) is True
def test_returns_false_when_only_other_files_exist(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("", encoding="utf-8")
(tmp_path / ".vibe").mkdir()
assert has_agents_md_file(tmp_path) is False
def test_agents_md_filenames_constant(self) -> None:
assert AGENTS_MD_FILENAMES == ["AGENTS.md", "VIBE.md", ".vibe.md"]
class TestHasTrustableContent:
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None:
(tmp_path / ".agents").mkdir()
assert has_trustable_content(tmp_path) is True
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
for name in AGENTS_MD_FILENAMES:
(tmp_path / name).write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is True
(tmp_path / name).unlink()
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
(tmp_path / "other.txt").write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is False

View File

@@ -41,7 +41,6 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None:
async with app.run_test() as pilot:
await pass_welcome_screen(pilot)
api_screen = app.screen
input_widget = api_screen.query_one("#key", Input)
await pilot.press(*api_key_value)

View File

@@ -67,8 +67,8 @@ def create_test_session():
if metadata is None:
metadata = {
"session_id": session_id,
"start_time": "2023-01-01T12:00:00",
"end_time": "2023-01-01T12:05:00",
"start_time": "2023-01-01T12:00:00Z",
"end_time": "2023-01-01T12:05:00Z",
"total_messages": 2,
"stats": {
"steps": 1,
@@ -635,3 +635,190 @@ class TestSessionLoaderEdgeCases:
assert messages[0].content == "Hello"
assert messages[1].role == Role.assistant
assert messages[1].content == "Hi there!"
@pytest.fixture
def create_test_session_with_cwd():
def _create_session(
session_dir: Path,
session_id: str,
cwd: str,
title: str | None = None,
end_time: str | None = None,
) -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}"
session_folder.mkdir(exist_ok=True)
messages_file = session_folder / "messages.jsonl"
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
metadata = {
"session_id": session_id,
"start_time": "2024-01-01T12:00:00Z",
"end_time": end_time or "2024-01-01T12:05:00Z",
"environment": {"working_directory": cwd},
"title": title,
}
metadata_file = session_folder / "meta.json"
with metadata_file.open("w", encoding="utf-8") as f:
json.dump(metadata, f)
return session_folder
return _create_session
class TestSessionLoaderListSessions:
def test_list_sessions_empty(self, session_config: SessionLoggingConfig) -> None:
result = SessionLoader.list_sessions(session_config)
assert result == []
def test_list_sessions_returns_all_sessions(
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
) -> None:
session_dir = Path(session_config.save_dir)
create_test_session_with_cwd(
session_dir,
"aaaaaaaa-1111",
"/home/user/project1",
title="First session",
end_time="2024-01-01T12:00:00Z",
)
create_test_session_with_cwd(
session_dir,
"bbbbbbbb-2222",
"/home/user/project2",
title="Second session",
end_time="2024-01-01T13:00:00Z",
)
result = SessionLoader.list_sessions(session_config)
assert len(result) == 2
session_ids = {s["session_id"] for s in result}
assert "aaaaaaaa-1111" in session_ids
assert "bbbbbbbb-2222" in session_ids
def test_list_sessions_filters_by_cwd(
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
) -> None:
session_dir = Path(session_config.save_dir)
create_test_session_with_cwd(
session_dir,
"aaaaaaaa-proj1",
"/home/user/project1",
title="Project 1 session",
)
create_test_session_with_cwd(
session_dir,
"bbbbbbbb-proj2",
"/home/user/project2",
title="Project 2 session",
)
create_test_session_with_cwd(
session_dir,
"cccccccc-proj1",
"/home/user/project1",
title="Another Project 1 session",
)
result = SessionLoader.list_sessions(session_config, cwd="/home/user/project1")
assert len(result) == 2
for session in result:
assert session["cwd"] == "/home/user/project1"
def test_list_sessions_includes_all_fields(
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
) -> None:
session_dir = Path(session_config.save_dir)
create_test_session_with_cwd(
session_dir,
"test-session-123",
"/home/user/project",
title="Test Session Title",
end_time="2024-01-15T10:30:00Z",
)
result = SessionLoader.list_sessions(session_config)
assert len(result) == 1
session = result[0]
assert session["session_id"] == "test-session-123"
assert session["cwd"] == "/home/user/project"
assert session["title"] == "Test Session Title"
def test_list_sessions_skips_invalid_sessions(
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
) -> None:
session_dir = Path(session_config.save_dir)
create_test_session_with_cwd(
session_dir, "valid-se", "/home/user/project", title="Valid Session"
)
invalid_session = session_dir / "test_20240101_120000_invalid1"
invalid_session.mkdir()
(invalid_session / "meta.json").write_text('{"session_id": "invalid"}')
no_id_session = session_dir / "test_20240101_120001_noid0000"
no_id_session.mkdir()
(no_id_session / "messages.jsonl").write_text(
'{"role": "user", "content": "Hello"}\n'
)
(no_id_session / "meta.json").write_text(
'{"environment": {"working_directory": "/test"}}'
)
result = SessionLoader.list_sessions(session_config)
assert len(result) == 1
assert result[0]["session_id"] == "valid-se"
def test_list_sessions_nonexistent_save_dir(self) -> None:
bad_config = SessionLoggingConfig(
save_dir="/nonexistent/path", session_prefix="test", enabled=True
)
result = SessionLoader.list_sessions(bad_config)
assert result == []
def test_list_sessions_handles_missing_environment(
self, session_config: SessionLoggingConfig
) -> None:
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20240101_120000_noenv000"
session_folder.mkdir()
(session_folder / "messages.jsonl").write_text(
'{"role": "user", "content": "Hello"}\n'
)
(session_folder / "meta.json").write_text(
'{"session_id": "noenv000", "end_time": "2024-01-01T12:00:00Z"}'
)
result = SessionLoader.list_sessions(session_config)
assert len(result) == 1
assert result[0]["session_id"] == "noenv000"
assert result[0]["cwd"] == "" # Empty string when no working_directory
def test_list_sessions_handles_none_title(
self, session_config: SessionLoggingConfig, create_test_session_with_cwd
) -> None:
session_dir = Path(session_config.save_dir)
create_test_session_with_cwd(
session_dir, "notitle0", "/home/user/project", title=None
)
result = SessionLoader.list_sessions(session_config)
assert len(result) == 1
assert result[0]["session_id"] == "notitle0"
assert result[0]["title"] is None

View File

@@ -8,6 +8,7 @@ from tests.conftest import build_test_vibe_config
from tests.skills.conftest import create_skill
from vibe.core.config import VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.trusted_folders import trusted_folders_manager
@pytest.fixture
@@ -184,6 +185,85 @@ class TestSkillManagerParsing:
class TestSkillManagerSearchPaths:
def test_discovers_from_vibe_skills_when_cwd_trusted(
self, tmp_working_directory: Path
) -> None:
trusted_folders_manager.add_trusted(tmp_working_directory)
vibe_skills = tmp_working_directory / ".vibe" / "skills"
vibe_skills.mkdir(parents=True)
create_skill(vibe_skills, "vibe-skill", "Skill from .vibe/skills")
config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False, skill_paths=[]
)
manager = SkillManager(lambda: config)
assert "vibe-skill" in manager.available_skills
assert (
manager.available_skills["vibe-skill"].description
== "Skill from .vibe/skills"
)
def test_discovers_from_agents_skills_when_cwd_trusted(
self, tmp_working_directory: Path
) -> None:
trusted_folders_manager.add_trusted(tmp_working_directory)
agents_skills = tmp_working_directory / ".agents" / "skills"
agents_skills.mkdir(parents=True)
create_skill(agents_skills, "agents-skill", "Skill from .agents/skills")
config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False, skill_paths=[]
)
manager = SkillManager(lambda: config)
assert "agents-skill" in manager.available_skills
assert (
manager.available_skills["agents-skill"].description
== "Skill from .agents/skills"
)
def test_discovers_from_both_vibe_and_agents_skills_when_cwd_trusted(
self, tmp_working_directory: Path
) -> None:
trusted_folders_manager.add_trusted(tmp_working_directory)
vibe_skills = tmp_working_directory / ".vibe" / "skills"
agents_skills = tmp_working_directory / ".agents" / "skills"
vibe_skills.mkdir(parents=True)
agents_skills.mkdir(parents=True)
create_skill(vibe_skills, "vibe-only", "From .vibe")
create_skill(agents_skills, "agents-only", "From .agents")
config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False, skill_paths=[]
)
manager = SkillManager(lambda: config)
assert len(manager.available_skills) == 2
assert manager.available_skills["vibe-only"].description == "From .vibe"
assert manager.available_skills["agents-only"].description == "From .agents"
def test_first_discovered_wins_when_same_skill_in_vibe_and_agents(
self, tmp_working_directory: Path
) -> None:
trusted_folders_manager.add_trusted(tmp_working_directory)
vibe_skills = tmp_working_directory / ".vibe" / "skills"
agents_skills = tmp_working_directory / ".agents" / "skills"
vibe_skills.mkdir(parents=True)
agents_skills.mkdir(parents=True)
create_skill(vibe_skills, "shared-skill", "First from .vibe")
create_skill(agents_skills, "shared-skill", "Second from .agents")
config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False, skill_paths=[]
)
manager = SkillManager(lambda: config)
assert len(manager.available_skills) == 1
assert (
manager.available_skills["shared-skill"].description == "First from .vibe"
)
def test_discovers_from_multiple_skill_paths(self, tmp_path: Path) -> None:
# Create two separate skill directories
skills_dir_1 = tmp_path / "skills1"

View File

@@ -111,7 +111,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -141,7 +141,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -160,7 +160,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -164,7 +164,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="12.2" y="245.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#9a9b99" x="48.8" y="294.3" width="61" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -160,7 +160,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -160,7 +160,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -160,7 +160,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -159,7 +159,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -159,7 +159,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 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">ProxySetupTestApp</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="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="256.2" clip-path="url(#terminal-line-26)">Proxy&#160;setup&#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="268.4" clip-path="url(#terminal-line-27)">Proxy&#160;setup&#160;cancelled.</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,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">PrePopulatedProxySetupTestApp</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="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="256.2" clip-path="url(#terminal-line-26)">Proxy&#160;setup&#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="793" clip-path="url(#terminal-line-27)">Proxy&#160;settings&#160;saved.&#160;Restart&#160;the&#160;CLI&#160;for&#160;changes&#160;to&#160;take&#160;effect.</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: #4b4e55 }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #292929 }
.terminal-r5 { fill: #9a9b99 }
.terminal-r6 { fill: #608ab1;font-weight: bold }
.terminal-r7 { fill: #868887 }
.terminal-r8 { fill: #949798 }
</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">ProxySetupTestApp</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="#4b4e55" x="1207.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="48.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r2" x="1207.8" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type&#160;</text><text class="terminal-r3" x="231.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">/help</text><text class="terminal-r1" x="292.8" y="44.4" textLength="256.2" clip-path="url(#terminal-line-1)">&#160;for&#160;more&#160;information</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-r5" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="48.8" y="93.2" textLength="256.2" clip-path="url(#terminal-line-3)">Proxy&#160;setup&#160;opened...</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-r5" x="0" y="166.4" textLength="1220" clip-path="url(#terminal-line-6)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r5" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r6" x="24.4" y="190.8" textLength="231.8" clip-path="url(#terminal-line-7)">Proxy&#160;Configuration</text><text class="terminal-r5" x="1207.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r5" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r5" x="1207.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r5" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r6" x="24.4" y="239.6" textLength="122" clip-path="url(#terminal-line-9)">HTTP_PROXY</text><text class="terminal-r7" x="170.8" y="239.6" textLength="329.4" clip-path="url(#terminal-line-9)">Proxy&#160;URL&#160;for&#160;HTTP&#160;requests</text><text class="terminal-r5" x="1207.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r5" x="0" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r5" x="1207.8" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r5" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r5" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r8" x="48.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">N</text><text class="terminal-r7" x="61" y="288.4" textLength="73.2" clip-path="url(#terminal-line-11)">OT&#160;SET</text><text class="terminal-r5" x="1207.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r5" x="1207.8" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r6" x="24.4" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">HTTPS_PROXY</text><text class="terminal-r7" x="183" y="337.2" textLength="341.6" clip-path="url(#terminal-line-13)">Proxy&#160;URL&#160;for&#160;HTTPS&#160;requests</text><text class="terminal-r5" x="1207.8" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r5" x="1207.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r5" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r5" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r7" x="48.8" y="386" textLength="85.4" clip-path="url(#terminal-line-15)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r5" x="1207.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r5" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r6" x="24.4" y="434.8" textLength="109.8" clip-path="url(#terminal-line-17)">ALL_PROXY</text><text class="terminal-r7" x="158.6" y="434.8" textLength="451.4" clip-path="url(#terminal-line-17)">Proxy&#160;URL&#160;for&#160;all&#160;requests&#160;(fallback)</text><text class="terminal-r5" x="1207.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r5" x="1207.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r5" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r7" x="48.8" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r5" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r5" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r6" x="24.4" y="532.4" textLength="97.6" clip-path="url(#terminal-line-21)">NO_PROXY</text><text class="terminal-r7" x="146.4" y="532.4" textLength="549" clip-path="url(#terminal-line-21)">Comma-separated&#160;list&#160;of&#160;hosts&#160;to&#160;bypass&#160;proxy</text><text class="terminal-r5" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r5" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r5" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r7" x="48.8" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="158.6" clip-path="url(#terminal-line-25)">SSL_CERT_FILE</text><text class="terminal-r7" x="207.4" y="630" textLength="427" clip-path="url(#terminal-line-25)">Path&#160;to&#160;custom&#160;SSL&#160;certificate&#160;file</text><text class="terminal-r5" x="1207.8" y="630" textLength="12.2" 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-r5" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r5" 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-r5" 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="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r7" x="48.8" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">NOT&#160;SET</text><text class="terminal-r5" 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-r5" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r5" 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-r5" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="24.4" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">SSL_CERT_DIR</text><text class="terminal-r7" x="195.2" y="727.6" textLength="549" clip-path="url(#terminal-line-29)">Path&#160;to&#160;directory&#160;containing&#160;SSL&#160;certificates</text><text class="terminal-r5" 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-r5" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r5" 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-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r5" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r7" x="48.8" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">NOT&#160;SET</text><text class="terminal-r5" 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-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" 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-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;navigate&#160;&#160;Enter&#160;save&#160;&amp;&#160;exit&#160;&#160;ESC&#160;cancel</text><text class="terminal-r5" 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-r5" 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-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" 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: 21 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: #4b4e55 }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #292929 }
.terminal-r5 { fill: #9a9b99 }
.terminal-r6 { fill: #608ab1;font-weight: bold }
.terminal-r7 { 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">PrePopulatedProxySetupTestApp</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="#4b4e55" x="1207.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1207.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="48.8" y="269.9" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="305" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r2" x="1207.8" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type&#160;</text><text class="terminal-r3" x="231.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">/help</text><text class="terminal-r1" x="292.8" y="44.4" textLength="256.2" clip-path="url(#terminal-line-1)">&#160;for&#160;more&#160;information</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-r5" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="48.8" y="93.2" textLength="256.2" clip-path="url(#terminal-line-3)">Proxy&#160;setup&#160;opened...</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-r5" x="0" y="166.4" textLength="1220" clip-path="url(#terminal-line-6)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r5" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r6" x="24.4" y="190.8" textLength="231.8" clip-path="url(#terminal-line-7)">Proxy&#160;Configuration</text><text class="terminal-r5" x="1207.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r5" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r5" x="1207.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r5" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r6" x="24.4" y="239.6" textLength="122" clip-path="url(#terminal-line-9)">HTTP_PROXY</text><text class="terminal-r7" x="170.8" y="239.6" textLength="329.4" clip-path="url(#terminal-line-9)">Proxy&#160;URL&#160;for&#160;HTTP&#160;requests</text><text class="terminal-r5" x="1207.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r5" x="0" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r5" x="1207.8" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r5" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r5" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="48.8" y="288.4" textLength="256.2" clip-path="url(#terminal-line-11)">http://old-proxy:8080</text><text class="terminal-r5" x="1207.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r5" x="1207.8" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r6" x="24.4" y="337.2" textLength="134.2" clip-path="url(#terminal-line-13)">HTTPS_PROXY</text><text class="terminal-r7" x="183" y="337.2" textLength="341.6" clip-path="url(#terminal-line-13)">Proxy&#160;URL&#160;for&#160;HTTPS&#160;requests</text><text class="terminal-r5" x="1207.8" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r5" x="1207.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r5" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r5" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="48.8" y="386" textLength="1146.8" clip-path="url(#terminal-line-15)">https://old-proxy:8443&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r5" x="1207.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r5" x="1207.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r5" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r6" x="24.4" y="434.8" textLength="109.8" clip-path="url(#terminal-line-17)">ALL_PROXY</text><text class="terminal-r7" x="158.6" y="434.8" textLength="451.4" clip-path="url(#terminal-line-17)">Proxy&#160;URL&#160;for&#160;all&#160;requests&#160;(fallback)</text><text class="terminal-r5" x="1207.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r5" x="1207.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r5" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r7" x="48.8" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r5" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r5" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r6" x="24.4" y="532.4" textLength="97.6" clip-path="url(#terminal-line-21)">NO_PROXY</text><text class="terminal-r7" x="146.4" y="532.4" textLength="549" clip-path="url(#terminal-line-21)">Comma-separated&#160;list&#160;of&#160;hosts&#160;to&#160;bypass&#160;proxy</text><text class="terminal-r5" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r5" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r5" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r7" x="48.8" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">NOT&#160;SET</text><text class="terminal-r5" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="158.6" clip-path="url(#terminal-line-25)">SSL_CERT_FILE</text><text class="terminal-r7" x="207.4" y="630" textLength="427" clip-path="url(#terminal-line-25)">Path&#160;to&#160;custom&#160;SSL&#160;certificate&#160;file</text><text class="terminal-r5" x="1207.8" y="630" textLength="12.2" 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-r5" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r5" 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-r5" 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="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r7" x="48.8" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">NOT&#160;SET</text><text class="terminal-r5" 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-r5" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r5" 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-r5" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r6" x="24.4" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">SSL_CERT_DIR</text><text class="terminal-r7" x="195.2" y="727.6" textLength="549" clip-path="url(#terminal-line-29)">Path&#160;to&#160;directory&#160;containing&#160;SSL&#160;certificates</text><text class="terminal-r5" 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-r5" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r5" 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-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r5" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r7" x="48.8" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">NOT&#160;SET</text><text class="terminal-r5" 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-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r5" 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-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r5" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓&#160;navigate&#160;&#160;Enter&#160;save&#160;&amp;&#160;exit&#160;&#160;ESC&#160;cancel</text><text class="terminal-r5" 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-r5" 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-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" 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: 22 KiB

View File

@@ -0,0 +1,201 @@
<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: #cc555a;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">ProxySetupTestApp</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="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="256.2" clip-path="url(#terminal-line-26)">Proxy&#160;setup&#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-r5" x="48.8" y="678.8" textLength="671" clip-path="url(#terminal-line-27)">Error:&#160;Failed&#160;to&#160;save&#160;proxy&#160;settings:&#160;Permission&#160;denied</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,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">ProxySetupTestApp</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="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="256.2" clip-path="url(#terminal-line-26)">Proxy&#160;setup&#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="793" clip-path="url(#terminal-line-27)">Proxy&#160;settings&#160;saved.&#160;Restart&#160;the&#160;CLI&#160;for&#160;changes&#160;to&#160;take&#160;effect.</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

@@ -162,7 +162,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -162,7 +162,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -162,7 +162,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -162,7 +162,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -163,7 +163,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -161,7 +161,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -161,7 +161,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -162,7 +162,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -162,7 +162,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -161,7 +161,7 @@
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v2.1.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="20" textLength="146.4" clip-path="url(#terminal-line-0)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="20" textLength="122" clip-path="url(#terminal-line-0)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="20" textLength="183" clip-path="url(#terminal-line-0)">devstral-latest</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="44.4" textLength="414.8" clip-path="url(#terminal-line-1)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</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="0" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Type&#160;</text><text class="terminal-r3" x="231.8" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">/help</text><text class="terminal-r1" x="292.8" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)">&#160;for&#160;more&#160;information</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)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.banner.banner.__version__", "0.0.0"
)

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import pytest
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from vibe.core.proxy_setup import get_current_proxy_settings, set_proxy_var
class ProxySetupTestApp(BaseSnapshotTestApp):
async def on_mount(self) -> None:
await super().on_mount()
await self._switch_to_proxy_setup_app()
class PrePopulatedProxySetupTestApp(BaseSnapshotTestApp):
async def on_mount(self) -> None:
set_proxy_var("HTTP_PROXY", "http://old-proxy:8080")
set_proxy_var("HTTPS_PROXY", "https://old-proxy:8443")
await super().on_mount()
await self._switch_to_proxy_setup_app()
def test_snapshot_proxy_setup_initial_empty(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_proxy_setup_initial_with_values(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_proxy_setup.py:PrePopulatedProxySetupTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
def test_snapshot_proxy_setup_save_new_values(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press(*"http://proxy.example.com:8080")
await pilot.press("down")
await pilot.press(*"https://proxy.example.com:8443")
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
settings = get_current_proxy_settings()
assert settings["HTTP_PROXY"] == "http://proxy.example.com:8080"
assert settings["HTTPS_PROXY"] == "https://proxy.example.com:8443"
def test_snapshot_proxy_setup_edit_existing_values(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press("ctrl+u")
await pilot.press(*"http://new-proxy:9090")
await pilot.press("down")
await pilot.press("ctrl+u")
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_proxy_setup.py:PrePopulatedProxySetupTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
settings = get_current_proxy_settings()
assert settings["HTTP_PROXY"] == "http://new-proxy:9090"
assert settings["HTTPS_PROXY"] is None
def test_snapshot_proxy_setup_cancel_discards_changes(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press(*"http://should-not-save:8080")
await pilot.pause(0.1)
await pilot.press("escape")
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
terminal_size=(100, 36),
run_before=run_before,
)
settings = get_current_proxy_settings()
assert settings["HTTP_PROXY"] is None
def test_snapshot_proxy_setup_save_error(
snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch
) -> None:
def raise_error(*args, **kwargs):
raise OSError("Permission denied")
monkeypatch.setattr("vibe.core.proxy_setup.set_key", raise_error)
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press(*"http://proxy:8080")
await pilot.press("enter")
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_proxy_setup.py:ProxySetupTestApp",
terminal_size=(100, 36),
run_before=run_before,
)

View File

@@ -16,7 +16,9 @@ from vibe.core.types import (
@pytest.mark.asyncio
async def test_auto_compact_triggers_and_batches_observer() -> None:
async def test_auto_compact_triggers_and_batches_observer(
telemetry_events: list[dict],
) -> None:
observed: list[tuple[Role, str | None]] = []
def observer(msg: LLMMessage) -> None:
@@ -52,3 +54,10 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
assert roles == [Role.system, Role.user, Role.assistant]
assert observed[1][1] is not None and "<summary>" in observed[1][1]
assert observed[2][1] == "<final>"
auto_compact = [
e
for e in telemetry_events
if e.get("event_name") == "vibe/auto_compact_triggered"
]
assert len(auto_compact) == 1

View File

@@ -17,7 +17,6 @@ from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.middleware import (
ConversationContext,
MiddlewareAction,
MiddlewarePipeline,
MiddlewareResult,
ResetReason,
)
@@ -48,9 +47,6 @@ class InjectBeforeMiddleware:
action=MiddlewareAction.INJECT_MESSAGE, message=self.injected_message
)
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
return None
@@ -99,15 +95,17 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
async for _ in agent.act("How can you help?"):
pass
assert len(observed) == 3
assert [r for r, _ in observed] == [Role.system, Role.user, Role.assistant]
assert len(observed) == 4
assert [r for r, _ in observed] == [
Role.system,
Role.user,
Role.user,
Role.assistant,
]
assert observed[0][1] == "You are Vibe, a super useful programming assistant."
# injected content should be appended to the user's message before emission
assert (
observed[1][1]
== f"How can you help?\n\n{InjectBeforeMiddleware.injected_message}"
)
assert observed[2][1] == "I can write very efficient code."
assert observed[1][1] == "How can you help?"
assert observed[2][1] == InjectBeforeMiddleware.injected_message
assert observed[3][1] == "I can write very efficient code."
@pytest.mark.asyncio
@@ -318,19 +316,14 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
@pytest.mark.asyncio
async def test_act_handles_user_cancellation_during_streaming() -> None:
class CountingMiddleware(MiddlewarePipeline):
class CountingMiddleware:
def __init__(self) -> None:
self.before_calls = 0
self.after_calls = 0
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
self.before_calls += 1
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
self.after_calls += 1
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
return None
@@ -371,7 +364,6 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
ToolResultEvent,
]
assert middleware.before_calls == 1
assert middleware.after_calls == 0
assert isinstance(events[-1], ToolResultEvent)
assert events[-1].skipped is True
assert events[-1].skip_reason is not None

View File

@@ -378,9 +378,7 @@ class TestReloadPreservesMessages:
assert agent.messages[0].role == Role.system
@pytest.mark.asyncio
async def test_reload_notifies_observer_with_all_messages(
self, observer_capture
) -> None:
async def test_reload_does_not_reemit_to_observer(self, observer_capture) -> None:
observed, observer = observer_capture
backend = FakeBackend(mock_llm_chunk(content="Response"))
agent = build_test_agent_loop(
@@ -394,10 +392,7 @@ class TestReloadPreservesMessages:
await agent.reload_with_initial_messages()
assert len(observed) == 3
assert observed[0].role == Role.system
assert observed[1].role == Role.user
assert observed[2].role == Role.assistant
assert len(observed) == 0
class TestCompactStatsHandling:

View File

@@ -75,7 +75,9 @@ def make_agent_loop(
@pytest.mark.asyncio
async def test_single_tool_call_executes_under_auto_approve() -> None:
async def test_single_tool_call_executes_under_auto_approve(
telemetry_events: list[dict],
) -> None:
mocked_tool_call_id = "call_1"
tool_call = make_todo_tool_call(mocked_tool_call_id)
backend = FakeBackend([
@@ -110,9 +112,19 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
assert tool_msgs[-1].tool_call_id == mocked_tool_call_id
assert "total_count" in (tool_msgs[-1].content or "")
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["tool_name"] == "todo"
assert tool_finished[0]["properties"]["status"] == "success"
assert tool_finished[0]["properties"]["approval_type"] == "always"
@pytest.mark.asyncio
async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
async def test_tool_call_requires_approval_if_not_auto_approved(
telemetry_events: list[dict],
) -> None:
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
@@ -145,9 +157,15 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask"
@pytest.mark.asyncio
async def test_tool_call_approved_by_callback() -> None:
async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None:
def approval_callback(
_tool_name: str, _args: BaseModel, _tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
@@ -179,11 +197,17 @@ async def test_tool_call_approved_by_callback() -> None:
assert agent_loop.stats.tool_calls_rejected == 0
assert agent_loop.stats.tool_calls_succeeded == 1
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask"
@pytest.mark.asyncio
async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_callback() -> (
None
):
async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_callback(
telemetry_events: list[dict],
) -> None:
custom_feedback = "User declined tool execution"
def approval_callback(
@@ -218,9 +242,17 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "ask"
@pytest.mark.asyncio
async def test_tool_call_skipped_when_permission_is_never() -> None:
async def test_tool_call_skipped_when_permission_is_never(
telemetry_events: list[dict],
) -> None:
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.NEVER,
@@ -254,6 +286,12 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
tool_finished = [
e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished"
]
assert len(tool_finished) == 1
assert tool_finished[0]["properties"]["approval_type"] == "never"
@pytest.mark.asyncio
async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None:

View File

@@ -26,7 +26,7 @@ class SpyStreamingFormatter:
def test_run_programmatic_preload_streaming_is_batched(
monkeypatch: pytest.MonkeyPatch,
monkeypatch: pytest.MonkeyPatch, telemetry_events: list[dict]
) -> None:
spy = SpyStreamingFormatter()
monkeypatch.setattr(
@@ -77,6 +77,14 @@ def test_run_programmatic_preload_streaming_is_batched(
Role.user,
Role.assistant,
]
new_session = [
e for e in telemetry_events if e.get("event_name") == "vibe/new_session"
]
assert len(new_session) == 1
assert new_session[0]["properties"]["entrypoint"] == "programmatic"
assert "version" in new_session[0]["properties"]
assert (
spy.emitted[0][1] == "You are Vibe, a super useful programming assistant."
)

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import LLMMessage, Role
def test_merge_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.system, content="System"),
LLMMessage(role=Role.user, content="User 1"),
LLMMessage(role=Role.user, content="User 2"),
LLMMessage(role=Role.assistant, content="Assistant"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 3
assert result[1].content == "User 1\n\nUser 2"
def test_preserves_non_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.user, content="User 1"),
LLMMessage(role=Role.assistant, content="Assistant"),
LLMMessage(role=Role.user, content="User 2"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 3
def test_empty_messages() -> None:
assert merge_consecutive_user_messages([]) == []
def test_single_message() -> None:
messages = [LLMMessage(role=Role.user, content="Only one")]
result = merge_consecutive_user_messages(messages)
assert len(result) == 1
def test_three_consecutive_user_messages() -> None:
messages = [
LLMMessage(role=Role.user, content="A"),
LLMMessage(role=Role.user, content="B"),
LLMMessage(role=Role.user, content="C"),
]
result = merge_consecutive_user_messages(messages)
assert len(result) == 1
assert result[0].content == "A\n\nB\n\nC"

View File

@@ -2,14 +2,17 @@ from __future__ import annotations
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from vibe.core.agents.models import BUILTIN_AGENTS, AgentProfile, BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.middleware import (
PLAN_AGENT_EXIT,
PLAN_AGENT_REMINDER,
ConversationContext,
MiddlewareAction,
MiddlewarePipeline,
PlanAgentMiddleware,
ResetReason,
)
from vibe.core.types import AgentStats
@@ -71,28 +74,58 @@ class TestPlanAgentMiddleware:
assert result.message is None
@pytest.mark.asyncio
async def test_after_turn_always_continues(self, ctx: ConversationContext) -> None:
async def test_injects_reminder_only_once_while_in_plan_mode(
self, ctx: ConversationContext
) -> None:
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
result = await middleware.after_turn(ctx)
result1 = await middleware.before_turn(ctx)
assert result1.action == MiddlewareAction.INJECT_MESSAGE
assert result1.message == PLAN_AGENT_REMINDER
assert result.action == MiddlewareAction.CONTINUE
result2 = await middleware.before_turn(ctx)
assert result2.action == MiddlewareAction.CONTINUE
assert result2.message is None
@pytest.mark.asyncio
async def test_dynamically_checks_agent(self, ctx: ConversationContext) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
async def test_injects_exit_message_when_leaving_plan_mode(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
# Enter plan mode
await middleware.before_turn(ctx)
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
# Leave plan mode
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
async def test_reinjects_reminder_when_reentering_plan_mode(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
# Enter plan mode - should inject reminder
result1 = await middleware.before_turn(ctx)
assert result1.action == MiddlewareAction.INJECT_MESSAGE
assert result1.message == PLAN_AGENT_REMINDER
# Leave plan mode - should inject exit message
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result2 = await middleware.before_turn(ctx)
assert result2.action == MiddlewareAction.INJECT_MESSAGE
assert result2.message == PLAN_AGENT_EXIT
# Re-enter plan mode - should inject reminder again
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
result3 = await middleware.before_turn(ctx)
assert result3.action == MiddlewareAction.INJECT_MESSAGE
assert result3.message == PLAN_AGENT_REMINDER
@pytest.mark.asyncio
async def test_custom_reminder(self, ctx: ConversationContext) -> None:
@@ -105,10 +138,238 @@ class TestPlanAgentMiddleware:
assert result.message == custom_reminder
def test_reset_does_nothing(self) -> None:
@pytest.mark.asyncio
async def test_reset_clears_state(self, ctx: ConversationContext) -> None:
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
await middleware.before_turn(ctx) # Enter and inject
middleware.reset()
# Should inject again after reset
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
@pytest.mark.asyncio
async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
# Enter plan mode
await middleware.before_turn(ctx)
# Leave plan mode - first call should inject exit
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
# Subsequent calls in default mode should be CONTINUE
result2 = await middleware.before_turn(ctx)
assert result2.action == MiddlewareAction.CONTINUE
assert result2.message is None
@pytest.mark.asyncio
async def test_multiple_turns_in_plan_mode_after_entry(
self, ctx: ConversationContext
) -> None:
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
# First turn: inject reminder
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
# Several more turns in plan mode: all should be CONTINUE
for _ in range(5):
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
assert result.message is None
@pytest.mark.asyncio
async def test_multiple_turns_in_default_mode_after_exit(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
await middleware.before_turn(ctx) # enter plan
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
await middleware.before_turn(ctx) # exit plan (fires exit message)
# Several more turns in default mode: all should be CONTINUE
for _ in range(5):
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
assert result.message is None
@pytest.mark.asyncio
async def test_rapid_toggling_plan_default_multiple_cycles(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
for _ in range(3):
# Enter plan
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
# Leave plan
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
@pytest.mark.asyncio
async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
await middleware.before_turn(ctx) # enter plan
# Switch to auto_approve (not default)
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
@pytest.mark.asyncio
async def test_exit_to_accept_edits_agent(self, ctx: ConversationContext) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
await middleware.before_turn(ctx) # enter plan
# Switch to accept_edits
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
@pytest.mark.asyncio
async def test_switching_between_non_plan_agents(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
middleware = PlanAgentMiddleware(lambda: current_profile)
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
async def test_non_plan_to_plan_entry(self, ctx: ConversationContext) -> None:
"""Starting in a non-plan agent then entering plan should inject reminder."""
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE]
middleware = PlanAgentMiddleware(lambda: current_profile)
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
# Now switch to plan
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
@pytest.mark.asyncio
async def test_reset_while_in_default_after_exiting_plan(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
await middleware.before_turn(ctx) # enter plan
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
await middleware.before_turn(ctx) # exit plan
middleware.reset()
# Still in default mode - should CONTINUE (no phantom exit message)
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
async def test_reset_while_in_default_then_reenter_plan(
self, ctx: ConversationContext
) -> None:
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
await middleware.before_turn(ctx) # enter plan
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
await middleware.before_turn(ctx) # exit plan
middleware.reset()
# Re-enter plan after reset
current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
@pytest.mark.asyncio
async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None:
middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])
await middleware.before_turn(ctx) # enter and inject
middleware.reset(ResetReason.COMPACT)
# Should reinject reminder after compact reset
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
@pytest.mark.asyncio
async def test_custom_exit_message(self, ctx: ConversationContext) -> None:
custom_exit = "Custom exit message"
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(
lambda: current_profile, exit_message=custom_exit
)
await middleware.before_turn(ctx) # enter plan
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.message == custom_exit
@pytest.mark.asyncio
async def test_plan_entry_then_immediate_exit_same_not_possible(
self, ctx: ConversationContext
) -> None:
"""Even if profile changes between two calls, each call sees one transition."""
current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN]
middleware = PlanAgentMiddleware(lambda: current_profile)
# First call: entry
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
# Second call (still plan): no injection
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
# Third call (switched to default): exit
current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
# Fourth call (still default): no injection
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
class TestMiddlewarePipelineWithPlanAgent:
@pytest.mark.asyncio
@@ -135,3 +396,222 @@ class TestMiddlewarePipelineWithPlanAgent:
result = await pipeline.run_before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
class TestPlanAgentMiddlewareIntegration:
@pytest.mark.asyncio
async def test_switch_agent_preserves_middleware_state_for_exit_message(
self,
) -> None:
config = build_test_vibe_config(
auto_compact_threshold=0,
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
enabled_tools=[],
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next(
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
result = await plan_middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
await agent.switch_agent(BuiltinAgentName.DEFAULT)
plan_middleware_after = next(
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
assert plan_middleware is plan_middleware_after
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
result = await plan_middleware_after.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
@pytest.mark.asyncio
async def test_switch_agent_allows_reinjection_on_reentry(self) -> None:
config = build_test_vibe_config(
auto_compact_threshold=0,
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
enabled_tools=[],
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next(
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
await plan_middleware.before_turn(ctx)
await agent.switch_agent(BuiltinAgentName.DEFAULT)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
result = await plan_middleware.before_turn(ctx)
assert result.message == PLAN_AGENT_EXIT
await agent.switch_agent(BuiltinAgentName.PLAN)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
result = await plan_middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_REMINDER
@pytest.mark.asyncio
async def test_switch_plan_to_auto_approve_fires_exit(self) -> None:
config = build_test_vibe_config(
auto_compact_threshold=0,
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
enabled_tools=[],
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next(
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
await plan_middleware.before_turn(ctx) # enter plan
await agent.switch_agent(BuiltinAgentName.AUTO_APPROVE)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
result = await plan_middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_AGENT_EXIT
@pytest.mark.asyncio
async def test_switch_between_non_plan_agents_no_injection(self) -> None:
config = build_test_vibe_config(
auto_compact_threshold=0,
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
enabled_tools=[],
)
agent = build_test_agent_loop(
config=config, agent_name=BuiltinAgentName.DEFAULT
)
plan_middleware = next(
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
result = await plan_middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
await agent.switch_agent(BuiltinAgentName.AUTO_APPROVE)
ctx = ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
result = await plan_middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
async def test_full_lifecycle_plan_default_plan_default(self) -> None:
"""Integration test for a full plan -> default -> plan -> default cycle."""
config = build_test_vibe_config(
auto_compact_threshold=0,
system_prompt_id="tests",
include_project_context=False,
include_prompt_detail=False,
include_model_info=False,
include_commit_signature=False,
enabled_tools=[],
)
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
plan_middleware = next(
mw
for mw in agent.middleware_pipeline.middlewares
if isinstance(mw, PlanAgentMiddleware)
)
def _ctx():
return ConversationContext(
messages=agent.messages, stats=agent.stats, config=agent.config
)
# 1. Enter plan: inject reminder
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.INJECT_MESSAGE
assert r.message == PLAN_AGENT_REMINDER
# 2. Stay in plan: no injection
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.CONTINUE
# 3. Switch to default: inject exit
await agent.switch_agent(BuiltinAgentName.DEFAULT)
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.INJECT_MESSAGE
assert r.message == PLAN_AGENT_EXIT
# 4. Stay in default: no injection
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.CONTINUE
# 5. Switch back to plan: inject reminder again
await agent.switch_agent(BuiltinAgentName.PLAN)
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.INJECT_MESSAGE
assert r.message == PLAN_AGENT_REMINDER
# 6. Stay in plan: no injection
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.CONTINUE
# 7. Switch to default again: inject exit
await agent.switch_agent(BuiltinAgentName.DEFAULT)
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.INJECT_MESSAGE
assert r.message == PLAN_AGENT_EXIT
# 8. Stay in default: no injection
r = await plan_middleware.before_turn(_ctx())
assert r.action == MiddlewareAction.CONTINUE

View File

@@ -267,6 +267,7 @@ class TestAPIToolFormatHandlerReasoningContent:
mock_message.role = "assistant"
mock_message.content = "The answer is 42."
mock_message.reasoning_content = "Let me think..."
mock_message.reasoning_signature = None
mock_message.tool_calls = None
result = handler.process_api_response_message(mock_message)

View File

@@ -1,6 +1,10 @@
from __future__ import annotations
from unittest.mock import MagicMock
import logging
import os
import threading
import time
from unittest.mock import MagicMock, patch
from pydantic import ValidationError
import pytest
@@ -9,7 +13,9 @@ from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
from vibe.core.tools.mcp import (
MCPToolResult,
RemoteTool,
_mcp_stderr_capture,
_parse_call_result,
_stderr_logger_thread,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
)
@@ -121,6 +127,66 @@ class TestParseCallResult:
assert result.text == "line1\nline2"
class TestMCPStderrCapture:
"""Tests for _mcp_stderr_capture and _stderr_logger_thread."""
@pytest.mark.asyncio
async def test_mcp_stderr_capture_returns_writable_stream(self):
async with _mcp_stderr_capture() as stream:
assert stream is not None
assert callable(getattr(stream, "write", None))
stream.write("test\n")
def test_stderr_logger_thread_logs_decoded_lines(self):
r_fd, w_fd = os.pipe()
try:
vibe_logger = logging.getLogger("vibe")
with patch.object(vibe_logger, "debug") as debug_mock:
thread = threading.Thread(
target=_stderr_logger_thread, args=(r_fd,), daemon=True
)
thread.start()
try:
w = os.fdopen(w_fd, "wb")
w_fd = -1
w.write(b"hello stderr\n")
w.write(b"second line\n")
w.close()
w = None
finally:
time.sleep(0.05)
debug_mock.assert_any_call("[MCP stderr] hello stderr")
debug_mock.assert_any_call("[MCP stderr] second line")
finally:
if w_fd >= 0:
try:
os.close(w_fd)
except OSError:
pass
try:
os.close(r_fd)
except OSError:
pass
@pytest.mark.asyncio
async def test_mcp_stderr_capture_logs_written_data(self):
vibe_logger = logging.getLogger("vibe")
with patch.object(vibe_logger, "debug") as debug_mock:
async with _mcp_stderr_capture() as stream:
stream.write("captured line\n")
time.sleep(0.05)
debug_mock.assert_called_with("[MCP stderr] captured line")
@pytest.mark.asyncio
async def test_mcp_stderr_capture_ignores_empty_lines(self):
vibe_logger = logging.getLogger("vibe")
with patch.object(vibe_logger, "debug") as debug_mock:
async with _mcp_stderr_capture() as stream:
stream.write("\n\n")
time.sleep(0.05)
debug_mock.assert_not_called()
class TestCreateMCPHttpProxyToolClass:
def test_creates_tool_class_with_correct_name(self):
remote = RemoteTool(name="my_tool", description="Test tool")

55
uv.lock generated
View File

@@ -366,6 +366,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/f9/9ff5a301459f804a885f237453ba81564bc6ee54740e9f2676c2642043f6/giturlparse-0.14.0-py2.py3-none-any.whl", hash = "sha256:04fd9c262ca9a4db86043d2ef32b2b90bfcbcdefc4f6a260fd9402127880931d", size = 16299, upload-time = "2025-10-22T09:21:10.818Z" },
]
[[package]]
name = "google-auth"
version = "2.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
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 = "h11"
version = "0.16.0"
@@ -710,7 +724,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.1.0"
version = "2.2.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@@ -718,6 +732,7 @@ dependencies = [
{ name = "cryptography" },
{ name = "gitpython" },
{ name = "giturlparse" },
{ name = "google-auth" },
{ name = "httpx" },
{ name = "keyring" },
{ name = "mcp" },
@@ -729,6 +744,7 @@ dependencies = [
{ name = "pyperclip" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
{ name = "textual" },
{ name = "textual-speedups" },
@@ -763,9 +779,10 @@ dev = [
requires-dist = [
{ name = "agent-client-protocol", specifier = "==0.8.0" },
{ name = "anyio", specifier = ">=4.12.0" },
{ name = "cryptography", specifier = ">=44.0.0" },
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
{ name = "gitpython", specifier = ">=3.1.46" },
{ name = "giturlparse", specifier = ">=0.14.0" },
{ name = "google-auth", specifier = ">=2.0.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "keyring", specifier = ">=25.6.0" },
{ name = "mcp", specifier = ">=1.14.0" },
@@ -777,6 +794,7 @@ requires-dist = [
{ name = "pyperclip", specifier = ">=1.11.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "requests", specifier = ">=2.20.0" },
{ name = "rich", specifier = ">=14.0.0" },
{ name = "textual", specifier = ">=1.0.0" },
{ name = "textual-speedups", specifier = ">=0.2.1" },
@@ -947,6 +965,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
@@ -1483,6 +1522,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.14.7"

View File

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

View File

@@ -20,7 +20,7 @@ from acp import (
SetSessionModeResponse,
run_agent,
)
from acp.helpers import ContentBlock, SessionUpdate
from acp.helpers import ContentBlock, SessionUpdate, update_available_commands
from acp.schema import (
AgentCapabilities,
AgentMessageChunk,
@@ -28,6 +28,8 @@ from acp.schema import (
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
AvailableCommand,
AvailableCommandInput,
ClientCapabilities,
ContentToolCallContent,
ForkSessionResponse,
@@ -38,6 +40,9 @@ from acp.schema import (
ModelInfo,
PromptCapabilities,
ResumeSessionResponse,
SessionCapabilities,
SessionInfo,
SessionListCapabilities,
SessionModelState,
SessionModeState,
SseMcpServer,
@@ -45,6 +50,7 @@ from acp.schema import (
TextResourceContents,
ToolCallProgress,
ToolCallUpdate,
UnstructuredCommandInput,
UserMessageChunk,
)
from pydantic import BaseModel, ConfigDict
@@ -58,15 +64,33 @@ from vibe.acp.tools.session_update import (
from vibe.acp.utils import (
TOOL_OPTIONS,
ToolOption,
create_assistant_message_replay,
create_compact_end_session_update,
create_compact_start_session_update,
create_reasoning_replay,
create_tool_call_replay,
create_tool_result_replay,
create_user_message_replay,
get_all_acp_session_modes,
get_proxy_help_text,
is_valid_acp_agent,
)
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
from vibe.core.config import (
MissingAPIKeyError,
SessionLoggingConfig,
VibeConfig,
load_dotenv_values,
)
from vibe.core.proxy_setup import (
ProxySetupError,
parse_proxy_command,
set_proxy_var,
unset_proxy_var,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
ApprovalResponse,
@@ -74,7 +98,9 @@ from vibe.core.types import (
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
LLMMessage,
ReasoningEvent,
Role,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
@@ -150,10 +176,13 @@ class VibeAcpAgentLoop(AcpAgent):
response = InitializeResponse(
agent_capabilities=AgentCapabilities(
load_session=False,
load_session=True,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
session_capabilities=SessionCapabilities(
list=SessionListCapabilities()
),
),
protocol_version=PROTOCOL_VERSION,
agent_info=Implementation(
@@ -171,6 +200,44 @@ class VibeAcpAgentLoop(AcpAgent):
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a session"
}) from e
async def _create_acp_session(
self, session_id: str, agent_loop: AgentLoop
) -> AcpSessionLoop:
session = AcpSessionLoop(id=session_id, agent_loop=agent_loop)
self.sessions[session.id] = session
if not agent_loop.auto_approve:
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
asyncio.create_task(self._send_available_commands(session.id))
return session
def _build_session_model_state(self, agent_loop: AgentLoop) -> SessionModelState:
return SessionModelState(
current_model_id=agent_loop.config.active_model,
available_models=[
ModelInfo(model_id=model.alias, name=model.alias)
for model in agent_loop.config.models
],
)
def _build_session_mode_state(self, session: AcpSessionLoop) -> SessionModeState:
return SessionModeState(
current_mode_id=session.agent_loop.agent_profile.name,
available_modes=get_all_acp_session_modes(session.agent_loop.agent_manager),
)
@override
async def new_session(
self,
@@ -181,13 +248,7 @@ class VibeAcpAgentLoop(AcpAgent):
load_dotenv_values()
os.chdir(cwd)
try:
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config.tool_paths.extend(self._get_acp_tool_overrides())
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a new session"
}) from e
config = self._load_config()
agent_loop = AgentLoop(
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
@@ -196,29 +257,14 @@ class VibeAcpAgentLoop(AcpAgent):
# 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 = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop)
self.sessions[session.id] = session
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
agent_loop.emit_new_session_telemetry("acp")
if not agent_loop.auto_approve:
agent_loop.set_approval_callback(
self._create_approval_callback(agent_loop.session_id)
)
response = NewSessionResponse(
session_id=agent_loop.session_id,
models=SessionModelState(
current_model_id=agent_loop.config.active_model,
available_models=[
ModelInfo(model_id=model.alias, name=model.alias)
for model in agent_loop.config.models
],
),
modes=SessionModeState(
current_mode_id=session.agent_loop.agent_profile.name,
available_modes=get_all_acp_session_modes(agent_loop.agent_manager),
),
return NewSessionResponse(
session_id=session.id,
models=self._build_session_model_state(agent_loop),
modes=self._build_session_mode_state(session),
)
return response
def _get_acp_tool_overrides(self) -> list[Path]:
overrides = ["todo"]
@@ -293,6 +339,85 @@ class VibeAcpAgentLoop(AcpAgent):
raise RequestError.invalid_params({"session": "Not found"})
return self.sessions[session_id]
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
if not msg.tool_calls:
return
for tool_call in msg.tool_calls:
if tool_call.id and tool_call.function.name:
update = create_tool_call_replay(
tool_call.id, tool_call.function.name, tool_call.function.arguments
)
await self.client.session_update(session_id=session_id, update=update)
async def _replay_conversation_history(
self, session_id: str, messages: list[LLMMessage]
) -> None:
for msg in messages:
if msg.role == Role.user:
update = create_user_message_replay(msg)
await self.client.session_update(session_id=session_id, update=update)
elif msg.role == Role.assistant:
if text_update := create_assistant_message_replay(msg):
await self.client.session_update(
session_id=session_id, update=text_update
)
if reasoning_update := create_reasoning_replay(msg):
await self.client.session_update(
session_id=session_id, update=reasoning_update
)
await self._replay_tool_calls(session_id, msg)
elif msg.role == Role.tool:
if result_update := create_tool_result_replay(msg):
await self.client.session_update(
session_id=session_id, update=result_update
)
async def _send_available_commands(self, session_id: str) -> None:
commands = [
AvailableCommand(
name="proxy-setup",
description="Configure proxy and SSL certificate settings",
input=AvailableCommandInput(
root=UnstructuredCommandInput(
hint="KEY value to set, KEY to unset, or empty for help"
)
),
)
]
update = update_available_commands(commands)
await self.client.session_update(session_id=session_id, update=update)
async def _handle_proxy_setup_command(
self, session_id: str, text_prompt: str
) -> PromptResponse:
args = text_prompt.strip()[len("/proxy-setup") :].strip()
try:
if not args:
message = get_proxy_help_text()
else:
key, value = parse_proxy_command(args)
if value is not None:
set_proxy_var(key, value)
message = f"Set `{key}={value}` in ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
else:
unset_proxy_var(key)
message = f"Removed `{key}` from ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
except ProxySetupError as e:
message = f"Error: {e}"
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
),
)
return PromptResponse(stop_reason="end_turn")
@override
async def load_session(
self,
@@ -301,7 +426,44 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> LoadSessionResponse | None:
raise NotImplementedError()
load_dotenv_values()
os.chdir(cwd)
config = self._load_config()
session_dir = SessionLoader.find_session_by_id(
session_id, config.session_logging
)
if session_dir is None:
raise RequestError.invalid_params({
"session_id": f"Session not found: {session_id}"
})
try:
loaded_messages, _ = SessionLoader.load_session(session_dir)
except ValueError as e:
raise RequestError.invalid_params({
"session_id": f"Failed to load session: {e}"
}) from e
agent_loop = AgentLoop(
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
)
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
]
agent_loop.messages.extend(non_system_messages)
session = await self._create_acp_session(session_id, agent_loop)
await self._replay_conversation_history(session_id, non_system_messages)
return LoadSessionResponse(
models=self._build_session_model_state(agent_loop),
modes=self._build_session_mode_state(session),
)
@override
async def set_session_mode(
@@ -348,7 +510,27 @@ class VibeAcpAgentLoop(AcpAgent):
async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
) -> ListSessionsResponse:
raise NotImplementedError()
try:
config = VibeConfig.load()
session_logging_config = config.session_logging
except MissingAPIKeyError:
session_logging_config = SessionLoggingConfig()
session_data = SessionLoader.list_sessions(session_logging_config, cwd=cwd)
sessions = [
SessionInfo(
session_id=s["session_id"],
cwd=s["cwd"],
title=s.get("title"),
updated_at=s.get("end_time"),
)
for s in sorted(
session_data, key=lambda s: s.get("end_time") or "", reverse=True
)
]
return ListSessionsResponse(sessions=sessions)
@override
async def prompt(
@@ -363,6 +545,9 @@ class VibeAcpAgentLoop(AcpAgent):
text_prompt = self._build_text_prompt(prompt)
if text_prompt.strip().lower().startswith("/proxy-setup"):
return await self._handle_proxy_setup_command(session_id, text_prompt)
temp_user_message_id: str | None = kwargs.get("messageId")
async def agent_loop_task() -> None:

View File

@@ -4,16 +4,20 @@ from enum import StrEnum
from typing import TYPE_CHECKING, Literal, cast
from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
ContentToolCallContent,
PermissionOption,
SessionMode,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
UserMessageChunk,
)
from vibe.core.agents.models import AgentProfile, AgentType
from vibe.core.types import CompactEndEvent, CompactStartEvent
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display
if TYPE_CHECKING:
@@ -111,3 +115,99 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
)
],
)
def get_proxy_help_text() -> str:
lines = [
"## Proxy Configuration",
"",
"Configure proxy and SSL settings for HTTP requests.",
"",
"### Usage:",
"- `/proxy-setup` - Show this help and current settings",
"- `/proxy-setup KEY value` - Set an environment variable",
"- `/proxy-setup KEY` - Remove an environment variable",
"",
"### Supported Variables:",
]
for key, description in SUPPORTED_PROXY_VARS.items():
lines.append(f"- `{key}`: {description}")
lines.extend(["", "### Current Settings:"])
current = get_current_proxy_settings()
any_set = False
for key, value in current.items():
if value:
lines.append(f"- `{key}={value}`")
any_set = True
if not any_set:
lines.append("- (none configured)")
return "\n".join(lines)
def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk:
content = msg.content if isinstance(msg.content, str) else ""
return UserMessageChunk(
session_update="user_message_chunk",
content=TextContentBlock(type="text", text=content),
field_meta={"messageId": msg.message_id} if msg.message_id else {},
)
def create_assistant_message_replay(msg: LLMMessage) -> AgentMessageChunk | None:
content = msg.content if isinstance(msg.content, str) else ""
if not content:
return None
return AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=content),
field_meta={"messageId": msg.message_id} if msg.message_id else {},
)
def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None:
if not isinstance(msg.reasoning_content, str) or not msg.reasoning_content:
return None
return AgentThoughtChunk(
session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=msg.reasoning_content),
field_meta={"messageId": msg.message_id} if msg.message_id else {},
)
def create_tool_call_replay(
tool_call_id: str, tool_name: str, arguments: str | None
) -> ToolCallStart:
return ToolCallStart(
session_update="tool_call",
title=tool_name,
tool_call_id=tool_call_id,
kind="other",
raw_input=arguments,
)
def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None:
if not msg.tool_call_id:
return None
content = msg.content if isinstance(msg.content, str) else ""
return ToolCallProgress(
session_update="tool_call_update",
tool_call_id=msg.tool_call_id,
status="completed",
raw_output=content,
content=[
ContentToolCallContent(
type="content", content=TextContentBlock(type="text", text=content)
)
]
if content
else None,
)

View File

@@ -26,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str:
return dense_text
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
selected_texts = []
for widget in app.query("*"):
@@ -48,7 +48,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
selected_texts.append(selected_text)
if not selected_texts:
return
return None
combined_text = "\n".join(selected_texts)
@@ -61,7 +61,9 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
timeout=2,
markup=False,
)
return combined_text
except Exception:
app.notify(
"Failed to copy - clipboard not available", severity="warning", timeout=3
)
return None

View File

@@ -67,6 +67,11 @@ class CommandRegistry:
description="Teleport session to Vibe Nuage",
handler="_teleport_command",
),
"proxy-setup": Command(
aliases=frozenset(["/proxy-setup"]),
description="Configure proxy and SSL certificate settings",
handler="_show_proxy_setup",
),
}
for command in excluded_commands:
@@ -78,9 +83,12 @@ class CommandRegistry:
self._alias_map[alias] = cmd_name
def find_command(self, user_input: str) -> Command | None:
cmd_name = self._alias_map.get(user_input.lower().strip())
cmd_name = self.get_command_name(user_input)
return self.commands.get(cmd_name) if cmd_name else None
def get_command_name(self, user_input: str) -> str | None:
return self._alias_map.get(user_input.lower().strip())
def get_help_text(self) -> str:
lines: list[str] = [
"### Keyboard Shortcuts",

View File

@@ -51,6 +51,7 @@ from vibe.cli.textual_ui.widgets.messages import (
)
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
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
@@ -127,6 +128,7 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
Input = auto()
ProxySetup = auto()
Question = auto()
@@ -308,6 +310,7 @@ class VibeApp(App): # noqa: PLR0904
await self._resume_history_from_messages()
await self._check_and_show_whats_new()
self._schedule_update_notification()
self.agent_loop.emit_new_session_telemetry("cli")
if self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
@@ -422,6 +425,24 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_to_input_app()
async def on_proxy_setup_app_proxy_setup_closed(
self, message: ProxySetupApp.ProxySetupClosed
) -> None:
if message.error:
await self._mount_and_scroll(
ErrorMessage(f"Failed to save proxy settings: {message.error}")
)
elif message.saved:
await self._mount_and_scroll(
UserCommandMessage(
"Proxy settings saved. Restart the CLI for changes to take effect."
)
)
else:
await self._mount_and_scroll(UserCommandMessage("Proxy setup cancelled."))
await self._switch_to_input_app()
async def on_compact_message_completed(
self, message: CompactMessage.Completed
) -> None:
@@ -449,6 +470,10 @@ class VibeApp(App): # noqa: PLR0904
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):
self.agent_loop.telemetry_client.send_slash_command_used(
cmd_name, "builtin"
)
await self._mount_and_scroll(UserMessage(user_input))
handler = getattr(self, command.handler)
if asyncio.iscoroutinefunction(handler):
@@ -479,6 +504,8 @@ class VibeApp(App): # noqa: PLR0904
if not skill_info:
return False
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
except OSError as e:
@@ -823,6 +850,11 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_config_app()
async def _show_proxy_setup(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
await self._switch_to_proxy_setup_app()
async def _reload_config(self) -> None:
try:
self._windowing.reset()
@@ -996,6 +1028,13 @@ 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_proxy_setup_app(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
await self._mount_and_scroll(UserCommandMessage("Proxy setup opened..."))
await self._switch_from_input(ProxySetupApp())
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
) -> None:
@@ -1020,6 +1059,7 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
self.call_after_refresh(self._chat_input_container.focus_input)
self.call_after_refresh(self._scroll_to_bottom)
def _focus_current_bottom_app(self) -> None:
try:
@@ -1028,6 +1068,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ChatInputContainer).focus_input()
case BottomApp.Config:
self.query_one(ConfigApp).focus()
case BottomApp.ProxySetup:
self.query_one(ProxySetupApp).focus()
case BottomApp.Approval:
self.query_one(ApprovalApp).focus()
case BottomApp.Question:
@@ -1037,34 +1079,66 @@ class VibeApp(App): # noqa: PLR0904
except Exception:
pass
def _handle_config_app_escape(self) -> None:
try:
config_app = self.query_one(ConfigApp)
config_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)
approval_app.action_reject()
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
self._last_escape_time = None
def _handle_question_app_escape(self) -> None:
try:
question_app = self.query_one(QuestionApp)
question_app.action_cancel()
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_input_app_escape(self) -> None:
try:
input_widget = self.query_one(ChatInputContainer)
input_widget.value = ""
except Exception:
pass
self._last_escape_time = None
def _handle_agent_running_escape(self) -> None:
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
def action_interrupt(self) -> None:
current_time = time.monotonic()
if self._current_bottom_app == BottomApp.Config:
self._handle_config_app_escape()
return
if self._current_bottom_app == BottomApp.ProxySetup:
try:
config_app = self.query_one(ConfigApp)
config_app.action_close()
proxy_setup_app = self.query_one(ProxySetupApp)
proxy_setup_app.action_close()
except Exception:
pass
self._last_escape_time = None
return
if self._current_bottom_app == BottomApp.Approval:
try:
approval_app = self.query_one(ApprovalApp)
approval_app.action_reject()
except Exception:
pass
self._last_escape_time = None
self._handle_approval_app_escape()
return
if self._current_bottom_app == BottomApp.Question:
try:
question_app = self.query_one(QuestionApp)
question_app.action_cancel()
except Exception:
pass
self._last_escape_time = None
self._handle_question_app_escape()
return
if (
@@ -1072,17 +1146,11 @@ class VibeApp(App): # noqa: PLR0904
and self._last_escape_time is not None
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
):
try:
input_widget = self.query_one(ChatInputContainer)
if input_widget.value:
input_widget.value = ""
self._last_escape_time = None
return
except Exception:
pass
self._handle_input_app_escape()
return
if self._agent_running:
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
self._handle_agent_running_escape()
self._last_escape_time = current_time
self._scroll_to_bottom()
@@ -1430,11 +1498,15 @@ class VibeApp(App): # noqa: PLR0904
)
def action_copy_selection(self) -> None:
copy_selection_to_clipboard(self, show_toast=False)
copied_text = copy_selection_to_clipboard(self, show_toast=False)
if copied_text is not None:
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
def on_mouse_up(self, event: MouseUp) -> None:
if self.config.autocopy_to_clipboard:
copy_selection_to_clipboard(self, show_toast=True)
copied_text = copy_selection_to_clipboard(self, show_toast=True)
if copied_text is not None:
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
def on_app_blur(self, event: AppBlur) -> None:
if self._chat_input_container and self._chat_input_container.input_widget:

View File

@@ -703,6 +703,44 @@ StatusMessage {
color: ansi_bright_black;
}
#proxysetup-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#proxysetup-content {
width: 100%;
height: auto;
}
.proxy-label {
height: auto;
color: ansi_blue;
text-style: bold;
}
.proxy-description {
height: auto;
color: ansi_bright_black;
}
.proxy-label-line {
height: auto;
}
.proxy-input {
width: 100%;
height: auto;
border: none;
border-left: wide ansi_bright_black;
margin-top: 1;
padding: 0 0 0 1;
}
#approval-app {
width: 100%;
height: auto;

View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from typing import ClassVar
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.widgets import Input, Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.proxy_setup import (
SUPPORTED_PROXY_VARS,
get_current_proxy_settings,
set_proxy_var,
unset_proxy_var,
)
class ProxySetupApp(Container):
can_focus = True
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "focus_previous", "Up", show=False),
Binding("down", "focus_next", "Down", show=False),
]
class ProxySetupClosed(Message):
def __init__(self, saved: bool, error: str | None = None) -> None:
super().__init__()
self.saved = saved
self.error = error
def __init__(self) -> None:
super().__init__(id="proxysetup-app")
self.inputs: dict[str, Input] = {}
self.initial_values: dict[str, str | None] = {}
def compose(self) -> ComposeResult:
self.initial_values = get_current_proxy_settings()
with Vertical(id="proxysetup-content"):
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
yield NoMarkupStatic("")
for key, description in SUPPORTED_PROXY_VARS.items():
yield Static(
f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]",
classes="proxy-label-line",
)
initial_value = self.initial_values.get(key) or ""
input_widget = Input(
value=initial_value,
placeholder="NOT SET",
id=f"proxy-input-{key}",
classes="proxy-input",
)
self.inputs[key] = input_widget
yield input_widget
yield NoMarkupStatic("")
yield NoMarkupStatic(
"↑↓ navigate Enter save & exit ESC cancel", classes="settings-help"
)
def focus(self, scroll_visible: bool = True) -> ProxySetupApp:
"""Override focus to focus the first input widget."""
if self.inputs:
first_input = list(self.inputs.values())[0]
first_input.focus(scroll_visible=scroll_visible)
else:
super().focus(scroll_visible=scroll_visible)
return self
def action_focus_next(self) -> None:
inputs = list(self.inputs.values())
focused = self.screen.focused
if focused is not None and isinstance(focused, Input) and focused in inputs:
idx = inputs.index(focused)
next_idx = (idx + 1) % len(inputs)
inputs[next_idx].focus()
def action_focus_previous(self) -> None:
inputs = list(self.inputs.values())
focused = self.screen.focused
if focused is not None and isinstance(focused, Input) and focused in inputs:
idx = inputs.index(focused)
prev_idx = (idx - 1) % len(inputs)
inputs[prev_idx].focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self._save_and_close()
def on_blur(self, _event: events.Blur) -> None:
self.call_after_refresh(self._refocus_if_needed)
def on_input_blurred(self, _event: Input.Blurred) -> None:
self.call_after_refresh(self._refocus_if_needed)
def _refocus_if_needed(self) -> None:
if self.has_focus or any(inp.has_focus for inp in self.inputs.values()):
return
self.focus()
def _save_and_close(self) -> None:
try:
for key, input_widget in self.inputs.items():
new_value = input_widget.value.strip()
old_value = self.initial_values.get(key) or ""
if new_value != old_value:
if new_value:
set_proxy_var(key, new_value)
else:
unset_proxy_var(key)
except Exception as e:
self.post_message(self.ProxySetupClosed(saved=False, error=str(e)))
return
self.post_message(self.ProxySetupClosed(saved=True))
def action_close(self) -> None:
self.post_message(self.ProxySetupClosed(saved=False))

View File

@@ -4,19 +4,26 @@ import asyncio
from collections.abc import AsyncGenerator, Callable
from enum import StrEnum, auto
from http import HTTPStatus
import json
from pathlib import Path
from threading import Thread
import time
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from uuid import uuid4
from pydantic import BaseModel
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.config import Backend, ProviderConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
from vibe.core.llm.format import (
APIToolFormatHandler,
FailedToolCall,
ResolvedMessage,
ResolvedToolCall,
)
from vibe.core.llm.types import BackendLike
from vibe.core.middleware import (
AutoCompactMiddleware,
@@ -35,6 +42,7 @@ from vibe.core.session.session_logger import SessionLogger
from vibe.core.session.session_migration import migrate_sessions_entrypoint
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@@ -44,6 +52,7 @@ from vibe.core.tools.base import (
ToolPermissionError,
)
from vibe.core.tools.manager import ToolManager
from vibe.core.trusted_folders import has_agents_md_file
from vibe.core.types import (
AgentStats,
ApprovalCallback,
@@ -96,6 +105,7 @@ class ToolExecutionResponse(StrEnum):
class ToolDecision(BaseModel):
verdict: ToolExecutionResponse
approval_type: ToolPermission
feedback: str | None = None
@@ -171,7 +181,9 @@ class AgentLoop:
self.user_input_callback: UserInputCallback | None = None
self.session_id = str(uuid4())
self._current_user_message_id: str | None = None
self.telemetry_client = TelemetryClient(config_getter=lambda: self.config)
self.session_logger = SessionLogger(config.session_logging, self.session_id)
self._teleport_service: TeleportService | None = None
@@ -209,6 +221,21 @@ class AgentLoop:
self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name)
def emit_new_session_telemetry(
self, entrypoint: Literal["cli", "acp", "programmatic"]
) -> None:
has_agents_md = has_agents_md_file(Path.cwd())
nb_skills = len(self.skill_manager.available_skills)
nb_mcp_servers = len(self.config.mcp_servers)
nb_models = len(self.config.models)
self.telemetry_client.send_new_session(
has_agents_md=has_agents_md,
nb_skills=nb_skills,
nb_mcp_servers=nb_mcp_servers,
nb_models=nb_models,
entrypoint=entrypoint,
)
def _select_backend(self) -> BackendLike:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@@ -331,12 +358,11 @@ class AgentLoop:
)
case MiddlewareAction.INJECT_MESSAGE:
if result.message and len(self.messages) > 0:
last_msg = self.messages[-1]
if last_msg.content:
last_msg.content += f"\n\n{result.message}"
else:
last_msg.content = result.message
if result.message:
injected_message = LLMMessage(
role=Role.user, content=result.message
)
self.messages.append(injected_message)
case MiddlewareAction.COMPACT:
old_tokens = result.metadata.get(
@@ -352,6 +378,7 @@ class AgentLoop:
current_context_tokens=old_tokens,
threshold=threshold,
)
self.telemetry_client.send_auto_compact_triggered()
summary = await self.compact()
@@ -370,10 +397,25 @@ class AgentLoop:
messages=self.messages, stats=self.stats, config=self.config
)
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
headers: dict[str, str] = {
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
}
if (
provider.backend == Backend.MISTRAL
and self._current_user_message_id is not None
):
headers["metadata"] = json.dumps({
"message_id": self._current_user_message_id
})
return headers
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
user_message = LLMMessage(role=Role.user, content=user_msg)
self.messages.append(user_message)
self.stats.steps += 1
self._current_user_message_id = user_message.message_id
if user_message.message_id is None:
raise AgentLoopError("User message must have a message_id")
@@ -406,15 +448,6 @@ class AgentLoop:
if user_cancelled:
return
after_result = await self.middleware_pipeline.run_after_turn(
self._get_context()
)
async for event in self._handle_middleware_result(after_result):
yield event
if after_result.action == MiddlewareAction.STOP:
return
finally:
await self._flush_new_messages()
@@ -497,19 +530,17 @@ class AgentLoop:
message_id=llm_result.message.message_id,
)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
for failed in resolved.failed_calls:
async def _emit_failed_tool_events(
self, failed_calls: list[FailedToolCall]
) -> AsyncGenerator[ToolResultEvent]:
for failed in failed_calls:
error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=failed.tool_name,
tool_class=None,
error=error_msg,
tool_call_id=failed.call_id,
)
self.stats.tool_calls_failed += 1
self.messages.append(
self.format_handler.create_failed_tool_response_message(
@@ -517,6 +548,113 @@ class AgentLoop:
)
)
async def _process_one_tool_call(
self, tool_call: ResolvedToolCall
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
try:
tool_instance = self.tool_manager.get(tool_call.tool_name)
except Exception as exc:
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, error_msg, "failure")
return
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
return
self.stats.tool_calls_agreed += 1
try:
start_time = time.perf_counter()
result_model = None
async for item in tool_instance.invoke(
ctx=InvokeContext(
tool_call_id=tool_call.call_id,
approval_callback=self.approval_callback,
agent_manager=self.agent_manager,
user_input_callback=self.user_input_callback,
),
**tool_call.args_dict,
):
if isinstance(item, ToolStreamEvent):
yield item
else:
result_model = item
duration = time.perf_counter() - start_time
if result_model is None:
raise ToolError("Tool did not yield a result")
result_dict = result_model.model_dump()
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
self._handle_tool_response(
tool_call, text, "success", decision, result_dict
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
duration=duration,
tool_call_id=tool_call.call_id,
)
self.stats.tool_calls_succeeded += 1
except asyncio.CancelledError:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, cancel, "failure", decision)
raise
except (ToolError, ToolPermissionError) as exc:
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
self.stats.tool_calls_agreed -= 1
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
self._handle_tool_response(tool_call, error_msg, "failure", decision)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
async for event in self._emit_failed_tool_events(resolved.failed_calls):
yield event
for tool_call in resolved.tool_calls:
yield ToolCallEvent(
tool_name=tool_call.tool_name,
@@ -524,120 +662,31 @@ class AgentLoop:
args=tool_call.validated_args,
tool_call_id=tool_call.call_id,
)
async for event in self._process_one_tool_call(tool_call):
yield event
try:
tool_instance = self.tool_manager.get(tool_call.tool_name)
except Exception as exc:
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, error_msg)
continue
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, skip_reason)
continue
self.stats.tool_calls_agreed += 1
try:
start_time = time.perf_counter()
result_model = None
async for item in tool_instance.invoke(
ctx=InvokeContext(
tool_call_id=tool_call.call_id,
approval_callback=self.approval_callback,
agent_manager=self.agent_manager,
user_input_callback=self.user_input_callback,
),
**tool_call.args_dict,
):
if isinstance(item, ToolStreamEvent):
yield item
else:
result_model = item
duration = time.perf_counter() - start_time
if result_model is None:
raise ToolError("Tool did not yield a result")
text = "\n".join(
f"{k}: {v}" for k, v in result_model.model_dump().items()
)
self._append_tool_response(tool_call, text)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
duration=duration,
tool_call_id=tool_call.call_id,
)
self.stats.tool_calls_succeeded += 1
except asyncio.CancelledError:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, cancel)
raise
except (ToolError, ToolPermissionError) as exc:
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
self.stats.tool_calls_agreed -= 1
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
self._append_tool_response(tool_call, error_msg)
continue
def _append_tool_response(self, tool_call: ResolvedToolCall, text: str) -> None:
def _handle_tool_response(
self,
tool_call: ResolvedToolCall,
text: str,
status: Literal["success", "failure", "skipped"],
decision: ToolDecision | None = None,
result: dict[str, Any] | None = None,
) -> None:
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(tool_call, text)
)
)
self.telemetry_client.send_tool_call_finished(
tool_call=tool_call,
agent_profile_name=self.agent_profile.name,
status=status,
decision=decision,
result=result,
)
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@@ -653,10 +702,7 @@ class AgentLoop:
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
)
end_time = time.perf_counter()
@@ -699,10 +745,7 @@ class AgentLoop:
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
):
processed_message = self.format_handler.process_api_response_message(
@@ -744,16 +787,23 @@ class AgentLoop:
self, tool: BaseTool, args: BaseModel, tool_call_id: str
) -> ToolDecision:
if self.auto_approve:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
allowlist_denylist_result = tool.check_allowlist_denylist(args)
if allowlist_denylist_result == ToolPermission.ALWAYS:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
elif allowlist_denylist_result == ToolPermission.NEVER:
denylist_patterns = tool.config.denylist
denylist_str = ", ".join(repr(pattern) for pattern in denylist_patterns)
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=f"Tool '{tool.get_name()}' blocked by denylist: [{denylist_str}]",
)
@@ -761,10 +811,14 @@ class AgentLoop:
perm = self.tool_manager.get_tool_config(tool_name).permission
if perm is ToolPermission.ALWAYS:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
if perm is ToolPermission.NEVER:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=f"Tool '{tool_name}' is permanently disabled",
)
@@ -776,6 +830,7 @@ class AgentLoop:
if not self.approval_callback:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.ASK,
feedback="Tool execution not permitted.",
)
if asyncio.iscoroutinefunction(self.approval_callback):
@@ -788,11 +843,15 @@ class AgentLoop:
match response:
case ApprovalResponse.YES:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
case ApprovalResponse.NO:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP, feedback=feedback
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
def _clean_message_history(self) -> None:
@@ -890,7 +949,6 @@ class AgentLoop:
self._reset_session()
async def compact(self) -> str:
"""Compact the conversation history."""
try:
self._clean_message_history()
await self.session_logger.save_interaction(
@@ -915,6 +973,7 @@ class AgentLoop:
system_message = self.messages[0]
summary_message = LLMMessage(role=Role.user, content=summary_content)
self.messages = [system_message, summary_message]
self._last_observed_message_index = 1
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@@ -955,13 +1014,14 @@ class AgentLoop:
if agent_name == self.agent_profile.name:
return
self.agent_manager.switch_profile(agent_name)
await self.reload_with_initial_messages()
await self.reload_with_initial_messages(reset_middleware=False)
async def reload_with_initial_messages(
self,
base_config: VibeConfig | None = None,
max_turns: int | None = None,
max_price: float | None = None,
reset_middleware: bool = True,
) -> None:
# Force an immediate yield to allow the UI to update before heavy sync work.
# When there are no messages, save_interaction returns early without any await,
@@ -1011,19 +1071,5 @@ class AgentLoop:
except ValueError:
pass
self._last_observed_message_index = 0
self._setup_middleware()
if self.message_observer:
for msg in self.messages:
self.message_observer(msg)
self._last_observed_message_index = len(self.messages)
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
if reset_middleware:
self._setup_middleware()

View File

@@ -110,7 +110,7 @@ EXPLORE = AgentProfile(
description="Read-only subagent for codebase exploration",
safety=AgentSafety.SAFE,
agent_type=AgentType.SUBAGENT,
overrides={"enabled_tools": ["grep", "read_file"]},
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
)
BUILTIN_AGENTS: dict[str, AgentProfile] = {

View File

@@ -148,6 +148,8 @@ class ProviderConfig(BaseModel):
api_style: str = "openai"
backend: Backend = Backend.GENERIC
reasoning_field_name: str = "reasoning_content"
project_id: str = ""
region: str = ""
class _MCPBase(BaseModel):
@@ -251,6 +253,7 @@ class ModelConfig(BaseModel):
temperature: float = 0.2
input_price: float = 0.0 # Price per million input tokens
output_price: float = 0.0 # Price per million output tokens
thinking: Literal["off", "low", "medium", "high"] = "off"
@model_validator(mode="before")
@classmethod
@@ -312,6 +315,7 @@ class VibeConfig(BaseSettings):
auto_compact_threshold: int = 200_000
context_warnings: bool = False
auto_approve: bool = False
enable_telemetry: bool = True
system_prompt_id: str = "cli"
include_commit_signature: bool = True
include_model_info: bool = True

View File

@@ -0,0 +1,630 @@
from __future__ import annotations
import json
import re
from typing import Any, ClassVar
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.types import (
AvailableTool,
FunctionCall,
LLMChunk,
LLMMessage,
LLMUsage,
Role,
StrToolChoice,
ToolCall,
)
class AnthropicMapper:
"""Shared mapper for converting messages to/from Anthropic API format."""
def prepare_messages(
self, messages: list[LLMMessage]
) -> tuple[str | None, list[dict[str, Any]]]:
system_prompt: str | None = None
converted: list[dict[str, Any]] = []
for msg in messages:
match msg.role:
case Role.system:
system_prompt = msg.content or ""
case Role.user:
user_content: list[dict[str, Any]] = []
if msg.content:
user_content.append({"type": "text", "text": msg.content})
converted.append({"role": "user", "content": user_content or ""})
case Role.assistant:
converted.append(self._convert_assistant_message(msg))
case Role.tool:
self._append_tool_result(converted, msg)
return system_prompt, converted
def _sanitize_tool_call_id(self, tool_id: str | None) -> str:
return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id or "")
def _convert_assistant_message(self, msg: LLMMessage) -> dict[str, Any]:
content: list[dict[str, Any]] = []
if msg.reasoning_content:
block: dict[str, Any] = {
"type": "thinking",
"thinking": msg.reasoning_content,
}
if msg.reasoning_signature:
block["signature"] = msg.reasoning_signature
content.append(block)
if msg.content:
content.append({"type": "text", "text": msg.content})
if msg.tool_calls:
for tc in msg.tool_calls:
content.append(self._convert_tool_call(tc))
return {"role": "assistant", "content": content if content else ""}
def _convert_tool_call(self, tc: ToolCall) -> dict[str, Any]:
try:
tool_input = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
tool_input = {}
return {
"type": "tool_use",
"id": self._sanitize_tool_call_id(tc.id),
"name": tc.function.name,
"input": tool_input,
}
def _append_tool_result(
self, converted: list[dict[str, Any]], msg: LLMMessage
) -> None:
tool_result = {
"type": "tool_result",
"tool_use_id": self._sanitize_tool_call_id(msg.tool_call_id),
"content": msg.content or "",
}
if not converted or converted[-1]["role"] != "user":
converted.append({"role": "user", "content": [tool_result]})
return
existing_content = converted[-1]["content"]
if isinstance(existing_content, str):
converted[-1]["content"] = [
{"type": "text", "text": existing_content},
tool_result,
]
else:
converted[-1]["content"].append(tool_result)
def prepare_tools(
self, tools: list[AvailableTool] | None
) -> list[dict[str, Any]] | None:
if not tools:
return None
return [
{
"name": tool.function.name,
"description": tool.function.description,
"input_schema": tool.function.parameters,
}
for tool in tools
]
def prepare_tool_choice(
self, tool_choice: StrToolChoice | AvailableTool | None
) -> dict[str, Any] | None:
if tool_choice is None:
return None
if isinstance(tool_choice, str):
match tool_choice:
case "none":
return {"type": "none"}
case "auto":
return {"type": "auto"}
case "any" | "required":
return {"type": "any"}
case _:
return None
return {"type": "tool", "name": tool_choice.function.name}
def parse_response(self, data: dict[str, Any]) -> LLMChunk:
content_blocks = data.get("content", [])
text_parts: list[str] = []
thinking_parts: list[str] = []
signature_parts: list[str] = []
tool_calls: list[ToolCall] = []
for idx, block in enumerate(content_blocks):
block_type = block.get("type")
if block_type == "text":
text_parts.append(block.get("text", ""))
elif block_type == "thinking":
thinking_parts.append(block.get("thinking", ""))
if "signature" in block:
signature_parts.append(block["signature"])
elif block_type == "tool_use":
tool_calls.append(
ToolCall(
id=block.get("id"),
index=idx,
function=FunctionCall(
name=block.get("name"),
arguments=json.dumps(block.get("input", {})),
),
)
)
usage_data = data.get("usage", {})
# Total input tokens = input_tokens + cache_creation + cache_read
total_input_tokens = (
usage_data.get("input_tokens", 0)
+ usage_data.get("cache_creation_input_tokens", 0)
+ usage_data.get("cache_read_input_tokens", 0)
)
usage = LLMUsage(
prompt_tokens=total_input_tokens,
completion_tokens=usage_data.get("output_tokens", 0),
)
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
content="".join(text_parts) or None,
reasoning_content="".join(thinking_parts) or None,
reasoning_signature="".join(signature_parts) or None,
tool_calls=tool_calls if tool_calls else None,
),
usage=usage,
)
def parse_streaming_event(
self, event_type: str, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
handler = {
"content_block_start": self._handle_block_start,
"content_block_delta": self._handle_block_delta,
"message_delta": self._handle_message_delta,
"message_start": self._handle_message_start,
}.get(event_type)
if handler is None:
return None, current_index
return handler(data, current_index)
def _handle_block_start(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
block = data.get("content_block", {})
idx = data.get("index", current_index)
match block.get("type"):
case "tool_use":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
id=block.get("id"),
index=idx,
function=FunctionCall(
name=block.get("name"), arguments=""
),
)
],
)
)
return chunk, idx
case "thinking":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=block.get("thinking", "")
)
)
return chunk, idx
case _:
return None, idx
def _handle_block_delta(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
delta = data.get("delta", {})
idx = data.get("index", current_index)
match delta.get("type"):
case "text_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, content=delta.get("text", "")
)
)
case "thinking_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=delta.get("thinking", "")
)
)
case "signature_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
reasoning_signature=delta.get("signature", ""),
)
)
case "input_json_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
index=idx,
function=FunctionCall(
arguments=delta.get("partial_json", "")
),
)
],
)
)
case _:
chunk = None
return chunk, idx
def _handle_message_delta(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
usage_data = data.get("usage", {})
if not usage_data:
return None, current_index
chunk = LLMChunk(
message=LLMMessage(role=Role.assistant),
usage=LLMUsage(
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
),
)
return chunk, current_index
def _handle_message_start(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
message = data.get("message", {})
usage_data = message.get("usage", {})
if not usage_data:
return None, current_index
# Total input tokens = input_tokens + cache_creation + cache_read
total_input_tokens = (
usage_data.get("input_tokens", 0)
+ usage_data.get("cache_creation_input_tokens", 0)
+ usage_data.get("cache_read_input_tokens", 0)
)
chunk = LLMChunk(
message=LLMMessage(role=Role.assistant),
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
)
return chunk, current_index
STREAMING_EVENT_TYPES = {
"message_start",
"message_delta",
"message_stop",
"content_block_start",
"content_block_delta",
"content_block_stop",
"ping",
"error",
}
class AnthropicAdapter(APIAdapter):
endpoint: ClassVar[str] = "/v1/messages"
API_VERSION = "2023-06-01"
BETA_FEATURES = (
"interleaved-thinking-2025-05-14,"
"fine-grained-tool-streaming-2025-05-14,"
"prompt-caching-2024-07-31"
)
THINKING_BUDGETS: ClassVar[dict[str, int]] = {
"low": 1024,
"medium": 10_000,
"high": 32_000,
}
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
DEFAULT_MAX_TOKENS = 8192
def __init__(self) -> None:
self._mapper = AnthropicMapper()
self._current_index: int = 0
@staticmethod
def _has_thinking_content(messages: list[dict[str, Any]]) -> bool:
for msg in messages:
if msg.get("role") != "assistant":
continue
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if block.get("type") == "thinking":
return True
return False
@staticmethod
def _build_system_blocks(system_prompt: str | None) -> list[dict[str, Any]]:
blocks: list[dict[str, Any]] = []
if system_prompt:
blocks.append({
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral"},
})
return blocks
@staticmethod
def _add_cache_control_to_last_user_message(messages: list[dict[str, Any]]) -> None:
if not messages:
return
last_message = messages[-1]
if last_message.get("role") != "user":
return
content = last_message.get("content")
if not isinstance(content, list) or not content:
return
last_block = content[-1]
if last_block.get("type") in {"text", "image", "tool_result"}:
last_block["cache_control"] = {"type": "ephemeral"}
@staticmethod
def _is_adaptive_model(model_name: str) -> bool:
return "opus-4-6" in model_name
def _apply_thinking_config(
self,
payload: dict[str, Any],
*,
model_name: str,
messages: list[dict[str, Any]],
temperature: float,
max_tokens: int | None,
thinking: str,
) -> None:
has_thinking = self._has_thinking_content(messages)
thinking_level = thinking
if thinking_level == "off" and not has_thinking:
payload["temperature"] = temperature
if max_tokens is not None:
payload["max_tokens"] = max_tokens
else:
payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
return
# Resolve effective level: use config, or fallback to "medium" when
# forced by thinking content in history
effective_level = thinking_level if thinking_level != "off" else "medium"
if self._is_adaptive_model(model_name):
payload["thinking"] = {"type": "adaptive"}
payload["output_config"] = {"effort": effective_level}
default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS
else:
budget = self.THINKING_BUDGETS[effective_level]
payload["thinking"] = {"type": "enabled", "budget_tokens": budget}
default_max = budget + self.DEFAULT_MAX_TOKENS
payload["temperature"] = 1
payload["max_tokens"] = max_tokens if max_tokens is not None else default_max
def _build_payload(
self,
*,
model_name: str,
system_prompt: str | None,
messages: list[dict[str, Any]],
temperature: float,
tools: list[dict[str, Any]] | None,
max_tokens: int | None,
tool_choice: dict[str, Any] | None,
stream: bool,
thinking: str,
) -> dict[str, Any]:
payload: dict[str, Any] = {"model": model_name, "messages": messages}
self._apply_thinking_config(
payload,
model_name=model_name,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
)
if system_blocks := self._build_system_blocks(system_prompt):
payload["system"] = system_blocks
if tools:
payload["tools"] = tools
if tool_choice:
payload["tool_choice"] = tool_choice
if stream:
payload["stream"] = True
self._add_cache_control_to_last_user_message(messages)
return payload
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest:
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
converted_tools = self._mapper.prepare_tools(tools)
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
payload = self._build_payload(
model_name=model_name,
system_prompt=system_prompt,
messages=converted_messages,
temperature=temperature,
tools=converted_tools,
max_tokens=max_tokens,
tool_choice=converted_tool_choice,
stream=enable_streaming,
thinking=thinking,
)
headers = {
"Content-Type": "application/json",
"anthropic-version": self.API_VERSION,
"anthropic-beta": self.BETA_FEATURES,
}
if api_key:
headers["x-api-key"] = api_key
body = json.dumps(payload).encode("utf-8")
return PreparedRequest(self.endpoint, headers, body)
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig | None = None
) -> LLMChunk:
event_type = data.get("type")
if event_type in STREAMING_EVENT_TYPES:
return self._parse_streaming_event(data)
return self._mapper.parse_response(data)
def _parse_streaming_event(self, data: dict[str, Any]) -> LLMChunk:
event_type = data.get("type", "")
empty_chunk = LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
if event_type == "message_start":
self._current_index = 0
return self._parse_message_start(data)
if event_type == "content_block_start":
return self._parse_content_block_start(data) or empty_chunk
if event_type == "content_block_delta":
return self._parse_content_block_delta(data)
if event_type == "content_block_stop":
return self._parse_content_block_stop(data)
if event_type == "message_delta":
return self._parse_message_delta(data)
if event_type == "error":
error = data.get("error", {})
error_type = error.get("type", "unknown_error")
error_message = error.get("message", "Unknown streaming error")
raise RuntimeError(
f"Anthropic stream error ({error_type}): {error_message}"
)
return empty_chunk
def _parse_message_start(self, data: dict[str, Any]) -> LLMChunk:
message = data.get("message", {})
usage_data = message.get("usage", {})
if not usage_data:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
total_input_tokens = (
usage_data.get("input_tokens", 0)
+ usage_data.get("cache_creation_input_tokens", 0)
+ usage_data.get("cache_read_input_tokens", 0)
)
return LLMChunk(
message=LLMMessage(role=Role.assistant, content=None),
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
)
def _parse_content_block_start(self, data: dict[str, Any]) -> LLMChunk | None:
content_block = data.get("content_block", {})
index = data.get("index", 0)
block_type = content_block.get("type")
if block_type == "thinking":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
reasoning_content=content_block.get("thinking", ""),
)
)
if block_type == "redacted_thinking":
return None
if block_type == "tool_use":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
index=index,
id=content_block.get("id"),
function=FunctionCall(
name=content_block.get("name"), arguments=""
),
)
],
)
)
return None
def _parse_content_block_delta(self, data: dict[str, Any]) -> LLMChunk:
delta = data.get("delta", {})
delta_type = delta.get("type", "")
index = data.get("index", 0)
match delta_type:
case "text_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant, content=delta.get("text", "")
)
)
case "thinking_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=delta.get("thinking", "")
)
)
case "signature_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
reasoning_signature=delta.get("signature", ""),
)
)
case "input_json_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
index=index,
function=FunctionCall(
arguments=delta.get("partial_json", "")
),
)
],
)
)
case _:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
def _parse_content_block_stop(self, _data: dict[str, Any]) -> LLMChunk:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
def _parse_message_delta(self, data: dict[str, Any]) -> LLMChunk:
usage_data = data.get("usage", {})
if not usage_data:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
return LLMChunk(
message=LLMMessage(role=Role.assistant, content=None),
usage=LLMUsage(
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
),
)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
if TYPE_CHECKING:
from vibe.core.config import ProviderConfig
class PreparedRequest(NamedTuple):
endpoint: str
headers: dict[str, str]
body: bytes
base_url: str = ""
class APIAdapter(Protocol):
endpoint: ClassVar[str]
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest: ...
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig
) -> LLMChunk: ...

View File

@@ -1,14 +1,18 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator
import json
import os
import types
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol, TypeVar
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
import httpx
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
LLMChunk,
@@ -23,51 +27,6 @@ if TYPE_CHECKING:
from vibe.core.config import ModelConfig, ProviderConfig
class PreparedRequest(NamedTuple):
endpoint: str
headers: dict[str, str]
body: bytes
class APIAdapter(Protocol):
endpoint: ClassVar[str]
def prepare_request(
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
) -> PreparedRequest: ...
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig
) -> LLMChunk: ...
BACKEND_ADAPTERS: dict[str, APIAdapter] = {}
T = TypeVar("T", bound=APIAdapter)
def register_adapter(
adapters: dict[str, APIAdapter], name: str
) -> Callable[[type[T]], type[T]]:
def decorator(cls: type[T]) -> type[T]:
adapters[name] = cls()
return cls
return decorator
@register_adapter(BACKEND_ADAPTERS, "openai")
class OpenAIAdapter(APIAdapter):
endpoint: ClassVar[str] = "/chat/completions"
@@ -119,7 +78,7 @@ class OpenAIAdapter(APIAdapter):
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
return msg_dict
def prepare_request(
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
@@ -131,13 +90,15 @@ class OpenAIAdapter(APIAdapter):
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
field_name = provider.reasoning_field_name
converted_messages = [
self._reasoning_to_api(
msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name
)
for msg in messages
for msg in merged_messages
]
payload = self.build_payload(
@@ -194,6 +155,13 @@ class OpenAIAdapter(APIAdapter):
return LLMChunk(message=message, usage=usage)
ADAPTERS: dict[str, APIAdapter] = {
"openai": OpenAIAdapter(),
"anthropic": AnthropicAdapter(),
"vertex-anthropic": VertexAnthropicAdapter(),
}
class GenericBackend:
def __init__(
self,
@@ -257,9 +225,9 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = BACKEND_ADAPTERS[api_style]
adapter = ADAPTERS[api_style]
endpoint, headers, body = adapter.prepare_request(
req = adapter.prepare_request(
model_name=model.name,
messages=messages,
temperature=temperature,
@@ -269,15 +237,18 @@ class GenericBackend:
enable_streaming=False,
provider=self._provider,
api_key=api_key,
thinking=model.thinking,
)
headers = req.headers
if extra_headers:
headers.update(extra_headers)
url = f"{self._provider.api_base}{endpoint}"
base = req.base_url or self._provider.api_base
url = f"{base}{req.endpoint}"
try:
res_data, _ = await self._make_request(url, body, headers)
res_data, _ = await self._make_request(url, req.body, headers)
return adapter.parse_response(res_data, self._provider)
except httpx.HTTPStatusError as e:
@@ -322,9 +293,9 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = BACKEND_ADAPTERS[api_style]
adapter = ADAPTERS[api_style]
endpoint, headers, body = adapter.prepare_request(
req = adapter.prepare_request(
model_name=model.name,
messages=messages,
temperature=temperature,
@@ -334,15 +305,18 @@ class GenericBackend:
enable_streaming=True,
provider=self._provider,
api_key=api_key,
thinking=model.thinking,
)
headers = req.headers
if extra_headers:
headers.update(extra_headers)
url = f"{self._provider.api_base}{endpoint}"
base = req.base_url or self._provider.api_base
url = f"{base}{req.endpoint}"
try:
async for res_data in self._make_streaming_request(url, body, headers):
async for res_data in self._make_streaming_request(url, req.body, headers):
yield adapter.parse_response(res_data, self._provider)
except httpx.HTTPStatusError as e:
@@ -393,6 +367,8 @@ class GenericBackend:
async with client.stream(
method="POST", url=url, content=data, headers=headers
) as response:
if not response.is_success:
await response.aread()
response.raise_for_status()
async for line in response.aiter_lines():
if line.strip() == "":

View File

@@ -11,6 +11,7 @@ import httpx
import mistralai
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
Content,
@@ -217,9 +218,10 @@ class MistralBackend:
extra_headers: dict[str, str] | None,
) -> LLMChunk:
try:
merged_messages = merge_consecutive_user_messages(messages)
response = await self._get_client().chat.complete_async(
model=model.name,
messages=[self._mapper.prepare_message(msg) for msg in messages],
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
temperature=temperature,
tools=[self._mapper.prepare_tool(tool) for tool in tools]
if tools
@@ -290,9 +292,10 @@ class MistralBackend:
extra_headers: dict[str, str] | None,
) -> AsyncGenerator[LLMChunk, None]:
try:
merged_messages = merge_consecutive_user_messages(messages)
async for chunk in await self._get_client().chat.stream_async(
model=model.name,
messages=[self._mapper.prepare_message(msg) for msg in messages],
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
temperature=temperature,
tools=[self._mapper.prepare_tool(tool) for tool in tools]
if tools

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import json
from typing import Any, ClassVar
import google.auth
from google.auth.transport.requests import Request
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import PreparedRequest
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
def get_vertex_access_token() -> str:
credentials, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
credentials.refresh(Request())
return credentials.token
def build_vertex_base_url(region: str) -> str:
if region == "global":
return "https://aiplatform.googleapis.com"
return f"https://{region}-aiplatform.googleapis.com"
def build_vertex_endpoint(
region: str, project_id: str, model: str, streaming: bool = False
) -> str:
action = "streamRawPredict" if streaming else "rawPredict"
return (
f"/v1/projects/{project_id}/locations/{region}/"
f"publishers/anthropic/models/{model}:{action}"
)
class VertexAnthropicAdapter(AnthropicAdapter):
"""Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter."""
endpoint: ClassVar[str] = ""
# Vertex AI doesn't support beta features
BETA_FEATURES: ClassVar[str] = ""
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest:
project_id = provider.project_id
region = provider.region
if not project_id:
raise ValueError("project_id is required in provider config for Vertex AI")
if not region:
raise ValueError("region is required in provider config for Vertex AI")
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
converted_tools = self._mapper.prepare_tools(tools)
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
# Build vertex-specific payload (no "model" key, uses anthropic_version)
payload: dict[str, Any] = {
"anthropic_version": "vertex-2023-10-16",
"messages": converted_messages,
}
self._apply_thinking_config(
payload,
model_name=model_name,
messages=converted_messages,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
)
if system_blocks := self._build_system_blocks(system_prompt):
payload["system"] = system_blocks
if converted_tools:
payload["tools"] = converted_tools
if converted_tool_choice:
payload["tool_choice"] = converted_tool_choice
if enable_streaming:
payload["stream"] = True
self._add_cache_control_to_last_user_message(converted_messages)
access_token = get_vertex_access_token()
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"anthropic-beta": self.BETA_FEATURES,
}
endpoint = build_vertex_endpoint(
region, project_id, model_name, streaming=enable_streaming
)
base_url = build_vertex_base_url(region)
body = json.dumps(payload).encode("utf-8")
return PreparedRequest(endpoint, headers, body, base_url=base_url)

View File

@@ -80,6 +80,7 @@ class APIToolFormatHandler:
"role": message.role,
"content": message.content,
"reasoning_content": getattr(message, "reasoning_content", None),
"reasoning_signature": getattr(message, "reasoning_signature", None),
}
if message.tool_calls:

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from vibe.core.types import LLMMessage, Role
def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
"""Merge consecutive user messages into a single message.
This handles cases where middleware injects messages resulting in
consecutive user messages before sending to the API.
"""
result: list[LLMMessage] = []
for msg in messages:
if result and result[-1].role == Role.user and msg.role == Role.user:
prev_content = result[-1].content or ""
curr_content = msg.content or ""
merged_content = f"{prev_content}\n\n{curr_content}".strip()
result[-1] = LLMMessage(
role=Role.user, content=merged_content, message_id=result[-1].message_id
)
else:
result.append(msg)
return result

View File

@@ -44,8 +44,6 @@ class MiddlewareResult:
class ConversationMiddleware(Protocol):
async def before_turn(self, context: ConversationContext) -> MiddlewareResult: ...
async def after_turn(self, context: ConversationContext) -> MiddlewareResult: ...
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: ...
@@ -61,9 +59,6 @@ class TurnLimitMiddleware:
)
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
@@ -80,9 +75,6 @@ class PriceLimitMiddleware:
)
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
@@ -102,9 +94,6 @@ class AutoCompactMiddleware:
)
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
@@ -137,9 +126,6 @@ class ContextWarningMiddleware:
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
self.has_warned = False
@@ -148,31 +134,46 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi
1. Answer the user's query comprehensively
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.</{VIBE_WARNING_TAG}>"""
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
class PlanAgentMiddleware:
def __init__(
self,
profile_getter: Callable[[], AgentProfile],
reminder: str = PLAN_AGENT_REMINDER,
exit_message: str = PLAN_AGENT_EXIT,
) -> None:
self._profile_getter = profile_getter
self.reminder = reminder
self.exit_message = exit_message
self._was_plan_agent = False
def _is_plan_agent(self) -> bool:
return self._profile_getter().name == BuiltinAgentName.PLAN
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if not self._is_plan_agent():
return MiddlewareResult()
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
is_plan = self._is_plan_agent()
was_plan = self._was_plan_agent
if was_plan and not is_plan:
self._was_plan_agent = False
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
)
if is_plan and not was_plan:
self._was_plan_agent = True
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
self._was_plan_agent = is_plan
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
self._was_plan_agent = False
class MiddlewarePipeline:
@@ -206,15 +207,3 @@ class MiddlewarePipeline:
)
return MiddlewareResult()
async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult:
for mw in self.middlewares:
result = await mw.after_turn(context)
if result.action == MiddlewareAction.INJECT_MESSAGE:
raise ValueError(
f"INJECT_MESSAGE not allowed in after_turn (from {type(mw).__name__})"
)
if result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
return result
return MiddlewareResult()

View File

@@ -39,12 +39,14 @@ def resolve_local_tools_dir(dir: Path) -> Path | None:
return None
def resolve_local_skills_dir(dir: Path) -> Path | None:
def resolve_local_skills_dirs(dir: Path) -> list[Path]:
if not trusted_folders_manager.is_trusted(dir):
return None
if (candidate := dir / ".vibe" / "skills").is_dir():
return candidate
return None
return []
return [
candidate
for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"]
if candidate.is_dir()
]
def resolve_local_agents_dir(dir: Path) -> Path | None:

View File

@@ -35,6 +35,6 @@ GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibe.log")
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log")
DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins")

View File

@@ -32,20 +32,25 @@ def run_programmatic(
logger.info("USER: %s", prompt)
async def _async_run() -> str | None:
if previous_messages:
non_system_messages = [
msg for msg in previous_messages if not (msg.role == Role.system)
]
agent_loop.messages.extend(non_system_messages)
logger.info(
"Loaded %d messages from previous session", len(non_system_messages)
)
try:
if previous_messages:
non_system_messages = [
msg for msg in previous_messages if not (msg.role == Role.system)
]
agent_loop.messages.extend(non_system_messages)
logger.info(
"Loaded %d messages from previous session", len(non_system_messages)
)
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
raise ConversationLimitException(event.content)
agent_loop.emit_new_session_telemetry("programmatic")
return formatter.finalize()
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
raise ConversationLimitException(event.content)
return formatter.finalize()
finally:
await agent_loop.telemetry_client.aclose()
return asyncio.run(_async_run())

View File

@@ -19,6 +19,7 @@ class Prompt(StrEnum):
class SystemPrompt(Prompt):
CLI = auto()
EXPLORE = auto()
TESTS = auto()

View File

@@ -1,46 +1,111 @@
You are operating as and within Mistral Vibe, a CLI coding-agent built by Mistral AI and powered by default by the Devstral family of models. It wraps Mistral's Devstral models to enable natural language interaction with a local codebase. Use the available tools when helpful.
You are Mistral Vibe, a CLI coding agent built by Mistral AI, powered by the Devstral model family. You interact with a local codebase through tools.
Act as an agentic assistant. For long tasks, break them down and execute step by step.
Phase 1 — Orient
Before ANY action:
Restate the goal in one line.
Determine the task type:
Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
## Tool Usage
Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
Identify constraints: language, framework, test setup, and any user restrictions on scope.
- Always use tools to fulfill user requests when possible.
- Check that all required parameters are provided or can be inferred from context. If values are missing, ask the user.
- When the user provides a specific value (e.g., in quotes), use it EXACTLY as given.
- Do not invent values for optional parameters.
- Analyze descriptive terms in requests as they may indicate required parameter values.
- If tools cannot accomplish the task, explain why and request more information.
Phase 2 — Plan (Change tasks only)
State your plan before writing code:
List files to change and the specific change per file.
Multi-file changes: numbered checklist. Single-file fix: one-line plan.
No time estimates. Concrete actions only.
## Code Modifications
Phase 3 — Execute & Verify (Change tasks only)
Apply changes, then confirm they work:
Edit one logical unit at a time.
After each unit, verify: run tests, or read back the file to confirm the edit landed.
Never claim completion without verification — a passing test, correct read-back, or successful build.
- Always read a file before proposing changes. Never suggest edits to code you haven't seen.
- Keep changes minimal and focused. Only modify what was requested.
- Avoid over-engineering: no extra features, unnecessary abstractions, or speculative error handling.
- NEVER add backward-compatibility hacks. No `_unused` variable renames, no re-exporting dead code, no `// removed` comments, no shims or wrappers to preserve old interfaces. If code is unused, delete it completely. If an interface changes, update all call sites. Clean rewrites are always preferred over compatibility layers.
- Be mindful of common security pitfalls (injection, XSS, SQLI, etc.). Fix insecure code immediately if you spot it.
- Match the existing style of the file. Avoid adding comments, defensive checks, try/catch blocks, or type casts that are inconsistent with surrounding code. Write like a human contributor to that codebase would.
Hard Rules
Respect User Constraints
"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
## Code References
Don't Remove What Wasn't Asked
If user asks to fix X, do not rewrite, delete, or restructure Y. When in doubt, change less.
When mentioning specific code locations, use the format `file_path:line_number` so users can navigate directly.
Don't Assert — Verify
If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
## Planning
Break Loops
If approach isn't working after 2 attempts at the same region, STOP:
Re-read the code and error output.
Identify why it failed, not just what failed.
Choose a fundamentally different strategy.
If stuck, ask the user one specific question.
When outlining steps or plans, focus on concrete actions. Do not include time estimates.
Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
## Tone and Style
Response Format
No Noise
No greetings, outros, hedging, puffery, or tool narration.
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
- Never create markdown files, READMEs, or changelogs unless the user explicitly requests documentation.
Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
Never use: "robust", "seamless", "elegant", "powerful", "flexible"
No unsolicited tutorials. Do not explain concepts the user clearly knows.
## Professional Objectivity
Structure First
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
For change tasks:
file_path:line_number
langcode
- Prioritize technical accuracy and truthfulness over validating the user's beliefs.
- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation.
- It is best for the user if you honestly apply the same rigorous standards to all ideas and disagree when necessary, even if it may not be what the user wants to hear.
- Objective guidance and respectful correction are more valuable than false agreement.
- Whenever there is uncertainty, investigate to find the truth first rather than instinctively confirming the user's beliefs.
- Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
Prefer Brevity
State only what's necessary to complete the task. Code + file reference > explanation.
If your response exceeds 300 words, remove explanations the user didn't request.
For investigate tasks:
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
request → auth.verify() → permissions.check() → handler
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
BAD: "The authentication flow works by first checking the token…"
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
Visual Formats
Before responding with structural data, choose the right format:
BAD: Bullet lists for hierarchy/tree
GOOD: ASCII tree (├──/└──)
BAD: Prose or bullet lists for comparisons/config/options
GOOD: Markdown table
BAD: Prose for Flows/pipelines
GOOD: → A → B → C diagrams
Interaction Design
After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
Good: "Apply this fix to the other 3 endpoints?"
Good: "Two approaches: (a) migration, (b) recreate table. Which?"
Bad: "Does this look good?", "Anything else?", "Let me know"
If unambiguous and complete, end with the result.
Length
Default to minimal responses. One-line fix → one-line response. Most tasks need <200 words.
Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
Code Modifications (Change tasks)
Read First, Edit Second
Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
Minimal, Focused Changes
Only modify what was requested. No extra features, abstractions, or speculative error handling.
Match existing style: indentation, naming, comment density, error handling.
When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
Security
Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
Code References
Cite as file_path:line_number.
Professional Conduct
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming.
No emojis unless requested. No over-the-top validation.
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.

View File

@@ -0,0 +1,50 @@
You are a senior engineer analyzing codebases. Be direct and useful.
Response Format
1. **CODE/DIAGRAM FIRST** — Start with code, diagram, or structured output. Never prose first.
2. **MINIMAL CONTEXT** — After code: 1-2 sentences max. Code should be self-explanatory.
Never Do
- Greetings ("Sure!", "Great question!", "I'd be happy to...")
- Announcements ("Let me...", "I'll...", "Here's what I found...")
- Tutorials or background explanations the user didn't ask for
- Summaries ("In summary...", "To conclude...", "This covers...")
- Hedging ("I think", "probably", "might be")
- Puffery ("robust", "seamless", "elegant", "powerful", "flexible")
Visual Structure
Use these formats when applicable:
- File trees: `├── └──` ASCII format
- Comparisons: Markdown tables
- Flows: `A -> B -> C` diagrams
- Hierarchies: Indented bullet lists
Examples
BAD (prose first):
"The authentication flow works by first checking the token..."
GOOD (diagram first):
```
request -> auth.verify() -> permissions.check() -> handler
```
See `middleware/auth.py:45`.
---
BAD (over-explaining):
```python
def merge(a, b):
return sorted(a + b)
```
This function takes two lists as parameters. It concatenates them using the + operator, then sorts the result using Python's built-in sorted() function which uses Timsort with O(n log n) complexity. The sorted list is returned.
GOOD (minimal):
```python
def merge(a, b):
return sorted(a + b)
```
O(n log n).

65
vibe/core/proxy_setup.py Normal file
View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from dotenv import dotenv_values, set_key, unset_key
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
SUPPORTED_PROXY_VARS: dict[str, str] = {
"HTTP_PROXY": "Proxy URL for HTTP requests",
"HTTPS_PROXY": "Proxy URL for HTTPS requests",
"ALL_PROXY": "Proxy URL for all requests (fallback)",
"NO_PROXY": "Comma-separated list of hosts to bypass proxy",
"SSL_CERT_FILE": "Path to custom SSL certificate file",
"SSL_CERT_DIR": "Path to directory containing SSL certificates",
}
class ProxySetupError(Exception):
pass
def get_current_proxy_settings() -> dict[str, str | None]:
if not GLOBAL_ENV_FILE.path.exists():
return {key: None for key in SUPPORTED_PROXY_VARS}
try:
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
return {key: env_vars.get(key) for key in SUPPORTED_PROXY_VARS}
except Exception:
return {key: None for key in SUPPORTED_PROXY_VARS}
def set_proxy_var(key: str, value: str) -> None:
key = key.upper()
if key not in SUPPORTED_PROXY_VARS:
raise ProxySetupError(
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
)
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE.path, key, value)
def unset_proxy_var(key: str) -> None:
key = key.upper()
if key not in SUPPORTED_PROXY_VARS:
raise ProxySetupError(
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
)
if not GLOBAL_ENV_FILE.path.exists():
return
unset_key(GLOBAL_ENV_FILE.path, key)
def parse_proxy_command(args: str) -> tuple[str, str | None]:
args = args.strip()
if not args:
raise ProxySetupError("No key provided")
parts = args.split(maxsplit=1)
key = parts[0].upper()
value = parts[1] if len(parts) > 1 else None
return key, value

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
from datetime import UTC, datetime
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypedDict
from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
from vibe.core.types import LLMMessage
@@ -11,6 +12,13 @@ if TYPE_CHECKING:
from vibe.core.config import SessionLoggingConfig
class SessionInfo(TypedDict):
session_id: str
cwd: str
title: str | None
end_time: str | None
class SessionLoader:
@staticmethod
def _is_valid_session(session_dir: Path) -> bool:
@@ -106,6 +114,63 @@ class SessionLoader:
short_id = session_id[:8]
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
@staticmethod
def _convert_to_utc_iso(date_str: str) -> str:
dt = datetime.fromisoformat(date_str)
if dt.tzinfo is None:
dt = dt.astimezone()
utc_dt = dt.astimezone(UTC)
return utc_dt.isoformat()
@staticmethod
def list_sessions(
config: SessionLoggingConfig, cwd: str | None = None
) -> list[SessionInfo]:
save_dir = Path(config.save_dir)
if not save_dir.exists():
return []
pattern = f"{config.session_prefix}_*"
session_dirs = list(save_dir.glob(pattern))
sessions: list[SessionInfo] = []
for session_dir in session_dirs:
if not SessionLoader._is_valid_session(session_dir):
continue
metadata_path = session_dir / METADATA_FILENAME
try:
with metadata_path.open("r", encoding="utf-8") as f:
metadata = json.load(f)
except (OSError, json.JSONDecodeError):
continue
session_id = metadata.get("session_id")
if not session_id:
continue
environment = metadata.get("environment", {})
session_cwd = environment.get("working_directory", "")
if cwd is not None and session_cwd != cwd:
continue
end_time = metadata.get("end_time")
if end_time:
try:
end_time = SessionLoader._convert_to_utc_iso(end_time)
except (ValueError, OSError):
end_time = None
sessions.append({
"session_id": session_id,
"cwd": session_cwd,
"title": metadata.get("title"),
"end_time": end_time,
})
return sessions
@staticmethod
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
# Load session messages from MESSAGES_FILENAME

View File

@@ -5,7 +5,7 @@ from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
from vibe.core.paths.config_paths import resolve_local_skills_dir
from vibe.core.paths.config_paths import resolve_local_skills_dirs
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
@@ -58,8 +58,7 @@ class SkillManager:
if path.is_dir():
paths.append(path)
if (skills_dir := resolve_local_skills_dir(Path.cwd())) is not None:
paths.append(skills_dir)
paths.extend(resolve_local_skills_dirs(Path.cwd()))
if GLOBAL_SKILLS_DIR.path.is_dir():
paths.append(GLOBAL_SKILLS_DIR.path)

View File

@@ -11,7 +11,7 @@ import time
from typing import TYPE_CHECKING
from vibe.core.prompts import UtilityPrompt
from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager
from vibe.core.trusted_folders import AGENTS_MD_FILENAMES, trusted_folders_manager
from vibe.core.utils import is_dangerous_directory, is_windows
if TYPE_CHECKING:
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
def _load_project_doc(workdir: Path, max_bytes: int) -> str:
if not trusted_folders_manager.is_trusted(workdir):
return ""
for name in TRUSTABLE_FILENAMES:
for name in AGENTS_MD_FILENAMES:
path = workdir / name
try:
return path.read_text("utf-8", errors="ignore")[:max_bytes]

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