Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert
2026-04-09 18:40:46 +02:00
committed by GitHub
parent 90763daf81
commit e9a9217cc8
113 changed files with 7202 additions and 541 deletions

3
.gitignore vendored
View File

@@ -199,3 +199,6 @@ result-*
# Tests run the agent in the playground, we don't need to keep the session files
tests/playground/*
.
# Profiler HTML reports (generated by vibe.cli.profiler)
*-profile.html

2
.vscode/launch.json vendored
View File

@@ -1,5 +1,5 @@
{
"version": "2.7.3",
"version": "2.7.4",
"configurations": [
{
"name": "ACP Server",

View File

@@ -5,6 +5,36 @@ 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.7.4] - 2026-04-09
### Added
- Console View for enhanced debugging and monitoring
- `/mcp` command to display MCP servers and their status
- Manual command output forwarding to agent context
### Changed
- Improved web_fetch content truncation for better readability
- Lazily load heavy dependencies to improve startup time
- Optimized folder parsing at startup using scandir
- Include file name in search_replace result display
### Fixed
- Stale configurations from subagent switch
- ValueError on OTEL context detach in agent_span
- Clipboard toast preview replaced with fixed text
- Only agents with type "agent" are loadable with --agent flag
- Made chat_url nullable in ChatAssistantPublicData
- Normalized OTEL span exporter endpoint
- Removed redundant permission prompts for parallel tool calls needing the same permission
- Removed bottom margin issue in UI
- Never crash before ACP server starts
- Use skill in recent commands via the up-arrow navigation
- Fixed loading order issues in vibe initialization
## [2.7.3] - 2026-04-03
### Added

View File

@@ -88,6 +88,8 @@ Example:
LOG_LEVEL=DEBUG uv run vibe
```
You can also view logs in real-time within the application by pressing `Ctrl+\` to open the debug console.
### Running Tests
Run all tests:
@@ -108,6 +110,30 @@ Run a specific test file:
uv run pytest tests/test_agent_tool_call.py
```
### Profiling
Vibe ships a lightweight profiler module (`vibe.cli.profiler`) that wraps [pyinstrument](https://github.com/joerick/pyinstrument). It silently no-ops when pyinstrument is not installed or the `VIBE_PROFILE` env var is unset, so instrumentation can stay in the code with zero overhead in normal use.
**1. Add profiling calls** around the code you want to measure:
```python
from vibe.cli import profiler
profiler.start("startup")
# ... code to measure ...
profiler.stop_and_print()
```
Only one profiler can run at a time. The label passed to `start()` is used to name the output file. Each call to `stop_and_print` writes an HTML report (`<label>-profile.html`) and prints a text summary to stderr.
**2. Run with profiling enabled:**
```bash
VIBE_PROFILE=1 uv run vibe
```
Without `VIBE_PROFILE=1`, all `start` / `stop_and_print` calls are no-ops.
### Linting and Type Checking
#### Ruff (Linting and Formatting)

View File

@@ -218,6 +218,7 @@ Simply run `vibe` to enter the interactive chat loop.
- **External Editor**: Press `Ctrl+G` to edit your current input in an external editor.
- **Tool Output Toggle**: Press `Ctrl+O` to toggle the tool output view.
- **Todo View Toggle**: Press `Ctrl+T` to toggle the todo list view.
- **Debug Console**: Press `Ctrl+\` to toggle the debug console.
- **Auto-Approve Toggle**: Press `Shift+Tab` to toggle auto-approve mode on/off.
You can start Vibe with a prompt using the following command:
@@ -636,6 +637,7 @@ Mistral Vibe can be used in text editors and IDEs that support [Agent Client Pro
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.7.3"
version = "2.7.4"
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.7.3/vibe-acp-darwin-aarch64-2.7.3.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-darwin-aarch64-2.7.4.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.3/vibe-acp-darwin-x86_64-2.7.3.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-darwin-x86_64-2.7.4.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.3/vibe-acp-linux-aarch64-2.7.3.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-aarch64-2.7.4.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.3/vibe-acp-linux-x86_64-2.7.3.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-x86_64-2.7.4.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.3/vibe-acp-windows-aarch64-2.7.3.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-aarch64-2.7.4.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.3/vibe-acp-windows-x86_64-2.7.3.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-x86_64-2.7.4.zip"
cmd = "./vibe-acp.exe"

View File

@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.7.3"
version = "2.7.4"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -97,6 +97,7 @@ required-version = ">=0.8.0"
dev = [
"debugpy>=1.8.19",
"pre-commit>=4.2.0",
"pyinstrument>=5.1.2",
"pyright>=1.1.403",
"pytest>=8.3.5",
"pytest-asyncio>=1.2.0",
@@ -175,7 +176,7 @@ max-branches = 15
max-locals = 15
max-args = 9
max-returns = 6
max-nested-blocks = 4
max-nested-blocks = 5
[tool.vulture]
ignore_decorators = ["@*"]

View File

@@ -12,6 +12,8 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
ORIGINAL_PATH="${PATH}"
function error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
@@ -28,6 +30,24 @@ function warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
function find_command_in_path() {
local cmd="$1"
local path_value="$2"
PATH="$path_value" command -v "$cmd" 2>/dev/null || true
}
function print_missing_path_instructions() {
local executable_name="$1"
local executable_path="$2"
local bin_dir
bin_dir=$(dirname "$executable_path")
warning "Installation completed, and '$executable_name' was installed at: $executable_path"
error "Your PATH does not include the folder that contains '$executable_name'."
error "Add this directory to your shell profile, then restart your terminal:"
error " export PATH=\"$bin_dir:\$PATH\""
}
function check_platform() {
local platform=$(uname -s)
@@ -45,7 +65,7 @@ function check_platform() {
}
function check_uv_installed() {
if command -v uv &> /dev/null; then
if command -v uv >/dev/null 2>&1; then
info "uv is already installed: $(uv --version)"
UV_INSTALLED=true
else
@@ -79,12 +99,21 @@ function install_uv() {
}
function check_vibe_installed() {
if command -v vibe &> /dev/null; then
if [[ -n "$(find_command_in_path vibe "$ORIGINAL_PATH")" ]]; then
info "vibe is already installed"
VIBE_INSTALLED=true
else
VIBE_INSTALLED=false
return
fi
local uv_bin_dir
uv_bin_dir=$(uv tool dir --bin 2>/dev/null || true)
if [[ -n "$uv_bin_dir" && -x "$uv_bin_dir/vibe" ]]; then
info "vibe is already installed (off PATH) at $uv_bin_dir/vibe"
VIBE_INSTALLED=true
return
fi
VIBE_INSTALLED=false
}
function install_vibe() {
@@ -132,7 +161,7 @@ function main() {
update_vibe
fi
if command -v vibe &> /dev/null; then
if [[ -n "$(find_command_in_path vibe "$ORIGINAL_PATH")" ]]; then
success "Installation completed successfully!"
echo
echo "You can now run vibe with:"
@@ -141,8 +170,20 @@ function main() {
echo "Or for ACP mode:"
echo " vibe-acp"
else
error "Installation completed but 'vibe' command not found"
error "Please check your installation and PATH settings"
local UV_BIN_DIR
local VIBE_BIN_PATH=""
UV_BIN_DIR=$(uv tool dir --bin 2>/dev/null || true)
if [[ -n "$UV_BIN_DIR" && -x "$UV_BIN_DIR/vibe" ]]; then
VIBE_BIN_PATH="$UV_BIN_DIR/vibe"
fi
if [[ -n "$VIBE_BIN_PATH" ]]; then
print_missing_path_instructions "vibe" "$VIBE_BIN_PATH"
else
error "Installation completed but 'vibe' command not found"
error "uv did not expose a 'vibe' executable in the expected tools directory."
error "Please check your installation and PATH settings"
fi
exit 1
fi
}

View File

@@ -239,6 +239,32 @@ def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
assert "Setup cancelled" in output
@pytest.mark.asyncio
async def test_vibe_acp_survives_broken_config(vibe_home_dir: Path) -> None:
vibe_home_dir.mkdir(parents=True, exist_ok=True)
(vibe_home_dir / "config.toml").write_text("{{{{invalid toml content!!")
proc, _initialize_response, conn = await _connect_and_initialize(
vibe_home_dir=vibe_home_dir, include_api_key=True
)
try:
# new_session should return a structured JSON-RPC error, not crash the server
with pytest.raises(RequestError):
await asyncio.wait_for(
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
)
assert proc.returncode is None, "Server crashed after broken config"
(vibe_home_dir / "config.toml").write_text("")
session = await asyncio.wait_for(
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
)
assert session.session_id
finally:
await _terminate_process(proc)
@pytest.mark.asyncio
async def test_vibe_acp_new_session_fails_without_api_key(vibe_home_dir: Path) -> None:
proc, _initialize_response, conn = await _connect_and_initialize(

View File

@@ -109,7 +109,7 @@ def acp_bash_tool(mock_client: MockClient) -> Bash:
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return Bash(config=config, state=state)
return Bash(config_getter=lambda: config, state=state)
class TestAcpBashBasic:
@@ -154,7 +154,7 @@ class TestAcpBashExecution:
self, mock_client: MockClient
) -> None:
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -174,7 +174,7 @@ class TestAcpBashExecution:
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -194,7 +194,7 @@ class TestAcpBashExecution:
mock_client._create_terminal_error = RuntimeError("Connection failed")
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -212,7 +212,7 @@ class TestAcpBashExecution:
@pytest.mark.asyncio
async def test_run_without_client(self) -> None:
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=None, session_id="test_session", tool_call_id="test_call"
),
@@ -231,7 +231,7 @@ class TestAcpBashExecution:
async def test_run_without_session_id(self) -> None:
mock_client = MockClient()
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id=None, tool_call_id="test_call"
),
@@ -254,7 +254,7 @@ class TestAcpBashExecution:
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -281,7 +281,7 @@ class TestAcpBashTimeout:
# Use a config with different default timeout to verify args timeout overrides it
tool = Bash(
config=BashToolConfig(default_timeout=30),
config_getter=lambda: BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -310,7 +310,7 @@ class TestAcpBashTimeout:
custom_handle.kill = failing_kill
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -328,7 +328,7 @@ class TestAcpBashEmbedding:
@pytest.mark.asyncio
async def test_run_with_embedding(self, mock_client: MockClient) -> None:
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -344,7 +344,7 @@ class TestAcpBashEmbedding:
self, mock_client: MockClient
) -> None:
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id=None
),
@@ -367,7 +367,7 @@ class TestAcpBashEmbedding:
mock_client.session_update = failing_session_update
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -393,7 +393,7 @@ class TestAcpBashConfig:
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(default_timeout=30),
config_getter=lambda: BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -423,7 +423,7 @@ class TestAcpBashCleanup:
custom_handle.release = mock_release
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -455,7 +455,7 @@ class TestAcpBashCleanup:
custom_handle.release = mock_release
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -481,7 +481,7 @@ class TestAcpBashCleanup:
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
config_getter=lambda: BashToolConfig(),
state=AcpBashState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),

View File

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

View File

@@ -291,6 +291,42 @@ class TestLoadSession:
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_replays_reasoning_before_assistant_message(
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-order-1234"
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)
response_updates = [
update.update
for update in client._session_updates
if isinstance(update.update, (AgentThoughtChunk, AgentMessageChunk))
]
assert [type(update) for update in response_updates] == [
AgentThoughtChunk,
AgentMessageChunk,
]
assert response_updates[0].content.text == "Let me think step by step..."
assert response_updates[1].content.text == "Here is my answer"
@pytest.mark.asyncio
async def test_load_session_not_found_raises_error(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]

View File

@@ -76,7 +76,7 @@ def acp_read_file_tool(
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return ReadFile(config=config, state=state)
return ReadFile(config_getter=lambda: config, state=state)
class TestAcpReadFileBasic:
@@ -116,7 +116,7 @@ class TestAcpReadFileExecution:
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(),
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -139,7 +139,7 @@ class TestAcpReadFileExecution:
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(),
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -162,7 +162,7 @@ class TestAcpReadFileExecution:
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(),
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -187,7 +187,7 @@ class TestAcpReadFileExecution:
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(),
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -207,7 +207,7 @@ class TestAcpReadFileExecution:
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(),
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=None, session_id="test_session", tool_call_id="test_call"
),
@@ -231,7 +231,7 @@ class TestAcpReadFileExecution:
test_file.touch()
mock_client = MockClient()
tool = ReadFile(
config=ReadFileToolConfig(),
config_getter=lambda: ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
client=mock_client, session_id=None, tool_call_id="test_call"
),

View File

@@ -85,7 +85,7 @@ def acp_search_replace_tool(
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return SearchReplace(config=config, state=state)
return SearchReplace(config_getter=lambda: config, state=state)
class TestAcpSearchReplaceBasic:
@@ -139,7 +139,7 @@ class TestAcpSearchReplaceExecution:
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig(create_backup=True)
tool = SearchReplace(
config=config,
config_getter=lambda: config,
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -169,7 +169,7 @@ class TestAcpSearchReplaceExecution:
mock_client._read_error = RuntimeError("File not found")
tool = SearchReplace(
config=SearchReplaceConfig(),
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -200,7 +200,7 @@ class TestAcpSearchReplaceExecution:
mock_client._file_content = "old" # Update mock to return correct content
tool = SearchReplace(
config=SearchReplaceConfig(),
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -243,7 +243,7 @@ class TestAcpSearchReplaceExecution:
test_file = tmp_path / "test.txt"
test_file.touch()
tool = SearchReplace(
config=SearchReplaceConfig(),
config_getter=lambda: SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
client=client, session_id=session_id, tool_call_id="test_call"
),

View File

@@ -58,7 +58,7 @@ def acp_write_file_tool(
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
return WriteFile(config=config, state=state)
return WriteFile(config_getter=lambda: config, state=state)
class TestAcpWriteFileBasic:
@@ -95,7 +95,7 @@ class TestAcpWriteFileExecution:
) -> None:
monkeypatch.chdir(tmp_path)
tool = WriteFile(
config=WriteFileConfig(),
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -130,7 +130,7 @@ class TestAcpWriteFileExecution:
mock_client._write_error = RuntimeError("Permission denied")
tool = WriteFile(
config=WriteFileConfig(),
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
@@ -149,7 +149,7 @@ class TestAcpWriteFileExecution:
) -> None:
monkeypatch.chdir(tmp_path)
tool = WriteFile(
config=WriteFileConfig(),
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=None, session_id="test_session", tool_call_id="test_call"
),
@@ -171,7 +171,7 @@ class TestAcpWriteFileExecution:
monkeypatch.chdir(tmp_path)
mock_client = MockClient()
tool = WriteFile(
config=WriteFileConfig(),
config_getter=lambda: WriteFileConfig(),
state=AcpWriteFileState.model_construct(
client=mock_client, session_id=None, tool_call_id="test_call"
),

View File

@@ -276,6 +276,20 @@ async def test_finds_files_recursively_with_partial_path(
assert popup.styles.display == "block"
@pytest.mark.asyncio
async def test_popup_is_positioned_near_cursor(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
popup = vibe_app.query_one(CompletionPopup)
await pilot.press(*"/com")
assert popup.styles.display == "block"
offset = popup.styles.offset
# The popup should have an explicit offset set by _position_popup
assert offset.x is not None
assert offset.y is not None
@pytest.mark.asyncio
async def test_does_not_trigger_completion_when_navigating_history(
file_tree: Path, vibe_app: VibeApp

View File

@@ -529,26 +529,6 @@ class TestMistralMapperPrepareMessage:
result = mapper.prepare_message(msg)
assert result.content == "Hello!"
def test_strip_reasoning_removes_reasoning_from_assistant(
self, mapper: MistralMapper
) -> None:
msg = LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="thinking...",
reasoning_signature="sig",
)
stripped = mapper.strip_reasoning(msg)
assert stripped.content == "Answer"
assert stripped.reasoning_content is None
assert stripped.reasoning_signature is None
def test_strip_reasoning_leaves_non_assistant_unchanged(
self, mapper: MistralMapper
) -> None:
msg = LLMMessage(role=Role.user, content="hello")
assert mapper.strip_reasoning(msg) is msg
class TestMistralBackendReasoningEffort:
"""Tests that MistralBackend correctly passes reasoning_effort to the SDK."""
@@ -612,53 +592,6 @@ class TestMistralBackendReasoningEffort:
assert call_kwargs["reasoning_effort"] == expected_effort
assert call_kwargs["temperature"] == expected_temperature
@pytest.mark.asyncio
async def test_complete_strips_reasoning_when_thinking_off(
self, backend: MistralBackend
) -> None:
model = ModelConfig(
name="mistral-small-latest",
provider="mistral",
alias="mistral-small",
thinking="off",
)
messages = [
LLMMessage(role=Role.user, content="hi"),
LLMMessage(
role=Role.assistant, content="answer", reasoning_content="thinking..."
),
LLMMessage(role=Role.user, content="follow up"),
]
with patch.object(backend, "_get_client") as mock_get_client:
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "response"
mock_response.choices[0].message.tool_calls = None
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 5
mock_client.chat.complete_async = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
await backend.complete(
model=model,
messages=messages,
temperature=0.2,
tools=None,
max_tokens=None,
tool_choice=None,
extra_headers=None,
)
call_kwargs = mock_client.chat.complete_async.call_args.kwargs
assert call_kwargs["reasoning_effort"] is None
# The assistant message should have reasoning stripped
converted_msgs = call_kwargs["messages"]
assistant_msg = converted_msgs[1]
assert isinstance(assistant_msg, AssistantMessage)
assert assistant_msg.content == "answer"
class TestBuildHttpErrorBodyReading:
_MESSAGES: ClassVar[list[LLMMessage]] = [LLMMessage(role=Role.user, content="hi")]

View File

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

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from urllib.parse import urlencode
from vibe.setup.auth import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInGateway,
BrowserSignInPollResult,
BrowserSignInProcess,
)
@dataclass
class ExchangeRequestPayload:
process_id: str
exchange_token: str
code_verifier: str
class StubBrowserSignInGateway(BrowserSignInGateway):
def __init__(
self,
*,
process: BrowserSignInProcess | None = None,
processes: list[BrowserSignInProcess] | None = None,
poll_results: list[BrowserSignInPollResult | BrowserSignInError] | None = None,
exchange_result: str = "sk-browser-key",
exchange_error: BrowserSignInError | None = None,
) -> None:
if process is not None and processes is not None:
msg = "StubBrowserSignInGateway accepts either process or processes."
raise AssertionError(msg)
self._processes = list(processes or ([] if process is None else [process]))
self._poll_results = list(poll_results or [])
self.exchange_result = exchange_result
self.exchange_error = exchange_error
self.code_challenges: list[str] = []
self.polled_urls: list[str] = []
self.exchange_requests: list[ExchangeRequestPayload] = []
self.closed = False
self.process_number = 0
async def create_process(self, code_challenge: str) -> BrowserSignInProcess:
self.code_challenges.append(code_challenge)
if not self._processes:
msg = "StubBrowserSignInGateway requires at least one scripted process."
raise AssertionError(msg)
self.process_number += 1
return self._processes.pop(0)
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
self.polled_urls.append(poll_url)
if not self._poll_results:
msg = "StubBrowserSignInGateway requires scripted poll results."
raise AssertionError(msg)
result = self._poll_results.pop(0)
if isinstance(result, BrowserSignInError):
raise result
return result
async def exchange(
self, process_id: str, exchange_token: str, code_verifier: str
) -> str:
self.exchange_requests.append(
ExchangeRequestPayload(
process_id=process_id,
exchange_token=exchange_token,
code_verifier=code_verifier,
)
)
if self.exchange_error is not None:
raise self.exchange_error
return self.exchange_result
async def aclose(self) -> None:
self.closed = True
def build_sign_in_process(
now: datetime, process_id: str = "process-1"
) -> BrowserSignInProcess:
fragment = urlencode({
"process_id": process_id,
"complete_token": f"complete-token-{process_id}",
"state": f"state-{process_id}",
})
return BrowserSignInProcess(
process_id=process_id,
sign_in_url=(
f"https://console.mistral.ai/codestral/cli/authenticate#{fragment}"
),
poll_url=(
f"https://api.mistral.ai/api/vibe/sign-in/poll/poll-token-{process_id}"
),
expires_at=now + timedelta(minutes=5),
)
def build_poll_failed_error() -> BrowserSignInError:
return BrowserSignInError(
"Browser sign-in status could not be retrieved.",
code=BrowserSignInErrorCode.POLL_FAILED,
)
async def noop_sleep(_: float) -> None:
return None

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
import asyncio
import base64
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
import hashlib
from types import SimpleNamespace
from typing import cast
from urllib.parse import urlencode
import pytest
from tests.browser_sign_in.stubs import (
StubBrowserSignInGateway,
build_poll_failed_error,
build_sign_in_process,
noop_sleep,
)
from vibe.setup.auth import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInPollResult,
BrowserSignInProcess,
BrowserSignInService,
)
TEST_NOW = datetime(2026, 3, 16, tzinfo=UTC)
TEST_PROCESS_ID = "process-1"
TEST_SIGN_IN_URL = "https://console.mistral.ai/codestral/cli/authenticate#" + urlencode({
"process_id": TEST_PROCESS_ID,
"complete_token": f"complete-token-{TEST_PROCESS_ID}",
"state": f"state-{TEST_PROCESS_ID}",
})
TEST_POLL_URL = "https://api.mistral.ai/api/vibe/sign-in/poll/poll-token-process-1"
def build_code_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def build_test_service(
*,
poll_results: list[BrowserSignInPollResult | BrowserSignInError],
exchange_error: BrowserSignInError | None = None,
open_browser: Callable[[str], bool] | None = None,
sleep: Callable[[float], Awaitable[None]] = noop_sleep,
now: Callable[[], datetime] | None = None,
process_time: datetime = TEST_NOW,
) -> tuple[StubBrowserSignInGateway, BrowserSignInService]:
gateway = StubBrowserSignInGateway(
process=build_sign_in_process(process_time),
poll_results=poll_results,
exchange_error=exchange_error,
)
service = BrowserSignInService(
gateway,
open_browser=open_browser or (lambda _: True),
sleep=sleep,
now=now or (lambda: process_time),
poll_interval=0,
)
return gateway, service
@pytest.mark.asyncio
async def test_authenticate_returns_api_key_after_pending_poll() -> None:
opened_urls: list[str] = []
statuses: list[str] = []
gateway, service = build_test_service(
poll_results=[
BrowserSignInPollResult(status="pending"),
BrowserSignInPollResult(status="completed", exchange_token="exchange-1"),
],
open_browser=lambda url: opened_urls.append(url) or True,
)
api_key = await service.authenticate(status_callback=statuses.append)
code_verifier = gateway.exchange_requests[0].code_verifier
assert gateway.code_challenges == [build_code_challenge(code_verifier)]
assert api_key == "sk-browser-key"
assert opened_urls == [TEST_SIGN_IN_URL]
assert statuses == [
"opening_browser",
"waiting_for_browser_sign_in",
"exchanging",
"completed",
]
assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL]
assert gateway.exchange_requests[0].exchange_token == "exchange-1"
@pytest.mark.asyncio
async def test_authenticate_raises_when_polling_expires() -> None:
opened_urls: list[str] = []
_, service = build_test_service(
poll_results=[BrowserSignInPollResult(status="expired")],
open_browser=lambda url: opened_urls.append(url) or True,
)
with pytest.raises(BrowserSignInError, match="expired"):
await service.authenticate()
assert opened_urls == [TEST_SIGN_IN_URL]
@pytest.mark.asyncio
async def test_authenticate_retries_after_transient_poll_failure() -> None:
gateway, service = build_test_service(
poll_results=[
build_poll_failed_error(),
BrowserSignInPollResult(status="completed", exchange_token="exchange-1"),
]
)
api_key = await service.authenticate()
assert api_key == "sk-browser-key"
assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL]
@pytest.mark.asyncio
async def test_authenticate_fails_after_three_consecutive_poll_failures() -> None:
_, service = build_test_service(
poll_results=[
build_poll_failed_error(),
build_poll_failed_error(),
build_poll_failed_error(),
]
)
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await service.authenticate()
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@pytest.mark.asyncio
async def test_authenticate_resets_poll_failure_streak_after_successful_poll() -> None:
gateway, service = build_test_service(
poll_results=[
build_poll_failed_error(),
BrowserSignInPollResult(status="pending"),
build_poll_failed_error(),
BrowserSignInPollResult(status="completed", exchange_token="exchange-1"),
]
)
api_key = await service.authenticate()
assert api_key == "sk-browser-key"
assert len(gateway.polled_urls) == 4
@pytest.mark.asyncio
async def test_authenticate_raises_on_unknown_poll_state() -> None:
class UnknownStateGateway:
def __init__(self) -> None:
self.process = build_sign_in_process(TEST_NOW)
async def create_process(self, code_challenge: str):
return self.process
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
return cast(
BrowserSignInPollResult,
SimpleNamespace(status="unexpected", exchange_token=None, message=None),
)
async def exchange(
self, process_id: str, exchange_token: str, code_verifier: str
) -> str:
return "sk-browser-key"
async def aclose(self) -> None:
return None
service = BrowserSignInService(
UnknownStateGateway(),
open_browser=lambda _: True,
sleep=noop_sleep,
now=lambda: TEST_NOW,
poll_interval=0,
)
with pytest.raises(BrowserSignInError, match="unknown state") as err:
await service.authenticate()
assert err.value.code is BrowserSignInErrorCode.UNKNOWN_STATE
@pytest.mark.asyncio
async def test_authenticate_raises_when_browser_cannot_be_opened() -> None:
_, service = build_test_service(poll_results=[], open_browser=lambda _: False)
with pytest.raises(BrowserSignInError, match="open browser"):
await service.authenticate()
@pytest.mark.asyncio
async def test_authenticate_raises_when_exchange_fails() -> None:
_, service = build_test_service(
poll_results=[
BrowserSignInPollResult(status="completed", exchange_token="exchange-1")
],
exchange_error=BrowserSignInError(
"Failed to exchange browser sign-in for an API key."
),
)
with pytest.raises(BrowserSignInError, match="exchange"):
await service.authenticate()
@pytest.mark.asyncio
async def test_authenticate_can_be_cancelled_before_start() -> None:
gateway, service = build_test_service(poll_results=[])
task = asyncio.create_task(service.authenticate())
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert gateway.code_challenges == []
assert gateway.exchange_requests == []
@pytest.mark.asyncio
async def test_authenticate_can_be_cancelled_while_waiting_for_sign_in() -> None:
blocker = asyncio.Event()
async def wait_forever(_: float) -> None:
await blocker.wait()
gateway, service = build_test_service(
poll_results=[BrowserSignInPollResult(status="pending")], sleep=wait_forever
)
task = asyncio.create_task(service.authenticate())
while not gateway.polled_urls:
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert gateway.exchange_requests == []
@pytest.mark.asyncio
async def test_authenticate_times_out_when_process_never_completes() -> None:
current_time = TEST_NOW
async def advance_time(_: float) -> None:
nonlocal current_time
current_time += timedelta(minutes=3)
_, service = build_test_service(
poll_results=[
BrowserSignInPollResult(status="pending"),
BrowserSignInPollResult(status="pending"),
],
sleep=advance_time,
now=lambda: current_time,
process_time=current_time,
)
with pytest.raises(BrowserSignInError, match="timed out"):
await service.authenticate()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"first_poll_result",
[BrowserSignInPollResult(status="pending"), build_poll_failed_error()],
ids=["pending", "transient_poll_failure"],
)
async def test_authenticate_caps_sleep_to_remaining_sign_in_lifetime(
first_poll_result: BrowserSignInPollResult | BrowserSignInError,
) -> None:
current_time = TEST_NOW
sleep_durations: list[float] = []
async def advance_time(duration: float) -> None:
nonlocal current_time
sleep_durations.append(duration)
current_time += timedelta(seconds=duration)
gateway = StubBrowserSignInGateway(
process=BrowserSignInProcess(
process_id=TEST_PROCESS_ID,
sign_in_url=TEST_SIGN_IN_URL,
poll_url=TEST_POLL_URL,
expires_at=TEST_NOW + timedelta(seconds=1),
),
poll_results=[first_poll_result],
)
service = BrowserSignInService(
gateway,
open_browser=lambda _: True,
sleep=advance_time,
now=lambda: current_time,
poll_interval=3.0,
)
with pytest.raises(BrowserSignInError, match="timed out") as err:
await service.authenticate()
assert err.value.code is BrowserSignInErrorCode.TIMED_OUT
assert sleep_durations == [1.0]
@pytest.mark.asyncio
async def test_aclose_closes_underlying_api() -> None:
gateway, service = build_test_service(poll_results=[])
await service.aclose()
assert gateway.closed is True

View File

@@ -0,0 +1,654 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
import logging
from urllib.parse import urlencode
import httpx
import pytest
from vibe.setup.auth import (
BrowserSignInError,
BrowserSignInErrorCode,
HttpBrowserSignInGateway,
)
AUTH_ORIGIN = "https://api.mistral.ai"
AUTH_BROWSER_BASE_URL = "https://console.mistral.ai"
AUTH_API_BASE_URL = AUTH_ORIGIN
TEST_PROCESS_ID = "process-1"
TEST_COMPLETE_TOKEN = "complete-token-1"
TEST_STATE = "state-1"
TEST_POLL_TOKEN = "poll-token-1"
def _iso(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def build_sign_in_url(
*,
process_id: str = TEST_PROCESS_ID,
base_url: str = AUTH_BROWSER_BASE_URL,
complete_token: str = TEST_COMPLETE_TOKEN,
state: str = TEST_STATE,
) -> str:
fragment = urlencode({
"process_id": process_id,
"complete_token": complete_token,
"state": state,
})
return f"{base_url}/codestral/cli/authenticate#{fragment}"
def build_poll_url(
*, poll_token: str = TEST_POLL_TOKEN, api_base_url: str = AUTH_API_BASE_URL
) -> str:
return f"{api_base_url}/api/vibe/sign-in/poll/{poll_token}"
@asynccontextmanager
async def build_gateway(
handler: Callable[[httpx.Request], httpx.Response],
*,
origin: str = AUTH_ORIGIN,
browser_base_url: str = AUTH_BROWSER_BASE_URL,
api_base_url: str = AUTH_API_BASE_URL,
):
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url=origin
) as client:
yield HttpBrowserSignInGateway(
browser_base_url=browser_base_url, api_base_url=api_base_url, client=client
)
@pytest.mark.asyncio
async def test_http_api_creates_process_with_pkce_payload() -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
captured_body: str | None = None
def handler(request: httpx.Request) -> httpx.Response:
nonlocal captured_body
assert request.url.path == "/vibe/sign-in"
captured_body = request.content.decode("utf-8")
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": build_poll_url(),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
process = await gateway.create_process("challenge-123")
assert process.process_id == TEST_PROCESS_ID
assert process.sign_in_url == build_sign_in_url()
assert process.poll_url == build_poll_url()
assert captured_body is not None
assert '"code_challenge":"challenge-123"' in captured_body
assert '"code_challenge_method":"S256"' in captured_body
@pytest.mark.asyncio
async def test_http_api_polls_process_state() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/vibe/sign-in/poll/poll-token-1"
return httpx.Response(
200, json={"status": "completed", "exchange_token": "exchange-1"}
)
async with build_gateway(handler) as gateway:
result = await gateway.poll(build_poll_url())
assert result.status == "completed"
assert result.exchange_token == "exchange-1"
@pytest.mark.asyncio
async def test_http_api_maps_410_poll_response_to_expired_status() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/vibe/sign-in/poll/poll-token-1"
return httpx.Response(410)
async with build_gateway(handler) as gateway:
result = await gateway.poll(build_poll_url())
assert result.status == "expired"
assert result.exchange_token is None
@pytest.mark.asyncio
async def test_http_api_exchanges_token_for_api_key() -> None:
captured_body: str | None = None
def handler(request: httpx.Request) -> httpx.Response:
nonlocal captured_body
assert request.url.path == "/vibe/sign-in/process-1/exchange"
captured_body = request.content.decode("utf-8")
return httpx.Response(200, json={"api_key": "sk-browser-key"})
async with build_gateway(handler) as gateway:
api_key = await gateway.exchange("process-1", "exchange-1", "verifier-1")
assert api_key == "sk-browser-key"
assert captured_body is not None
assert '"exchange_token":"exchange-1"' in captured_body
assert '"code_verifier":"verifier-1"' in captured_body
@pytest.mark.asyncio
async def test_http_api_translates_transport_errors() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
async with build_gateway(handler) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_assigns_poll_failed_code_on_transport_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
async with build_gateway(handler) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(build_poll_url())
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@pytest.mark.asyncio
async def test_http_api_assigns_poll_failed_code_on_invalid_poll_url() -> None:
async with build_gateway(lambda _: httpx.Response(200)) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll("https://evil.example/poll/secret-1")
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@pytest.mark.asyncio
async def test_http_api_translates_non_json_start_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<html>not json</html>")
async with build_gateway(handler) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_translates_missing_start_fields() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": build_poll_url(),
},
)
async with build_gateway(handler) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_accepts_poll_url_under_configured_api_base_url() -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": build_poll_url(),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
process = await gateway.create_process("challenge-123")
assert process.poll_url == build_poll_url()
@pytest.mark.asyncio
async def test_http_api_accepts_poll_url_under_configured_api_base_path() -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/vibe/sign-in"
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": build_poll_url(
poll_token=TEST_POLL_TOKEN, api_base_url="https://api.mistral.ai/v1"
),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
) as gateway:
process = await gateway.create_process("challenge-123")
assert process.poll_url == build_poll_url(api_base_url="https://api.mistral.ai/v1")
@pytest.mark.asyncio
async def test_http_api_accepts_same_origin_urls_with_explicit_default_https_ports() -> (
None
):
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai:443"
),
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai:443"),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
process = await gateway.create_process("challenge-123")
assert process.sign_in_url == build_sign_in_url(
base_url="https://console.mistral.ai:443"
)
assert process.poll_url == build_poll_url(api_base_url="https://api.mistral.ai:443")
@pytest.mark.asyncio
async def test_http_api_accepts_poll_url_without_explicit_default_https_port_when_base_has_one() -> (
None
):
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/vibe/sign-in/poll/poll-token-1"
return httpx.Response(
200, json={"status": "completed", "exchange_token": "exchange-1"}
)
async with build_gateway(
handler,
origin="https://api.mistral.ai:443",
api_base_url="https://api.mistral.ai:443",
) as gateway:
result = await gateway.poll(build_poll_url())
assert result.status == "completed"
assert result.exchange_token == "exchange-1"
@pytest.mark.asyncio
async def test_http_api_accepts_sign_in_url_under_configured_browser_base_url() -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": build_poll_url(),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
process = await gateway.create_process("challenge-123")
assert process.sign_in_url == build_sign_in_url()
@pytest.mark.asyncio
async def test_http_api_rejects_sign_in_url_outside_browser_base_url() -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(base_url="https://evil.example"),
"poll_url": build_poll_url(),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_rejects_sign_in_url_outside_browser_base_path_after_normalization() -> (
None
):
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1/.."
),
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai/v1"),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
browser_base_url="https://console.mistral.ai/v1",
api_base_url="https://api.mistral.ai/v1",
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_rejects_sign_in_url_with_encoded_dot_segments_outside_browser_base_path() -> (
None
):
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1/%2e%2e"
),
"poll_url": build_poll_url(api_base_url="https://api.mistral.ai/v1"),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
browser_base_url="https://console.mistral.ai/v1",
api_base_url="https://api.mistral.ai/v1",
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_rejects_poll_url_outside_api_base_url() -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": "https://evil.example/api/vibe/sign-in/poll/poll-token-1",
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_rejects_returned_poll_url_outside_api_base_path_after_normalization() -> (
None
):
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1"
),
"poll_url": build_poll_url(
poll_token=TEST_POLL_TOKEN,
api_base_url="https://api.mistral.ai/v1/..",
),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
browser_base_url="https://console.mistral.ai/v1",
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_rejects_returned_poll_url_with_encoded_dot_segments_outside_api_base_path() -> (
None
):
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(
base_url="https://console.mistral.ai/v1"
),
"poll_url": build_poll_url(
poll_token=TEST_POLL_TOKEN,
api_base_url="https://api.mistral.ai/v1/%2e%2e",
),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(
handler,
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
browser_base_url="https://console.mistral.ai/v1",
) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_rejects_poll_url_outside_api_base_path() -> None:
async with build_gateway(
lambda _: httpx.Response(200),
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(
"https://api.mistral.ai/v1evil/api/vibe/sign-in/poll/poll-token-1"
)
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@pytest.mark.asyncio
async def test_http_api_rejects_poll_url_outside_api_base_path_after_normalization() -> (
None
):
async with build_gateway(
lambda _: httpx.Response(200),
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(
"https://api.mistral.ai/v1/../api/vibe/sign-in/poll/poll-token-1"
)
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@pytest.mark.asyncio
async def test_http_api_translates_invalid_returned_poll_url_port() -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(),
"poll_url": "https://api.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1",
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
with pytest.raises(BrowserSignInError, match="start browser sign-in") as err:
await gateway.create_process("challenge-123")
assert err.value.code is BrowserSignInErrorCode.START_FAILED
@pytest.mark.asyncio
async def test_http_api_assigns_poll_failed_code_on_invalid_poll_url_port() -> None:
async with build_gateway(lambda _: httpx.Response(200)) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(
"https://api.mistral.ai:99999/api/vibe/sign-in/poll/poll-token-1"
)
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@pytest.mark.asyncio
async def test_http_api_translates_non_json_poll_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<html>not json</html>")
async with build_gateway(handler) as gateway:
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
) as err:
await gateway.poll(build_poll_url())
assert err.value.code is BrowserSignInErrorCode.POLL_FAILED
@pytest.mark.asyncio
async def test_http_api_translates_non_json_exchange_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<html>not json</html>")
async with build_gateway(handler) as gateway:
with pytest.raises(BrowserSignInError, match="exchange browser sign-in") as err:
await gateway.exchange("process-1", "exchange-1", "verifier-1")
assert err.value.code is BrowserSignInErrorCode.EXCHANGE_FAILED
@pytest.mark.asyncio
async def test_http_api_does_not_log_sign_in_or_poll_secrets_on_start_validation_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
now = datetime(2026, 3, 16, tzinfo=UTC)
complete_token = "complete-token-secret"
state = "state-secret"
poll_token = "poll-token-secret"
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"process_id": TEST_PROCESS_ID,
"sign_in_url": build_sign_in_url(
base_url="https://evil.example",
complete_token=complete_token,
state=state,
),
"poll_url": build_poll_url(poll_token=poll_token),
"expires_at": _iso(now + timedelta(minutes=5)),
},
)
async with build_gateway(handler) as gateway:
with caplog.at_level(logging.WARNING, logger="vibe"):
with pytest.raises(BrowserSignInError, match="start browser sign-in"):
await gateway.create_process("challenge-123")
assert complete_token not in caplog.text
assert state not in caplog.text
assert poll_token not in caplog.text
@pytest.mark.asyncio
async def test_http_api_does_not_log_poll_secret_on_poll_url_validation_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
poll_token = "poll-token-secret"
async with build_gateway(
lambda _: httpx.Response(200),
origin="https://api.mistral.ai",
api_base_url="https://api.mistral.ai/v1",
) as gateway:
with caplog.at_level(logging.WARNING, logger="vibe"):
with pytest.raises(
BrowserSignInError, match="status could not be retrieved"
):
await gateway.poll(
f"https://api.mistral.ai/v1evil/api/vibe/sign-in/poll/{poll_token}"
)
assert poll_token not in caplog.text

View File

@@ -124,10 +124,7 @@ def test_copy_selection_to_clipboard_success(
assert result == "selected text"
mock_copy_to_clipboard.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard',
severity="information",
timeout=2,
markup=False,
"Selection copied to clipboard", severity="information", timeout=2, markup=False
)
@@ -170,32 +167,13 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
"first selection\nsecond selection"
)
mock_app.notify.assert_called_once_with(
'"first selection\u23cesecond selection" copied to clipboard',
"Selection copied to clipboard",
severity="information",
timeout=2,
markup=False,
)
def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) -> None:
long_text = "a" * 100
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=(long_text, None)
)
mock_app.query.return_value = [widget]
with patch("vibe.cli.clipboard._copy_to_clipboard") as mock_copy_to_clipboard:
result = copy_selection_to_clipboard(mock_app)
assert result == long_text
mock_copy_to_clipboard.assert_called_once_with(long_text)
notification_call = mock_app.notify.call_args
assert notification_call is not None
assert '"' in notification_call[0][0]
assert "copied to clipboard" in notification_call[0][0]
assert len(notification_call[0][0]) < len(long_text) + 30
def test_copy_to_clipboard_stops_after_verified_copy() -> None:
"""Stops iterating once _read_clipboard confirms the text landed."""
mock_first = MagicMock()

View File

@@ -24,46 +24,62 @@ class TestCommandRegistry:
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:
def test_parse_command_returns_command_when_alias_matches(self) -> None:
registry = CommandRegistry()
cmd = registry.find_command("/help")
assert cmd is not None
result = registry.parse_command("/help")
assert result is not None
cmd_name, cmd, cmd_args = result
assert cmd_name == "help"
assert cmd.handler == "_show_help"
assert isinstance(cmd, Command)
assert cmd_args == ""
def test_find_command_returns_none_when_no_match(self) -> None:
def test_parse_command_returns_none_when_no_match(self) -> None:
registry = CommandRegistry()
assert registry.find_command("/nonexistent") is None
assert registry.parse_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."""
def test_parse_command_uses_get_command_name(self) -> None:
"""parse_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)
result = registry.parse_command(alias)
if cmd_name is None:
assert cmd is None
assert result is None
else:
assert cmd is not None
assert cmd_name in registry.commands
assert registry.commands[cmd_name] is cmd
assert result is not None
found_name, found_cmd, _ = result
assert found_name == cmd_name
assert registry.commands[cmd_name] is found_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.parse_command("/exit") is None
assert registry.get_command_name("/help") == "help"
def test_resume_command_registration(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/resume") == "resume"
assert registry.get_command_name("/continue") == "resume"
cmd = registry.find_command("/resume")
assert cmd is not None
result = registry.parse_command("/resume")
assert result is not None
_, cmd, _ = result
assert cmd.handler == "_show_session_picker"
def test_parse_command_keeps_args_for_no_arg_commands(self) -> None:
registry = CommandRegistry()
result = registry.parse_command("/help extra")
assert result == ("help", registry.commands["help"], "extra")
def test_parse_command_keeps_args_for_argument_commands(self) -> None:
registry = CommandRegistry()
result = registry.parse_command("/mcp filesystem")
assert result == ("mcp", registry.commands["mcp"], "filesystem")
def test_data_retention_command_registration(self) -> None:
registry = CommandRegistry()
cmd = registry.find_command("/data-retention")
assert cmd is not None
result = registry.parse_command("/data-retention")
assert result is not None
_, cmd, _ = result
assert cmd.handler == "_show_data_retention"

156
tests/cli/test_mcp_app.py Normal file
View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import Any
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, collect_mcp_tool_index
from vibe.core.config import MCPStdio
from vibe.core.tools.base import InvokeContext
from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult, _OpenArgs
from vibe.core.types import ToolStreamEvent
def _make_tool_cls(
*,
is_mcp: bool,
description: str = "",
server_name: str | None = None,
remote_name: str = "tool",
) -> type:
if not is_mcp:
return type("FakeTool", (), {"description": description})
async def _run(
self: Any, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
yield MCPToolResult(ok=True, server="", tool="", text=None)
return type(
"FakeMCPTool",
(MCPTool,),
{
"description": description,
"_server_name": server_name,
"_remote_name": remote_name,
"run": _run,
},
)
def _make_tool_manager(
all_tools: dict[str, type], available_tools: dict[str, type] | None = None
) -> MagicMock:
mgr = MagicMock()
mgr.registered_tools = all_tools
mgr.available_tools = available_tools if available_tools is not None else all_tools
return mgr
class TestCollectMcpToolIndex:
def test_non_mcp_tools_are_excluded(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
all_tools = {
"srv_tool": _make_tool_cls(is_mcp=True, server_name="srv"),
"bash": _make_tool_cls(is_mcp=False),
}
mgr = _make_tool_manager(all_tools)
index = collect_mcp_tool_index(servers, mgr)
assert "bash" not in str(index.server_tools)
assert len(index.server_tools["srv"]) == 1
def test_counts_match_available_vs_all(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
tool_a = _make_tool_cls(is_mcp=True, server_name="srv", remote_name="tool_a")
tool_b = _make_tool_cls(is_mcp=True, server_name="srv", remote_name="tool_b")
all_tools = {"srv_tool_a": tool_a, "srv_tool_b": tool_b}
available = {"srv_tool_a": tool_a}
mgr = _make_tool_manager(all_tools, available)
index = collect_mcp_tool_index(servers, mgr)
assert len(index.server_tools["srv"]) == 2
enabled = sum(
1 for t, _ in index.server_tools["srv"] if t in index.enabled_tools
)
assert enabled == 1
def test_tool_with_no_matching_server_is_skipped(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
all_tools = {"other_tool": _make_tool_cls(is_mcp=True, server_name="other")}
mgr = _make_tool_manager(all_tools)
index = collect_mcp_tool_index(servers, mgr)
assert index.server_tools == {}
def test_empty_servers_returns_empty(self) -> None:
mgr = _make_tool_manager({
"srv_tool": _make_tool_cls(is_mcp=True, server_name="srv")
})
index = collect_mcp_tool_index([], mgr)
assert index.server_tools == {}
class TestMCPAppInit:
def test_viewing_server_none_when_no_initial_server(self) -> None:
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=[], tool_manager=mgr)
assert app._viewing_server is None
def test_initial_server_stripped_and_stored(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=servers, tool_manager=mgr, initial_server=" srv ")
assert app._viewing_server == "srv"
def test_widget_id_is_mcp_app(self) -> None:
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=[], tool_manager=mgr)
assert app.id == "mcp-app"
def test_refresh_view_unknown_server_falls_back_to_overview(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
app.query_one = MagicMock()
app._refresh_view("nonexistent")
assert app._viewing_server is None
def test_refresh_view_known_server_sets_viewing_server(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
app.query_one = MagicMock()
app._refresh_view("srv")
assert app._viewing_server == "srv"
def test_refresh_view_none_clears_viewing_server(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
app._viewing_server = "srv"
app.query_one = MagicMock()
app._refresh_view(None)
assert app._viewing_server is None
def test_action_back_calls_refresh_view_none(self) -> None:
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
app._viewing_server = "srv"
render_calls: list[str | None] = []
app._refresh_view = lambda server_name: render_calls.append(server_name)
app.action_back()
assert render_calls == [None]
def test_action_back_noop_when_in_overview(self) -> None:
mgr = _make_tool_manager({})
app = MCPApp(mcp_servers=[], tool_manager=mgr)
app._viewing_server = None
render_calls: list[str | None] = []
app._refresh_view = lambda server_name: render_calls.append(server_name)
app.action_back()
assert render_calls == []

View File

@@ -37,4 +37,4 @@ async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
assert notifications
notification = notifications[-1]
assert notification.markup is False
assert "copied to clipboard" in notification.message
assert "Selection copied to clipboard" in notification.message

View File

@@ -0,0 +1,44 @@
"""Test that _LogView.render_line handles width mismatches during resize."""
from __future__ import annotations
from unittest.mock import PropertyMock, patch
import pytest
from textual.app import App, ComposeResult
from textual.geometry import Size
from textual.strip import Strip
from vibe.cli.textual_ui.widgets.debug_console import _LogView
class _LogViewTestApp(App):
def compose(self) -> ComposeResult:
self._log_view = _LogView(
load_page=lambda: None, has_more=lambda: False, id="test-log-view"
)
yield self._log_view
@pytest.mark.asyncio
async def test_render_line_no_keyerror_on_width_mismatch():
"""render_line must not raise KeyError when wrap width != cached width."""
app = _LogViewTestApp()
async with app.run_test(size=(80, 24)) as pilot:
log_view = app._log_view
long_line = "A" * 200
log_view.write_line(long_line)
await pilot.pause()
assert log_view._total_visual == 3
assert log_view._cached_width == 80
# At width 120, wrapping produces 2 lines, but _wrap_prefix says 3.
new_size = Size(120, 24)
log_view._render_line_cache.clear()
with patch.object(
type(log_view), "size", new_callable=PropertyMock, return_value=new_size
):
result = log_view.render_line(2)
assert isinstance(result, Strip)

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import time
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_app
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage
from vibe.core.types import Role
class TestCapOutput:
"""Unit tests for VibeApp._cap_output."""
def test_short_text_unchanged(self) -> None:
assert VibeApp._cap_output("hello", 100) == "hello"
def test_exact_limit_unchanged(self) -> None:
text = "a" * 50
assert VibeApp._cap_output(text, 50) == text
def test_over_limit_truncated(self) -> None:
text = "a" * 100
result = VibeApp._cap_output(text, 50)
assert result == "a" * 50 + "\n... [truncated]"
def test_empty_string_unchanged(self) -> None:
assert VibeApp._cap_output("", 10) == ""
class TestFormatManualCommandContext:
"""Unit tests for VibeApp._format_manual_command_context with output capping."""
@pytest.fixture
def app(self) -> VibeApp:
return build_test_vibe_app()
def test_stdout_capped_in_context(self, app: VibeApp) -> None:
limit = app._get_bash_max_output_bytes()
big_stdout = "x" * (limit + 5000)
result = app._format_manual_command_context(
command="cat big.log", cwd="/tmp", stdout=big_stdout, exit_code=0
)
assert "[truncated]" in result
# The raw oversized content must not appear in the formatted output
assert big_stdout not in result
def test_stderr_capped_in_context(self, app: VibeApp) -> None:
limit = app._get_bash_max_output_bytes()
big_stderr = "E" * (limit + 1000)
result = app._format_manual_command_context(
command="make build", cwd="/tmp", stderr=big_stderr, exit_code=1
)
assert "[truncated]" in result
assert big_stderr not in result
def test_small_output_not_truncated(self, app: VibeApp) -> None:
result = app._format_manual_command_context(
command="echo hi", cwd="/tmp", stdout="hi\n", exit_code=0
)
assert "[truncated]" not in result
assert "hi" in result
def test_both_stdout_and_stderr_capped_independently(self, app: VibeApp) -> None:
limit = app._get_bash_max_output_bytes()
big_stdout = "O" * (limit + 100)
big_stderr = "E" * (limit + 100)
result = app._format_manual_command_context(
command="cmd", cwd="/tmp", stdout=big_stdout, stderr=big_stderr, exit_code=1
)
# Both should be truncated
assert result.count("[truncated]") == 2
class TestGetBashMaxOutputBytes:
"""Test that _get_bash_max_output_bytes reads from the tool config."""
def test_returns_default_value(self) -> None:
from vibe.core.tools.builtins.bash import BashToolConfig
app = build_test_vibe_app()
result = app._get_bash_max_output_bytes()
assert result == BashToolConfig().max_output_bytes
def test_returns_positive_int(self) -> None:
app = build_test_vibe_app()
assert app._get_bash_max_output_bytes() > 0
@pytest.mark.asyncio
async def test_large_bang_command_output_is_capped_in_history() -> None:
"""Integration test: !command output injected into history respects the cap."""
backend = FakeBackend(mock_llm_chunk(content="ok"))
app = build_test_vibe_app(agent_loop=build_test_agent_loop(backend=backend))
async with app.run_test() as pilot:
limit = app._get_bash_max_output_bytes()
# Generate output larger than the cap.
# seq lines average ~4 chars for small numbers but grow; use
# a generous count to guarantee we exceed the byte limit.
repeat = (limit // 2) + 100
cmd = f"!seq 1 {repeat}"
chat_input = app.query_one(ChatInputContainer)
chat_input.value = cmd
await pilot.press("enter")
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if next(iter(app.query(BashOutputMessage)), None):
break
await pilot.pause(0.05)
injected = app.agent_loop.messages[-1]
assert injected.role == Role.user
assert injected.injected is True
assert injected.content is not None
assert "[truncated]" in injected.content
# The injected content should be bounded; allow generous margin for
# formatting overhead but ensure it's not the full raw output.
assert len(injected.content) < limit * 3

View File

@@ -73,6 +73,12 @@ def config_dir(
config_file.write_text(tomli_w.dumps(get_base_config()), encoding="utf-8")
monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", config_dir)
# Re-evaluate PLAN agent overrides so the allowlist uses the monkeypatched path
from vibe.core.agents.models import PLAN, _plan_overrides
object.__setattr__(PLAN, "overrides", _plan_overrides())
return config_dir

View File

@@ -78,3 +78,29 @@ class TestAgentManager:
assert agent.name == "default"
assert agent.agent_type == AgentType.AGENT
def test_initial_agent_rejects_subagent(self) -> None:
"""Test that creating AgentManager with a subagent as initial_agent raises."""
config = build_test_vibe_config(
include_project_context=False, include_prompt_detail=False
)
with pytest.raises(ValueError, match="cannot be used as the primary agent"):
AgentManager(lambda: config, initial_agent="explore")
def test_initial_agent_accepts_subagent_when_allowed(self) -> None:
"""Test that allow_subagent=True permits subagent as initial_agent."""
config = build_test_vibe_config(
include_project_context=False, include_prompt_detail=False
)
manager = AgentManager(
lambda: config, initial_agent="explore", allow_subagent=True
)
assert manager.active_profile.name == "explore"
def test_initial_agent_accepts_agent_type(self) -> None:
"""Test that creating AgentManager with an agent-type agent works."""
config = build_test_vibe_config(
include_project_context=False, include_prompt_detail=False
)
manager = AgentManager(lambda: config, initial_agent="plan")
assert manager.active_profile.name == "plan"

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
import pytest
from vibe.core.config import OtelExporterConfig, ProviderConfig, VibeConfig
from vibe.core.config import OtelSpanExporterConfig, ProviderConfig, VibeConfig
from vibe.core.types import Backend
class TestOtelExporterConfig:
class TestOtelSpanExporterConfig:
def test_derives_endpoint_from_mistral_provider(
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -22,7 +22,7 @@ class TestOtelExporterConfig:
]
}
)
result = config.otel_exporter_config
result = config.otel_span_exporter_config
assert result is not None
assert result.endpoint == "https://customer.mistral.ai/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-test"}
@@ -49,7 +49,7 @@ class TestOtelExporterConfig:
]
}
)
result = config.otel_exporter_config
result = config.otel_span_exporter_config
assert result is not None
assert result.endpoint == "https://eu.mistral.ai/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-eu"}
@@ -67,7 +67,7 @@ class TestOtelExporterConfig:
]
}
)
result = config.otel_exporter_config
result = config.otel_span_exporter_config
assert result is not None
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-fallback"}
@@ -76,7 +76,7 @@ class TestOtelExporterConfig:
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "sk-default")
result = vibe_config.otel_exporter_config
result = vibe_config.otel_span_exporter_config
assert result is not None
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
@@ -88,7 +88,7 @@ class TestOtelExporterConfig:
) -> None:
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
with caplog.at_level("WARNING"):
assert vibe_config.otel_exporter_config is None
assert vibe_config.otel_span_exporter_config is None
assert "OTEL tracing enabled but MISTRAL_API_KEY is not set" in caplog.text
def test_custom_api_key_env_var(
@@ -108,20 +108,33 @@ class TestOtelExporterConfig:
]
}
)
result = config.otel_exporter_config
result = config.otel_span_exporter_config
assert result is not None
assert result.endpoint == "https://onprem.corp.com/telemetry/v1/traces"
assert result.headers == {"Authorization": "Bearer sk-custom"}
def test_explicit_otel_endpoint_bypasses_provider_derivation(
def test_explicit_otel_endpoint_appends_default_traces_path(
self, vibe_config: VibeConfig
) -> None:
config = vibe_config.model_copy(
update={"otel_endpoint": "https://my-collector:4318/v1/traces"}
update={"otel_endpoint": "https://my-collector:4318"}
)
result = config.otel_exporter_config
result = config.otel_span_exporter_config
assert result is not None
assert result == OtelExporterConfig(
assert result == OtelSpanExporterConfig(
endpoint="https://my-collector:4318/v1/traces"
)
assert result.headers is None
def test_explicit_otel_endpoint_preserves_path_prefix(
self, vibe_config: VibeConfig
) -> None:
config = vibe_config.model_copy(
update={"otel_endpoint": "https://my-collector:4318/api/public/otel"}
)
result = config.otel_span_exporter_config
assert result is not None
assert result == OtelSpanExporterConfig(
endpoint="https://my-collector:4318/api/public/otel/v1/traces"
)
assert result.headers is None

View File

@@ -8,7 +8,12 @@ from unittest.mock import MagicMock, patch
import pytest
from vibe.core.logger import StructuredLogFormatter, apply_logging_config
from vibe.core.logger import (
StructuredLogFormatter,
apply_logging_config,
decode_log_message,
encode_log_message,
)
@pytest.fixture
@@ -278,3 +283,63 @@ class TestApplyLoggingConfig:
handler = test_logger.handlers[-1]
assert isinstance(handler, RotatingFileHandler)
assert handler.maxBytes == 5242880
class TestDecodeLogMessage:
def test_plain_message_unchanged(self) -> None:
assert decode_log_message("Hello world") == "Hello world"
def test_decodes_escaped_newline(self) -> None:
assert decode_log_message("hello\\nworld") == "hello\nworld"
def test_decodes_escaped_backslash(self) -> None:
assert decode_log_message("C:\\\\path") == "C:\\path"
def test_decodes_escaped_backslash_before_n(self) -> None:
# This is the bug case: C:\new encoded as C:\\new must decode back to C:\new
assert decode_log_message("C:\\\\new") == "C:\\new"
def test_roundtrip_with_newlines(self) -> None:
original = "line one\nline two\nline three"
encoded = encode_log_message(original)
assert decode_log_message(encoded) == original
def test_roundtrip_with_backslashes(self) -> None:
original = "C:\\Users\\test\\file.txt"
encoded = encode_log_message(original)
assert decode_log_message(encoded) == original
def test_roundtrip_with_backslash_n(self) -> None:
original = "C:\\new folder\\notes.txt"
encoded = encode_log_message(original)
assert decode_log_message(encoded) == original
def test_roundtrip_mixed(self) -> None:
original = "path: C:\\new\nand a newline"
encoded = encode_log_message(original)
assert decode_log_message(encoded) == original
def test_exception_encoding_escapes_backslashes(self) -> None:
formatter = StructuredLogFormatter()
try:
raise ValueError("error in C:\\new\\path")
except ValueError:
import sys
exc_info = sys.exc_info()
record = logging.LogRecord(
name="test",
level=logging.ERROR,
pathname="test.py",
lineno=1,
msg="fail",
args=(),
exc_info=exc_info,
)
output = formatter.format(record)
assert "\n" not in output
# The backslashes in the exception should be escaped
assert "C:\\\\new\\\\path" in output

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
from collections.abc import Callable, Generator
from dataclasses import FrozenInstanceError
from datetime import datetime
from pathlib import Path
import threading
import time
import pytest
from vibe.core.log_reader import LogEntry, LogReader
def _wait_for(condition: Callable[[], bool], timeout: float = 3.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(0.05)
return False
class TestLogEntry:
def test_log_entry_is_frozen(self) -> None:
entry = LogEntry(
timestamp=datetime.now(),
ppid=1,
pid=123,
level="INFO",
message="test",
raw_line="raw",
line_number=1,
)
with pytest.raises(FrozenInstanceError):
entry.message = "modified" # type: ignore[misc]
class TestLogReaderParsing:
@pytest.fixture
def log_file(self, tmp_path: Path) -> Path:
return tmp_path / "test.log"
def test_parses_valid_info_log(self, log_file: Path) -> None:
log_file.write_text(
"2026-02-08T10:30:45.123000+00:00 1234 5678 INFO Test message\n"
)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert len(result.entries) == 1
assert result.entries[0].level == "INFO"
assert result.entries[0].message == "Test message"
def test_parses_valid_error_log(self, log_file: Path) -> None:
log_file.write_text(
"2026-02-08T10:30:45.123000+00:00 1234 5678 ERROR Error message\n"
)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert result.entries[0].level == "ERROR"
def test_parses_log_with_ppid_pid(self, log_file: Path) -> None:
log_file.write_text("2026-02-08T10:30:45.123000+00:00 1111 2222 DEBUG msg\n")
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert result.entries[0].ppid == 1111
assert result.entries[0].pid == 2222
def test_skips_invalid_log_lines(self, log_file: Path) -> None:
log_file.write_text(
"invalid line\n2026-02-08T10:30:45.123000+00:00 1 2 INFO valid\n"
)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert len(result.entries) == 1
assert result.entries[0].message == "valid"
def test_skips_multiline_continuations(self, log_file: Path) -> None:
log_file.write_text(
"2026-02-08T10:30:45.123000+00:00 1 2 INFO msg\n continuation\n"
)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert len(result.entries) == 1
def test_handles_empty_file(self, log_file: Path) -> None:
log_file.write_text("")
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert result.entries == []
def test_handles_missing_file(self, tmp_path: Path) -> None:
missing = tmp_path / "nonexistent.log"
reader = LogReader(log_file=missing)
result = reader.get_logs()
assert result.entries == []
def test_skips_line_with_invalid_timestamp(self, log_file: Path) -> None:
log_file.write_text("not-a-timestamp 1 2 INFO message\n")
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert result.entries == []
def test_skips_line_with_invalid_level(self, log_file: Path) -> None:
log_file.write_text("2026-02-08T10:30:45.123000+00:00 1 2 UNKNOWN message\n")
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert result.entries == []
def test_skips_line_missing_pid(self, log_file: Path) -> None:
log_file.write_text("2026-02-08T10:30:45.123000+00:00 1 INFO message\n")
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert result.entries == []
def test_skips_empty_lines(self, log_file: Path) -> None:
log_file.write_text("\n\n2026-02-08T10:30:45.123000+00:00 1 2 INFO valid\n\n")
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert len(result.entries) == 1
def test_handles_extremely_long_lines(self, log_file: Path) -> None:
long_message = "x" * (128 * 1024) # 128KB message
log_file.write_text(
f"2026-02-08T10:30:45.123000+00:00 1 2 INFO {long_message}\n"
f"2026-02-08T10:30:46.123000+00:00 1 2 INFO short\n"
)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert len(result.entries) == 2
assert result.entries[0].message == "short"
assert result.entries[1].message == long_message
class TestLogReaderMassiveLogs:
@pytest.fixture
def log_file(self, tmp_path: Path) -> Path:
return tmp_path / "test.log"
def test_limit_prevents_reading_entire_large_file(self, log_file: Path) -> None:
num_lines = 10_000
lines = [
f"2026-02-08T10:30:{i % 60:02d}.{i:06d}+00:00 1 2 INFO Message {i}\n"
for i in range(num_lines)
]
log_file.write_text("".join(lines))
reader = LogReader(log_file=log_file)
result = reader.get_logs(limit=10)
assert len(result.entries) == 10
assert result.has_more is True
def test_handles_file_with_mostly_invalid_lines(self, log_file: Path) -> None:
invalid_lines = ["garbage line\n"] * 1000
valid_line = "2026-02-08T10:30:45.123000+00:00 1 2 INFO valid entry\n"
log_file.write_text("".join(invalid_lines) + valid_line)
reader = LogReader(log_file=log_file)
result = reader.get_logs(limit=100)
assert len(result.entries) == 1
assert result.entries[0].message == "valid entry"
def test_handles_binary_garbage_in_file(self, log_file: Path) -> None:
binary_garbage = bytes(range(256))
valid_line = b"2026-02-08T10:30:45.123000+00:00 1 2 INFO valid after binary\n"
log_file.write_bytes(binary_garbage + b"\n" + valid_line)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert len(result.entries) == 1
assert result.entries[0].message == "valid after binary"
def test_handles_null_bytes_in_lines(self, log_file: Path) -> None:
line_with_nulls = (
"2026-02-08T10:30:45.123000+00:00 1 2 INFO msg\x00with\x00nulls\n"
)
valid_line = "2026-02-08T10:30:46.123000+00:00 1 2 INFO clean message\n"
log_file.write_text(line_with_nulls + valid_line)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert len(result.entries) == 2
assert result.entries[0].message == "clean message"
assert result.entries[1].message == "msg\x00with\x00nulls"
def test_handles_massive_single_line_without_newline(self, log_file: Path) -> None:
massive_line = "x" * (1024 * 1024)
log_file.write_text(massive_line)
reader = LogReader(log_file=log_file)
result = reader.get_logs()
assert result.entries == []
class TestLogReaderPagination:
@pytest.fixture
def log_file_with_entries(self, tmp_path: Path) -> Path:
log_file = tmp_path / "test.log"
lines = [
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
for i in range(10)
]
log_file.write_text("".join(lines))
return log_file
def test_returns_logs_newest_first(self, log_file_with_entries: Path) -> None:
reader = LogReader(log_file=log_file_with_entries)
result = reader.get_logs()
assert "Message 9" in result.entries[0].message
def test_limit_restricts_results(self, log_file_with_entries: Path) -> None:
reader = LogReader(log_file=log_file_with_entries)
result = reader.get_logs(limit=3)
assert len(result.entries) == 3
def test_has_more_false_when_exhausted(self, log_file_with_entries: Path) -> None:
reader = LogReader(log_file=log_file_with_entries)
result = reader.get_logs(limit=100)
assert result.has_more is False
def test_cursor_continues_from_previous_position(
self, log_file_with_entries: Path
) -> None:
reader = LogReader(log_file=log_file_with_entries)
page1 = reader.get_logs(limit=3)
assert len(page1.entries) == 3
assert page1.has_more is True
assert page1.cursor is not None
page2 = reader.get_logs(limit=3, offset=page1.cursor)
assert len(page2.entries) == 3
assert page2.has_more is True
assert page2.entries[0].message != page1.entries[-1].message
class TestLogReaderWatcher:
@pytest.fixture
def log_reader(self, tmp_path: Path) -> Generator[LogReader, None, None]:
log_file = tmp_path / "test.log"
log_file.write_text("")
reader = LogReader(log_file=log_file)
yield reader
reader.shutdown()
def test_start_watching_sets_is_watching(self, log_reader: LogReader) -> None:
log_reader.start_watching()
assert log_reader.is_watching is True
def test_stop_watching_clears_is_watching(self, log_reader: LogReader) -> None:
log_reader.start_watching()
log_reader.stop_watching()
assert log_reader.is_watching is False
def test_consumer_receives_new_entries(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
log_file.write_text("")
received: list[LogEntry] = []
reader = LogReader(log_file=log_file, consumer=received.append)
try:
reader.start_watching()
with log_file.open("a") as f:
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO New entry\n")
assert _wait_for(lambda: len(received) >= 1)
assert received[0].message == "New entry"
finally:
reader.shutdown()
def test_toggle_watching_on_off(self, log_reader: LogReader) -> None:
log_reader.start_watching()
assert log_reader.is_watching is True
log_reader.stop_watching()
assert log_reader.is_watching is False
log_reader.start_watching()
assert log_reader.is_watching is True
def test_set_consumer_updates_callback(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
log_file.write_text("")
received: list[LogEntry] = []
reader = LogReader(log_file=log_file)
try:
reader.set_consumer(received.append)
reader.start_watching()
with log_file.open("a") as f:
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO Entry\n")
assert _wait_for(lambda: len(received) >= 1)
finally:
reader.shutdown()
def test_consumer_can_call_get_logs_without_deadlock(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
log_file.write_text("")
reader = LogReader(log_file=log_file, poll_interval=0.05)
callback_completed = threading.Event()
callback_errors: list[Exception] = []
def consumer(_: LogEntry) -> None:
try:
reader.get_logs(limit=1)
except Exception as exc:
callback_errors.append(exc)
finally:
callback_completed.set()
reader.set_consumer(consumer)
try:
reader.start_watching()
with log_file.open("a") as f:
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO Entry\n")
assert callback_completed.wait(timeout=1.0)
assert callback_errors == []
finally:
reader.shutdown()
class TestLogReaderCleanup:
def test_shutdown_stops_watching(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
log_file.write_text("")
reader = LogReader(log_file=log_file)
reader.start_watching()
assert reader.is_watching is True
reader.shutdown()
assert reader.is_watching is False
class TestLogReaderLineNumbers:
def test_line_numbers_relative_from_end(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
lines = [
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
for i in range(5)
]
log_file.write_text("".join(lines))
reader = LogReader(log_file=log_file)
result = reader.get_logs(limit=5)
assert result.entries[0].line_number == 1 # Newest
assert result.entries[0].message == "Message 4"
assert result.entries[1].line_number == 2 # Second newest
assert result.entries[1].message == "Message 3"
def test_live_entries_have_zero_line_number(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
log_file.write_text("")
received: list[LogEntry] = []
reader = LogReader(log_file=log_file, consumer=received.append)
try:
reader.start_watching()
with log_file.open("a") as f:
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO Live entry\n")
assert _wait_for(lambda: len(received) >= 1)
assert received[0].line_number == 0
assert received[0].message == "Live entry"
finally:
reader.shutdown()
class TestLogReaderCursorDrift:
def test_cursor_stable_with_new_logs(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
lines = [
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
for i in range(10)
]
log_file.write_text("".join(lines))
received: list[LogEntry] = []
reader = LogReader(log_file=log_file, consumer=received.append)
try:
page1 = reader.get_logs(limit=3)
assert page1.entries[0].message == "Message 9"
assert page1.cursor == 3
reader.start_watching()
with log_file.open("a") as f:
f.write("2026-02-08T10:30:50.000000+00:00 1 2 INFO New message 1\n")
f.write("2026-02-08T10:30:51.000000+00:00 1 2 INFO New message 2\n")
assert _wait_for(lambda: len(received) >= 2)
page2 = reader.get_logs(limit=3, offset=page1.cursor)
assert page2.entries[0].message == "Message 6"
assert page2.entries[1].message == "Message 5"
assert page2.entries[2].message == "Message 4"
finally:
reader.shutdown()
def test_stop_watching_resets_counter(self, tmp_path: Path) -> None:
log_file = tmp_path / "test.log"
lines = [
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
for i in range(5)
]
log_file.write_text("".join(lines))
received: list[LogEntry] = []
reader = LogReader(log_file=log_file, consumer=received.append)
try:
reader.start_watching()
with log_file.open("a") as f:
f.write("2026-02-08T10:30:50.000000+00:00 1 2 INFO New message\n")
assert _wait_for(lambda: len(received) >= 1)
reader.stop_watching()
result = reader.get_logs(limit=10)
assert len(result.entries) == 6
assert result.entries[0].message == "New message"
finally:
reader.shutdown()

View File

@@ -225,6 +225,18 @@ class TestNuageClientGetChatAssistantUrl:
url = await nuage.get_chat_assistant_url("exec-123")
assert url == "https://chat.example.com/thread/123"
@pytest.mark.asyncio
async def test_get_chat_assistant_url_none(
self, nuage: NuageClient, mock_client: MagicMock
) -> None:
mock_response = MagicMock()
mock_response.is_success = True
mock_response.json.return_value = {"result": {"chat_url": None}}
mock_client.post = AsyncMock(return_value=mock_response)
url = await nuage.get_chat_assistant_url("exec-123")
assert url is None
@pytest.mark.asyncio
async def test_get_chat_assistant_url_failure(
self, nuage: NuageClient, mock_client: MagicMock

View File

@@ -382,6 +382,31 @@ class TestTeleportServiceExecute:
assert isinstance(events[4], TeleportAuthCompleteEvent)
assert isinstance(events[-1], TeleportCompleteEvent)
@pytest.mark.asyncio
async def test_execute_raises_when_chat_url_is_none(
self,
service: TeleportService,
git_info: GitRepoInfo,
mock_github_connected: MagicMock,
) -> None:
service._git.get_info = AsyncMock(return_value=git_info)
service._git.is_commit_pushed = AsyncMock(return_value=True)
mock_nuage = MagicMock()
mock_nuage.start_workflow = AsyncMock(return_value="exec-123")
mock_nuage.get_github_integration = AsyncMock(
return_value=mock_github_connected
)
mock_nuage.get_chat_assistant_url = AsyncMock(return_value=None)
service._nuage = mock_nuage
session = TeleportSession()
gen = service.execute("test prompt", session)
with pytest.raises(ServiceTeleportError, match="not available"):
async for _ in gen:
pass
@pytest.mark.asyncio
async def test_execute_uses_default_prompt_when_none(
self,

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
import pytest
from vibe.core.autocompletion.file_indexer.watcher import WatchController
class TestWatchControllerIsWatching:
@pytest.fixture
def watch_dir(self, tmp_path: Path) -> Path:
return tmp_path / "watch"
@pytest.fixture
def watcher(self) -> Generator[WatchController, None, None]:
changes: list = []
controller = WatchController(on_changes=lambda root, c: changes.extend(c))
yield controller
controller.stop()
def test_is_watching_false_initially(self, watcher: WatchController) -> None:
assert watcher.is_watching is False
def test_is_watching_true_after_start(
self, watcher: WatchController, watch_dir: Path
) -> None:
watch_dir.mkdir()
watcher.start(watch_dir)
assert watcher.is_watching is True
def test_is_watching_false_after_stop(
self, watcher: WatchController, watch_dir: Path
) -> None:
watch_dir.mkdir()
watcher.start(watch_dir)
assert watcher.is_watching is True
watcher.stop()
assert watcher.is_watching is False
def test_is_watching_true_after_restart(
self, watcher: WatchController, watch_dir: Path
) -> None:
watch_dir.mkdir()
watcher.start(watch_dir)
watcher.stop()
assert watcher.is_watching is False
watcher.start(watch_dir)
assert watcher.is_watching is True

View File

@@ -34,7 +34,7 @@ def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[
def _make_read_file() -> ReadFile:
return ReadFile(config=ReadFileToolConfig(), state=ReadFileState())
return ReadFile(config_getter=lambda: ReadFileToolConfig(), state=ReadFileState())
class TestGetResultExtra:

View File

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

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,204 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #9a9b99 }
.terminal-r3 { fill: #868887 }
.terminal-r4 { fill: #68a0b3 }
.terminal-r5 { fill: #d0b344 }
.terminal-r6 { fill: #cc555a }
.terminal-r7 { fill: #cc555a;font-weight: bold }
.terminal-r8 { fill: #ff8205;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">DebugConsoleSnapshotApp</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-r2" x="878.4" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r1" x="902.8" y="20" textLength="183" clip-path="url(#terminal-line-0)">Debug&#160;Console&#160;&#160;</text><text class="terminal-r3" x="1085.8" y="20" textLength="207.4" clip-path="url(#terminal-line-0)">(ctrl+\&#160;to&#160;close)</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r2" x="878.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r2" x="878.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r3" x="902.8" y="68.8" textLength="231.8" clip-path="url(#terminal-line-2)">2026-02-21&#160;10:28:51</text><text class="terminal-r3" x="1146.8" y="68.8" textLength="97.6" clip-path="url(#terminal-line-2)">DEBUG&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="68.8" textLength="207.4" clip-path="url(#terminal-line-2)">&#160;Initializing&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r2" x="878.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="902.8" y="93.2" textLength="170.8" clip-path="url(#terminal-line-3)">model&#160;registry</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r2" x="878.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r3" x="902.8" y="117.6" textLength="231.8" clip-path="url(#terminal-line-4)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="117.6" textLength="97.6" clip-path="url(#terminal-line-4)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="117.6" textLength="207.4" clip-path="url(#terminal-line-4)">&#160;Server&#160;started&#160;&#160;</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r2" x="878.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r1" x="902.8" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">on&#160;port&#160;8080</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r2" x="878.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r3" x="902.8" y="166.4" textLength="231.8" clip-path="url(#terminal-line-6)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="166.4" textLength="97.6" clip-path="url(#terminal-line-6)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="166.4" textLength="207.4" clip-path="url(#terminal-line-6)">&#160;Loading&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r2" x="878.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="902.8" y="190.8" textLength="488" clip-path="url(#terminal-line-7)">configuration&#160;from&#160;/etc/vibe/config.yaml</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r2" x="878.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r3" x="902.8" y="215.2" textLength="231.8" clip-path="url(#terminal-line-8)">2026-02-21&#160;10:28:51</text><text class="terminal-r5" x="1146.8" y="215.2" textLength="97.6" clip-path="url(#terminal-line-8)">WARNING&#160;</text><text class="terminal-r1" x="1244.4" y="215.2" textLength="207.4" clip-path="url(#terminal-line-8)">&#160;Cache&#160;miss&#160;for&#160;&#160;</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r2" x="878.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="902.8" y="239.6" textLength="134.2" clip-path="url(#terminal-line-9)">key&#160;user:42</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="878.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r3" x="902.8" y="264" textLength="231.8" clip-path="url(#terminal-line-10)">2026-02-21&#160;10:28:51</text><text class="terminal-r3" x="1146.8" y="264" textLength="97.6" clip-path="url(#terminal-line-10)">DEBUG&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="264" textLength="207.4" clip-path="url(#terminal-line-10)">&#160;Processing&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="878.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="902.8" y="288.4" textLength="317.2" clip-path="url(#terminal-line-11)">request&#160;GET&#160;/api/v1/models</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="878.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r3" x="902.8" y="312.8" textLength="231.8" clip-path="url(#terminal-line-12)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="312.8" textLength="97.6" clip-path="url(#terminal-line-12)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="312.8" textLength="207.4" clip-path="url(#terminal-line-12)">&#160;Request&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="878.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="902.8" y="337.2" textLength="207.4" clip-path="url(#terminal-line-13)">completed&#160;in&#160;45ms</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="878.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r3" x="902.8" y="361.6" textLength="231.8" clip-path="url(#terminal-line-14)">2026-02-21&#160;10:28:51</text><text class="terminal-r6" x="1146.8" y="361.6" textLength="97.6" clip-path="url(#terminal-line-14)">ERROR&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="361.6" textLength="207.4" clip-path="url(#terminal-line-14)">&#160;Connection&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="878.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="902.8" y="386" textLength="329.4" clip-path="url(#terminal-line-15)">refused&#160;to&#160;upstream&#160;service</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="878.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r3" x="902.8" y="410.4" textLength="231.8" clip-path="url(#terminal-line-16)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="410.4" textLength="97.6" clip-path="url(#terminal-line-16)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="410.4" textLength="207.4" clip-path="url(#terminal-line-16)">&#160;Retrying&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="878.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="902.8" y="434.8" textLength="268.4" clip-path="url(#terminal-line-17)">connection&#160;attempt&#160;1/3</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="878.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r3" x="902.8" y="459.2" textLength="231.8" clip-path="url(#terminal-line-18)">2026-02-21&#160;10:28:51</text><text class="terminal-r5" x="1146.8" y="459.2" textLength="97.6" clip-path="url(#terminal-line-18)">WARNING&#160;</text><text class="terminal-r1" x="1244.4" y="459.2" textLength="207.4" clip-path="url(#terminal-line-18)">&#160;Rate&#160;limit&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r2" x="878.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="902.8" y="483.6" textLength="414.8" clip-path="url(#terminal-line-19)">approaching&#160;for&#160;client&#160;api-key-abc</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r2" x="878.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r3" x="902.8" y="508" textLength="231.8" clip-path="url(#terminal-line-20)">2026-02-21&#160;10:28:52</text><text class="terminal-r4" x="1146.8" y="508" textLength="97.6" clip-path="url(#terminal-line-20)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="508" textLength="207.4" clip-path="url(#terminal-line-20)">&#160;Health&#160;check&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r2" x="878.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="902.8" y="532.4" textLength="73.2" clip-path="url(#terminal-line-21)">passed</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r2" x="878.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r3" x="902.8" y="556.8" textLength="231.8" clip-path="url(#terminal-line-22)">2026-02-21&#160;10:28:53</text><text class="terminal-r7" x="1146.8" y="556.8" textLength="97.6" clip-path="url(#terminal-line-22)">CRITICAL</text><text class="terminal-r1" x="1244.4" y="556.8" textLength="207.4" clip-path="url(#terminal-line-22)">&#160;Out&#160;of&#160;memory&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r2" x="878.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="902.8" y="581.2" textLength="170.8" clip-path="url(#terminal-line-23)">error&#160;detected</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r8" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r4" x="439.2" y="605.6" textLength="183" clip-path="url(#terminal-line-24)">devstral-latest</text><text class="terminal-r1" x="622.2" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r2" x="878.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r2" x="878.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type&#160;</text><text class="terminal-r4" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">&#160;for&#160;more&#160;information</text><text class="terminal-r2" x="878.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r2" x="878.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r2" x="878.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r2" x="878.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r2" x="0" y="752" textLength="890.6" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────&#160;default&#160;─┐│</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r2" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r8" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r2" x="866.2" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">││</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r2" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r2" x="866.2" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">││</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r2" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r2" x="866.2" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">││</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r2" x="0" y="849.6" textLength="890.6" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────┘│</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="671" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text><text class="terminal-r2" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,203 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #9a9b99 }
.terminal-r3 { fill: #868887 }
.terminal-r4 { fill: #68a0b3 }
.terminal-r5 { fill: #d0b344 }
.terminal-r6 { fill: #cc555a }
.terminal-r7 { fill: #ff8205;font-weight: bold }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">DebugConsoleSnapshotApp</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-r2" x="878.4" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r1" x="902.8" y="20" textLength="183" clip-path="url(#terminal-line-0)">Debug&#160;Console&#160;&#160;</text><text class="terminal-r3" x="1085.8" y="20" textLength="207.4" clip-path="url(#terminal-line-0)">(ctrl+\&#160;to&#160;close)</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r2" x="878.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r2" x="878.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r3" x="902.8" y="68.8" textLength="231.8" clip-path="url(#terminal-line-2)">2026-02-21&#160;10:28:51</text><text class="terminal-r3" x="1146.8" y="68.8" textLength="97.6" clip-path="url(#terminal-line-2)">DEBUG&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="68.8" textLength="207.4" clip-path="url(#terminal-line-2)">&#160;Initializing&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r2" x="878.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="902.8" y="93.2" textLength="170.8" clip-path="url(#terminal-line-3)">model&#160;registry</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r2" x="878.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r3" x="902.8" y="117.6" textLength="231.8" clip-path="url(#terminal-line-4)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="117.6" textLength="97.6" clip-path="url(#terminal-line-4)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="117.6" textLength="207.4" clip-path="url(#terminal-line-4)">&#160;Server&#160;started&#160;&#160;</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r2" x="878.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r1" x="902.8" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">on&#160;port&#160;8080</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r2" x="878.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r3" x="902.8" y="166.4" textLength="231.8" clip-path="url(#terminal-line-6)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="166.4" textLength="97.6" clip-path="url(#terminal-line-6)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="166.4" textLength="207.4" clip-path="url(#terminal-line-6)">&#160;Loading&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r2" x="878.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="902.8" y="190.8" textLength="488" clip-path="url(#terminal-line-7)">configuration&#160;from&#160;/etc/vibe/config.yaml</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r2" x="878.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r3" x="902.8" y="215.2" textLength="231.8" clip-path="url(#terminal-line-8)">2026-02-21&#160;10:28:51</text><text class="terminal-r5" x="1146.8" y="215.2" textLength="97.6" clip-path="url(#terminal-line-8)">WARNING&#160;</text><text class="terminal-r1" x="1244.4" y="215.2" textLength="207.4" clip-path="url(#terminal-line-8)">&#160;Cache&#160;miss&#160;for&#160;&#160;</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r2" x="878.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="902.8" y="239.6" textLength="134.2" clip-path="url(#terminal-line-9)">key&#160;user:42</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="878.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r3" x="902.8" y="264" textLength="231.8" clip-path="url(#terminal-line-10)">2026-02-21&#160;10:28:51</text><text class="terminal-r3" x="1146.8" y="264" textLength="97.6" clip-path="url(#terminal-line-10)">DEBUG&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="264" textLength="207.4" clip-path="url(#terminal-line-10)">&#160;Processing&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="878.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="902.8" y="288.4" textLength="317.2" clip-path="url(#terminal-line-11)">request&#160;GET&#160;/api/v1/models</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="878.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r3" x="902.8" y="312.8" textLength="231.8" clip-path="url(#terminal-line-12)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="312.8" textLength="97.6" clip-path="url(#terminal-line-12)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="312.8" textLength="207.4" clip-path="url(#terminal-line-12)">&#160;Request&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="878.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="902.8" y="337.2" textLength="207.4" clip-path="url(#terminal-line-13)">completed&#160;in&#160;45ms</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="878.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r3" x="902.8" y="361.6" textLength="231.8" clip-path="url(#terminal-line-14)">2026-02-21&#160;10:28:51</text><text class="terminal-r6" x="1146.8" y="361.6" textLength="97.6" clip-path="url(#terminal-line-14)">ERROR&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="361.6" textLength="207.4" clip-path="url(#terminal-line-14)">&#160;Connection&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="878.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="902.8" y="386" textLength="329.4" clip-path="url(#terminal-line-15)">refused&#160;to&#160;upstream&#160;service</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="878.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r3" x="902.8" y="410.4" textLength="231.8" clip-path="url(#terminal-line-16)">2026-02-21&#160;10:28:51</text><text class="terminal-r4" x="1146.8" y="410.4" textLength="97.6" clip-path="url(#terminal-line-16)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="410.4" textLength="207.4" clip-path="url(#terminal-line-16)">&#160;Retrying&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="878.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="902.8" y="434.8" textLength="268.4" clip-path="url(#terminal-line-17)">connection&#160;attempt&#160;1/3</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="878.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r3" x="902.8" y="459.2" textLength="231.8" clip-path="url(#terminal-line-18)">2026-02-21&#160;10:28:51</text><text class="terminal-r5" x="1146.8" y="459.2" textLength="97.6" clip-path="url(#terminal-line-18)">WARNING&#160;</text><text class="terminal-r1" x="1244.4" y="459.2" textLength="207.4" clip-path="url(#terminal-line-18)">&#160;Rate&#160;limit&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r2" x="878.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="902.8" y="483.6" textLength="414.8" clip-path="url(#terminal-line-19)">approaching&#160;for&#160;client&#160;api-key-abc</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r2" x="878.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r3" x="902.8" y="508" textLength="231.8" clip-path="url(#terminal-line-20)">2026-02-21&#160;10:28:52</text><text class="terminal-r4" x="1146.8" y="508" textLength="97.6" clip-path="url(#terminal-line-20)">INFO&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1244.4" y="508" textLength="207.4" clip-path="url(#terminal-line-20)">&#160;Health&#160;check&#160;&#160;&#160;&#160;</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r2" x="878.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="902.8" y="532.4" textLength="73.2" clip-path="url(#terminal-line-21)">passed</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r2" x="878.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r2" x="878.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r7" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r4" x="439.2" y="605.6" textLength="183" clip-path="url(#terminal-line-24)">devstral-latest</text><text class="terminal-r1" x="622.2" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r2" x="878.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="414.8" clip-path="url(#terminal-line-25)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r2" x="878.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type&#160;</text><text class="terminal-r4" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)">&#160;for&#160;more&#160;information</text><text class="terminal-r2" x="878.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r2" x="878.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r2" x="878.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r2" x="878.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r2" x="0" y="752" textLength="890.6" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────&#160;default&#160;─┐│</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r2" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r7" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r2" x="866.2" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">││</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r2" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r2" x="866.2" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">││</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r2" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r2" x="866.2" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">││</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r2" x="0" y="849.6" textLength="890.6" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────┘│</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r2" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r2" x="671" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text><text class="terminal-r2" x="878.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -186,7 +186,7 @@
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="605.6" textLength="414.8" clip-path="url(#terminal-line-24)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="630" textLength="61" clip-path="url(#terminal-line-25)">Type&#160;</text><text class="terminal-r3" x="231.8" y="630" textLength="61" clip-path="url(#terminal-line-25)">/help</text><text class="terminal-r1" x="292.8" y="630" textLength="256.2" clip-path="url(#terminal-line-25)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="280.6" clip-path="url(#terminal-line-27)">Configuration&#160;reloaded.</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-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="780.8" clip-path="url(#terminal-line-27)">Configuration&#160;reloaded&#160;(includes&#160;agent&#160;instructions&#160;and&#160;skills).</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)">

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,8 +1,16 @@
from __future__ import annotations
import time
import pytest
@pytest.fixture(autouse=True)
def _pin_timezone(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TZ", "UTC")
time.tzset()
@pytest.fixture(autouse=True)
def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import atexit
from pathlib import Path
import shutil
import tempfile
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from vibe.core.log_reader import LogReader
_SAMPLE_LOGS = "\n".join([
"2026-02-21T10:28:51.100000+00:00 1234 5678 DEBUG Initializing model registry",
"2026-02-21T10:28:51.200000+00:00 1234 5678 INFO Server started on port 8080",
"2026-02-21T10:28:51.300000+00:00 1234 5678 INFO Loading configuration from /etc/vibe/config.yaml",
"2026-02-21T10:28:51.400000+00:00 1234 5678 WARNING Cache miss for key user:42",
"2026-02-21T10:28:51.500000+00:00 1234 5678 DEBUG Processing request GET /api/v1/models",
"2026-02-21T10:28:51.600000+00:00 1234 5678 INFO Request completed in 45ms",
"2026-02-21T10:28:51.700000+00:00 1234 5678 ERROR Connection refused to upstream service",
"2026-02-21T10:28:51.800000+00:00 1234 5678 INFO Retrying connection attempt 1/3",
"2026-02-21T10:28:51.900000+00:00 1234 5678 WARNING Rate limit approaching for client api-key-abc",
"2026-02-21T10:28:52.000000+00:00 1234 5678 INFO Health check passed",
])
_APPENDED_LOG = (
"2026-02-21T10:28:53.000000+00:00 1234 5678 CRITICAL Out of memory error detected"
)
# Module-level temp dir so it's available when import_app instantiates the class
_TMP_DIR = Path(tempfile.mkdtemp())
_LOG_FILE = _TMP_DIR / "test.log"
_LOG_FILE.write_text(_SAMPLE_LOGS + "\n")
atexit.register(shutil.rmtree, str(_TMP_DIR), True)
class DebugConsoleSnapshotApp(BaseSnapshotTestApp):
def __init__(self) -> None:
super().__init__()
self._log_reader = LogReader(log_file=_LOG_FILE, poll_interval=0.1)
def test_snapshot_debug_console_open(snap_compare: SnapCompare) -> None:
"""Test that the debug console opens and shows log entries."""
_LOG_FILE.write_text(_SAMPLE_LOGS + "\n")
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("ctrl+backslash")
await pilot.pause(0.4)
assert snap_compare(
DebugConsoleSnapshotApp(), terminal_size=(120, 36), run_before=run_before
)
def test_snapshot_debug_console_live_append(snap_compare: SnapCompare) -> None:
"""Test that appending a log line to the file shows the new entry."""
_LOG_FILE.write_text(_SAMPLE_LOGS + "\n")
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("ctrl+backslash")
await pilot.pause(0.4)
with _LOG_FILE.open("a") as f:
f.write(_APPENDED_LOG + "\n")
f.flush()
await pilot.pause(0.5)
await pilot.pause()
assert snap_compare(
DebugConsoleSnapshotApp(), terminal_size=(120, 36), run_before=run_before
)
def test_snapshot_debug_console_close(snap_compare: SnapCompare) -> None:
"""Test that closing the debug console restores the normal UI."""
_LOG_FILE.write_text(_SAMPLE_LOGS + "\n")
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("ctrl+backslash")
await pilot.pause(0.4)
await pilot.press("ctrl+backslash")
await pilot.pause(0.2)
assert snap_compare(
DebugConsoleSnapshotApp(), terminal_size=(120, 36), run_before=run_before
)

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
from unittest.mock import patch
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_mcp_registry import (
FakeMCPRegistry,
FakeMCPRegistryWithBrokenServer,
)
from vibe.core.config import MCPHttp, MCPStdio
_MCP_PATCH = "vibe.core.agent_loop.MCPRegistry"
class SnapshotTestAppNoMcpServers(BaseSnapshotTestApp):
def __init__(self) -> None:
super().__init__(config=default_config())
class SnapshotTestAppWithBrokenMcpServer(BaseSnapshotTestApp):
def __init__(self) -> None:
config = default_config()
config.mcp_servers = [
MCPStdio(name="filesystem", transport="stdio", command="npx"),
MCPStdio(
name="broken-server", transport="stdio", command="nonexistent-cmd"
),
MCPHttp(name="search", transport="http", url="http://localhost:8080"),
]
super().__init__(config=config)
class SnapshotTestAppWithMcpServers(BaseSnapshotTestApp):
def __init__(self) -> None:
config = default_config()
config.mcp_servers = [
MCPStdio(name="filesystem", transport="stdio", command="npx"),
MCPHttp(name="search", transport="http", url="http://localhost:8080"),
]
super().__init__(config=config)
async def _run_mcp_command(pilot: Pilot, command: str) -> None:
await pilot.pause(0.1)
await pilot.press(*command)
await pilot.press("enter")
await pilot.pause(0.1)
pilot.app.set_focus(None)
await pilot.pause(0.1)
def test_snapshot_mcp_no_servers(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp")
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppNoMcpServers",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_mcp_broken_server(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp")
with patch(_MCP_PATCH, FakeMCPRegistryWithBrokenServer):
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppWithBrokenMcpServer",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_mcp_overview(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp")
with patch(_MCP_PATCH, FakeMCPRegistry):
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_mcp_overview_navigate_down(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp")
await pilot.press("down")
await pilot.pause(0.1)
with patch(_MCP_PATCH, FakeMCPRegistry):
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_mcp_enter_drills_into_server(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp")
await pilot.press("enter")
await pilot.pause(0.1)
await pilot.press("down")
await pilot.pause(0.1)
await pilot.press("enter")
with patch(_MCP_PATCH, FakeMCPRegistry):
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_mcp_server_arg(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp filesystem")
await pilot.pause(0.1)
with patch(_MCP_PATCH, FakeMCPRegistry):
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_mcp_backspace_returns_to_overview(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp filesystem")
await pilot.press("backspace")
await pilot.pause(0.1)
with patch(_MCP_PATCH, FakeMCPRegistry):
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_mcp_escape_closes(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await _run_mcp_command(pilot, "/mcp")
await pilot.press("escape")
await pilot.pause(0.2)
with patch(_MCP_PATCH, FakeMCPRegistry):
assert snap_compare(
"test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers",
terminal_size=(120, 36),
run_before=run_before,
)

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.tools.mcp.tools import (
RemoteTool,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
)
if TYPE_CHECKING:
from vibe.core.config import MCPServer
from vibe.core.tools.base import BaseTool
_BROKEN_SERVER_NAME = "broken-server"
class FakeMCPRegistry(MCPRegistry):
def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]:
result: dict[str, type[BaseTool]] = {}
for srv in servers:
key = self._server_key(srv)
if key not in self._cache:
remote = RemoteTool(
name="fake_tool", description=f"A fake tool for {srv.name}"
)
match srv.transport:
case "stdio":
proxy = create_mcp_stdio_proxy_tool_class(
command=["fake-cmd"], remote=remote, alias=srv.name
)
case "http" | "streamable-http":
proxy = create_mcp_http_proxy_tool_class(
url="http://fake-mcp-server", remote=remote, alias=srv.name
)
case _:
raise ValueError(
f"FakeMCPRegistry: unsupported transport {srv.transport!r}"
)
self._cache[key] = {proxy.get_name(): proxy}
result.update(self._cache[key])
return result
class FakeMCPRegistryWithBrokenServer(FakeMCPRegistry):
def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]:
working = [s for s in servers if s.name != _BROKEN_SERVER_NAME]
return super().get_tools(working)

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.paths import PLANS_DIR
from vibe.core.tools.base import ToolPermission
class TestPlanAgentWriteFileResolvePermission:
"""Plan agent sets write_file to NEVER with allowlist=[plans/*].
resolve_permission must use this, not the base config.
"""
def test_write_file_to_non_plan_path_denied_in_plan_mode(self) -> None:
config = build_test_vibe_config()
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
tool = agent.tool_manager.get("write_file")
from vibe.core.tools.builtins.write_file import WriteFileArgs
args = WriteFileArgs(path="/some/random/file.py", content="hello")
ctx = tool.resolve_permission(args)
# With plan agent override: permission should be NEVER
# (unless the path matches the plans allowlist)
assert ctx is not None
assert ctx.permission == ToolPermission.NEVER
def test_write_file_to_plan_path_allowed_in_plan_mode(self) -> None:
config = build_test_vibe_config()
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
tool = agent.tool_manager.get("write_file")
from vibe.core.tools.builtins.write_file import WriteFileArgs
plan_path = str(PLANS_DIR.path / "my-plan.md")
args = WriteFileArgs(path=plan_path, content="# Plan")
ctx = tool.resolve_permission(args)
# Plan path is in the allowlist, so should be ALWAYS
assert ctx is not None
assert ctx.permission == ToolPermission.ALWAYS
def test_search_replace_to_non_plan_path_denied_in_plan_mode(self) -> None:
config = build_test_vibe_config()
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
tool = agent.tool_manager.get("search_replace")
from vibe.core.tools.builtins.search_replace import SearchReplaceArgs
args = SearchReplaceArgs(
file_path="/some/file.py", content="<<<< SEARCH\na\n====\nb\n>>>> REPLACE"
)
ctx = tool.resolve_permission(args)
assert ctx is not None
assert ctx.permission == ToolPermission.NEVER
class TestAcceptEditsAgentResolvePermission:
"""Accept-edits agent sets write_file/search_replace to ALWAYS.
resolve_permission must reflect this.
"""
def test_write_file_always_in_accept_edits_mode(self) -> None:
config = build_test_vibe_config()
agent = build_test_agent_loop(
config=config, agent_name=BuiltinAgentName.ACCEPT_EDITS
)
tool = agent.tool_manager.get("write_file")
from vibe.core.tools.builtins.write_file import WriteFileArgs
# Use a workdir-relative path; outside-workdir always requires ASK
# regardless of agent permission.
args = WriteFileArgs(path="file.py", content="hello")
ctx = tool.resolve_permission(args)
# Inside workdir, no allowlist/denylist/sensitive match → None,
# so the caller falls through to config permission (ALWAYS).
assert ctx is None
class TestAgentOverrideNotLeakedAcrossSwitches:
"""Switching agents must change what resolve_permission returns."""
def test_switch_from_plan_to_default_restores_write_permission(self) -> None:
config = build_test_vibe_config()
agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN)
tool = agent.tool_manager.get("write_file")
from vibe.core.tools.builtins.write_file import WriteFileArgs
args = WriteFileArgs(path="/some/file.py", content="hello")
# In plan mode: should be NEVER
ctx_plan = tool.resolve_permission(args)
assert ctx_plan is not None
assert ctx_plan.permission == ToolPermission.NEVER
# Switch to default
agent.agent_manager.switch_profile(BuiltinAgentName.DEFAULT)
# In default mode: should NOT be NEVER
ctx_default = tool.resolve_permission(args)
assert ctx_default is not None
assert ctx_default.permission != ToolPermission.NEVER

View File

@@ -613,8 +613,8 @@ async def test_parallel_tool_calls_with_approval_callback(
@pytest.mark.asyncio
async def test_parallel_approvals_can_run_concurrently() -> None:
"""The core does not serialize approval callbacks — that is a CLI-layer concern.
Parallel tool calls may invoke the approval callback concurrently.
"""Approval callbacks are serialized by _approval_lock so that an 'always allow'
grant from the first call is visible to subsequent parallel calls.
"""
concurrency = 0
max_concurrency = 0
@@ -642,7 +642,7 @@ async def test_parallel_approvals_can_run_concurrently() -> None:
await act_and_collect_events(agent_loop, "Go")
assert max_concurrency > 1
assert max_concurrency == 1
assert agent_loop.stats.tool_calls_agreed == 3
assert agent_loop.stats.tool_calls_succeeded == 3

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from tests import TESTS_ROOT
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agents.manager import AgentManager
@@ -656,3 +658,22 @@ class TestAgentLoopInitialization:
f"System message should contain custom prompt content. "
f"Expected '{custom_prompt_content}' to be in system message."
)
class TestActConsumersUseAclosing:
def test_no_bare_async_for_over_act(self) -> None:
vibe_pkg = TESTS_ROOT.parent / "vibe"
violations: list[str] = []
for path in vibe_pkg.rglob("*.py"):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.AsyncFor):
continue
match node.iter:
case ast.Call(func=ast.Attribute(attr="act")):
violations.append(f"{path}:{node.lineno}")
assert not violations, (
"Bare `async for ... in .act()` found — wrap in "
"contextlib.aclosing(). See issue #569.\n" + "\n".join(violations)
)

View File

@@ -61,14 +61,15 @@ def test_history_manager_filters_invalid_and_duplicated_entries(tmp_path: Path)
assert reloaded.get_previous(current_input="") is None
def test_history_manager_filters_commands(tmp_path: Path) -> None:
def test_history_manager_stores_slash_prefixed_entries(tmp_path: Path) -> None:
history_file = tmp_path / "history.jsonl"
manager = HistoryManager(history_file, max_entries=5)
manager.add("first")
manager.add("/skip")
manager.add("/tool_call arg1 arg2")
reloaded = HistoryManager(history_file)
assert reloaded.get_previous(current_input="") == "/tool_call arg1 arg2"
assert reloaded.get_previous(current_input="") == "first"
assert reloaded.get_previous(current_input="") is None

View File

@@ -0,0 +1,198 @@
from __future__ import annotations
from pathlib import Path
import shlex
import stat
import subprocess
from textwrap import dedent
INSTALL_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "install.sh"
_FAKE_VIBE_SCRIPT = """#!/usr/bin/env bash
exit 0
"""
_FAKE_UV_SCRIPT = """#!/usr/bin/env bash
set -euo pipefail
tool_bin_dir="${UV_TOOL_BIN_DIR:?UV_TOOL_BIN_DIR must be set}"
case "$*" in
"--version")
echo "uv 0.test"
;;
"tool dir --bin")
echo "$tool_bin_dir"
;;
"tool install mistral-vibe"|"tool upgrade mistral-vibe")
mkdir -p "$tool_bin_dir"
cat >"$tool_bin_dir/vibe" <<'VIBE'
#!/usr/bin/env bash
exit 0
VIBE
chmod +x "$tool_bin_dir/vibe"
cat >"$tool_bin_dir/vibe-acp" <<'VIBE_ACP'
#!/usr/bin/env bash
exit 0
VIBE_ACP
chmod +x "$tool_bin_dir/vibe-acp"
;;
*)
echo "unexpected uv invocation: $*" >&2
exit 1
;;
esac
"""
def _write_executable(path: Path, content: str) -> None:
path.write_text(dedent(content))
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
def _write_fake_uv(path: Path) -> None:
_write_executable(path, _FAKE_UV_SCRIPT)
def _write_fake_vibe(path: Path) -> None:
_write_executable(path, _FAKE_VIBE_SCRIPT)
def _write_fake_uv_installer(payload_path: Path) -> None:
payload_path.write_text(
"#!/bin/sh\n"
"set -eu\n"
"\n"
'mkdir -p "$HOME/.local/bin"\n'
"cat >\"$HOME/.local/bin/uv\" <<'UV'\n"
f"{_FAKE_UV_SCRIPT}"
"UV\n"
'chmod +x "$HOME/.local/bin/uv"\n'
)
def _write_fake_curl(path: Path, payload_path: Path) -> None:
_write_executable(
path,
f"""\
#!/usr/bin/env bash
cat {shlex.quote(str(payload_path))}
""",
)
def _run_install_script(
home: Path, path_entries: list[Path], extra_env: dict[str, str]
) -> subprocess.CompletedProcess[str]:
env = {
"HOME": str(home),
"PATH": ":".join([*(str(entry) for entry in path_entries), "/usr/bin", "/bin"]),
"TERM": "dumb",
**extra_env,
}
return subprocess.run(
["bash", str(INSTALL_SCRIPT)],
capture_output=True,
check=False,
cwd=INSTALL_SCRIPT.parent.parent,
env=env,
text=True,
timeout=30,
)
def test_install_reports_missing_path_for_uv_tool_bin(tmp_path: Path) -> None:
home = tmp_path / "home"
fake_bin = tmp_path / "fake-bin"
home.mkdir()
fake_bin.mkdir()
installer_payload = tmp_path / "fake-uv-installer.sh"
_write_fake_uv_installer(installer_payload)
_write_fake_curl(fake_bin / "curl", installer_payload)
uv_bin_dir = home / ".local" / "bin"
result = _run_install_script(home, [fake_bin], {"UV_TOOL_BIN_DIR": str(uv_bin_dir)})
assert result.returncode == 1
assert (
"Your PATH does not include the folder that contains 'vibe'." in result.stderr
)
assert f'export PATH="{uv_bin_dir}:$PATH"' in result.stderr
assert (
result.stderr.count(
"Add this directory to your shell profile, then restart your terminal:"
)
== 1
)
assert (
"uv was installed but not found in PATH for this session" not in result.stdout
)
def test_install_succeeds_when_uv_bin_dir_is_already_on_path(tmp_path: Path) -> None:
home = tmp_path / "home"
fake_bin = tmp_path / "fake-bin"
home.mkdir()
fake_bin.mkdir()
_write_fake_uv(fake_bin / "uv")
result = _run_install_script(home, [fake_bin], {"UV_TOOL_BIN_DIR": str(fake_bin)})
assert result.returncode == 0
assert "Installation completed successfully!" in result.stdout
assert (fake_bin / "vibe").exists()
assert (fake_bin / "vibe-acp").exists()
def test_install_fails_when_vibe_not_in_uv_tool_dir(tmp_path: Path) -> None:
"""Covers the fallback error when uv tool dir doesn't contain a vibe binary."""
home = tmp_path / "home"
fake_bin = tmp_path / "fake-bin"
home.mkdir()
fake_bin.mkdir()
# Create a fake uv that does NOT produce a vibe binary on install
_write_executable(
fake_bin / "uv",
"""\
#!/usr/bin/env bash
set -euo pipefail
tool_bin_dir="${UV_TOOL_BIN_DIR:?UV_TOOL_BIN_DIR must be set}"
case "$*" in
"--version") echo "uv 0.test" ;;
"tool dir --bin") echo "$tool_bin_dir" ;;
"tool install mistral-vibe") mkdir -p "$tool_bin_dir" ;;
*) echo "unexpected: $*" >&2; exit 1 ;;
esac
""",
)
uv_tool_bin = tmp_path / "uv-tools"
uv_tool_bin.mkdir()
result = _run_install_script(
home, [fake_bin], {"UV_TOOL_BIN_DIR": str(uv_tool_bin)}
)
assert result.returncode == 1
assert "uv did not expose a 'vibe' executable" in result.stderr
assert "Your PATH does not include" not in result.stderr
def test_update_succeeds_when_vibe_is_already_on_path(tmp_path: Path) -> None:
home = tmp_path / "home"
fake_bin = tmp_path / "fake-bin"
home.mkdir()
fake_bin.mkdir()
_write_fake_uv(fake_bin / "uv")
_write_fake_vibe(fake_bin / "vibe")
result = _run_install_script(home, [fake_bin], {"UV_TOOL_BIN_DIR": str(fake_bin)})
assert result.returncode == 0
assert "Updating mistral-vibe from GitHub repository using uv..." in result.stdout
assert (
"Installing mistral-vibe from GitHub repository using uv..."
not in result.stdout
)

View File

@@ -17,7 +17,7 @@ from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core import tracing
from vibe.core.config import OtelExporterConfig
from vibe.core.config import OtelSpanExporterConfig
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tracing import agent_span, setup_tracing, tool_span
from vibe.core.types import BaseEvent, FunctionCall, ToolCall
@@ -54,7 +54,7 @@ class TestSetupTracing:
mock_set.assert_not_called()
def test_noop_when_exporter_config_is_none(self) -> None:
config = MagicMock(enable_otel=True, otel_exporter_config=None)
config = MagicMock(enable_otel=True, otel_span_exporter_config=None)
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
setup_tracing(config)
mock_set.assert_not_called()
@@ -62,7 +62,7 @@ class TestSetupTracing:
def test_configures_provider_from_exporter_config(self) -> None:
config = MagicMock(
enable_otel=True,
otel_exporter_config=OtelExporterConfig(
otel_span_exporter_config=OtelSpanExporterConfig(
endpoint="https://customer.mistral.ai/telemetry/v1/traces",
headers={"Authorization": "Bearer sk-test"},
),
@@ -86,7 +86,7 @@ class TestSetupTracing:
def test_custom_endpoint_has_no_auth_headers(self) -> None:
config = MagicMock(
enable_otel=True,
otel_exporter_config=OtelExporterConfig(
otel_span_exporter_config=OtelSpanExporterConfig(
endpoint="https://my-collector:4318/v1/traces"
),
)

View File

@@ -18,7 +18,7 @@ from vibe.core.types import ToolCallEvent, ToolResultEvent
@pytest.fixture
def tool():
config = AskUserQuestionConfig()
return AskUserQuestion(config=config, state=BaseToolState())
return AskUserQuestion(config_getter=lambda: config, state=BaseToolState())
@pytest.fixture

View File

@@ -12,7 +12,7 @@ from vibe.core.tools.permissions import PermissionContext
def bash(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = BashToolConfig()
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
@pytest.mark.asyncio
@@ -39,7 +39,7 @@ async def test_fails_cat_command_with_missing_file(bash):
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = BashToolConfig()
bash_tool = Bash(config=config, state=BaseToolState())
bash_tool = Bash(config_getter=lambda: config, state=BaseToolState())
result = await collect_result(bash_tool.run(BashArgs(command="pwd")))
@@ -57,7 +57,7 @@ async def test_handles_timeout(bash):
@pytest.mark.asyncio
async def test_truncates_output_to_max_bytes(bash):
config = BashToolConfig(max_output_bytes=5)
bash_tool = Bash(config=config, state=BaseToolState())
bash_tool = Bash(config_getter=lambda: config, state=BaseToolState())
result = await collect_result(
bash_tool.run(BashArgs(command="printf 'abcdefghij'"))
@@ -78,7 +78,7 @@ async def test_decodes_non_utf8_bytes(bash):
def test_find_not_in_default_allowlist():
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
bash_tool = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
# find -exec runs arbitrary commands; must not be allowlisted by default
permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
assert (
@@ -89,7 +89,7 @@ def test_find_not_in_default_allowlist():
def test_resolve_permission():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState())
bash_tool = Bash(config_getter=lambda: config, state=BaseToolState())
allowlisted = bash_tool.resolve_permission(BashArgs(command="echo hi"))
denylisted = bash_tool.resolve_permission(BashArgs(command="rm -rf /tmp"))
@@ -111,7 +111,7 @@ class TestResolvePermissionWindowsSyntax:
def _make_bash(self, **kwargs) -> Bash:
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
def test_dir_with_windows_flags_allowlisted(self):
bash_tool = self._make_bash(allowlist=["dir"])
@@ -215,7 +215,7 @@ class TestDenylistWordBoundary:
def _make_bash(self, **kwargs) -> Bash:
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
def test_vi_blocks_vi_exact(self):
bash_tool = self._make_bash(denylist=["vi"])

View File

@@ -56,7 +56,9 @@ def _default_profile() -> AgentProfile:
@pytest.fixture
def tool() -> ExitPlanMode:
return ExitPlanMode(config=ExitPlanModeConfig(), state=BaseToolState())
return ExitPlanMode(
config_getter=lambda: ExitPlanModeConfig(), state=BaseToolState()
)
@pytest.fixture

View File

@@ -46,7 +46,7 @@ class TestBashGranularPermissions:
def _bash(self, **kwargs):
config = BashToolConfig(**kwargs)
return Bash(config=config, state=BaseToolState())
return Bash(config_getter=lambda: config, state=BaseToolState())
def test_allowlisted_command_always(self):
bash = self._bash()
@@ -264,7 +264,7 @@ class TestReadFileGranularPermissions:
def _read_file(self, **kwargs):
config = ReadFileToolConfig(**kwargs)
return ReadFile(config=config, state=ReadFileState())
return ReadFile(config_getter=lambda: config, state=ReadFileState())
def test_in_workdir_normal_file_returns_none(self):
(self.workdir / "test.py").touch()
@@ -346,7 +346,7 @@ class TestWriteFileGranularPermissions:
def _write_file(self):
config = WriteFileConfig()
return WriteFile(config=config, state=BaseToolState())
return WriteFile(config_getter=lambda: config, state=BaseToolState())
def test_in_workdir_returns_none(self):
tool = self._write_file()
@@ -379,7 +379,7 @@ class TestSearchReplaceGranularPermissions:
def test_outside_workdir_returns_permission_context(self):
config = SearchReplaceConfig()
tool = SearchReplace(config=config, state=BaseToolState())
tool = SearchReplace(config_getter=lambda: config, state=BaseToolState())
result = tool.resolve_permission(
SearchReplaceArgs(file_path="/tmp/file.py", content="x")
)
@@ -395,7 +395,7 @@ class TestGrepGranularPermissions:
def _grep(self):
config = GrepToolConfig()
return Grep(config=config, state=BaseToolState())
return Grep(config_getter=lambda: config, state=BaseToolState())
def test_in_workdir_normal_path_returns_none(self):
tool = self._grep()
@@ -442,7 +442,7 @@ class TestApprovalFlowSimulation:
session_pattern="mkdir *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="mkdir another_dir"))
assert isinstance(result, PermissionContext)
uncovered = [
@@ -460,7 +460,7 @@ class TestApprovalFlowSimulation:
session_pattern="mkdir *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="npm install"))
assert isinstance(result, PermissionContext)
uncovered = [
@@ -472,7 +472,7 @@ class TestApprovalFlowSimulation:
assert uncovered[0].session_pattern == "npm install *"
def test_outside_dir_approved_covers_subsequent(self):
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/newdir"))
assert isinstance(result, PermissionContext)
outside_rps = [
@@ -509,7 +509,7 @@ class TestApprovalFlowSimulation:
session_pattern="rm *",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something"))
assert isinstance(result, PermissionContext)
cmd_perms = [
@@ -529,7 +529,7 @@ class TestApprovalFlowSimulation:
session_pattern="sudo apt install foo",
)
]
bash = Bash(config=BashToolConfig(), state=BaseToolState())
bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState())
result = bash.resolve_permission(BashArgs(command="sudo apt install bar"))
assert isinstance(result, PermissionContext)
cmd_perms = [
@@ -575,7 +575,7 @@ class TestApprovalFlowSimulation:
class TestWebFetchPermissions:
def _make_webfetch(self) -> WebFetch:
return WebFetch(config=WebFetchConfig(), state=BaseToolState())
return WebFetch(config_getter=lambda: WebFetchConfig(), state=BaseToolState())
def test_returns_url_pattern_with_domain(self):
wf = self._make_webfetch()
@@ -671,7 +671,7 @@ class TestWebFetchPermissions:
def test_config_permission_always_honored(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.ALWAYS),
config_getter=lambda: WebFetchConfig(permission=ToolPermission.ALWAYS),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
@@ -680,7 +680,7 @@ class TestWebFetchPermissions:
def test_config_permission_never_honored(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.NEVER),
config_getter=lambda: WebFetchConfig(permission=ToolPermission.NEVER),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
@@ -689,7 +689,8 @@ class TestWebFetchPermissions:
def test_config_permission_ask_falls_through_to_domain(self):
wf = WebFetch(
config=WebFetchConfig(permission=ToolPermission.ASK), state=BaseToolState()
config_getter=lambda: WebFetchConfig(permission=ToolPermission.ASK),
state=BaseToolState(),
)
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
assert isinstance(result, PermissionContext)

View File

@@ -13,7 +13,7 @@ from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolC
def grep(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
return Grep(config=config, state=BaseToolState())
return Grep(config_getter=lambda: config, state=BaseToolState())
@pytest.fixture
@@ -28,7 +28,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
monkeypatch.setattr("shutil.which", mock_which)
config = GrepToolConfig()
return Grep(config=config, state=BaseToolState())
return Grep(config_getter=lambda: config, state=BaseToolState())
def test_detects_ripgrep_when_available(grep):
@@ -137,7 +137,7 @@ async def test_truncates_to_max_matches(grep, tmp_path):
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig(max_output_bytes=100)
grep_tool = Grep(config=config, state=BaseToolState())
grep_tool = Grep(config_getter=lambda: config, state=BaseToolState())
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
@@ -189,7 +189,7 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
grep_tool = Grep(config=config, state=BaseToolState())
grep_tool = Grep(config_getter=lambda: config, state=BaseToolState())
(tmp_path / "test.py").write_text("match\n")
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))

View File

@@ -36,7 +36,7 @@ class SimpleTool(BaseTool[SimpleArgs, SimpleResult, BaseToolConfig, BaseToolStat
@pytest.fixture
def simple_tool() -> SimpleTool:
return SimpleTool(config=BaseToolConfig(), state=BaseToolState())
return SimpleTool(config_getter=lambda: BaseToolConfig(), state=BaseToolState())
class TestInvokeContext:

View File

@@ -54,7 +54,7 @@ def _make_ctx(skill_manager: SkillManager | None = None) -> InvokeContext:
@pytest.fixture
def skill_tool() -> Skill:
return Skill(config=SkillToolConfig(), state=BaseToolState())
return Skill(config_getter=lambda: SkillToolConfig(), state=BaseToolState())
class TestSkillRun:

View File

@@ -16,7 +16,7 @@ from vibe.core.types import AssistantEvent, LLMMessage, Role
@pytest.fixture
def task_tool() -> Task:
return Task(config=TaskToolConfig(), state=BaseToolState())
return Task(config_getter=lambda: TaskToolConfig(), state=BaseToolState())
class TestTaskArgs:
@@ -91,7 +91,7 @@ class TestTaskToolResolvePermission:
def test_denylist_takes_precedence(self) -> None:
config = TaskToolConfig(allowlist=["explore"], denylist=["explore"])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert isinstance(result, PermissionContext)
@@ -99,7 +99,7 @@ class TestTaskToolResolvePermission:
def test_glob_pattern_in_allowlist(self) -> None:
config = TaskToolConfig(allowlist=["exp*"])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert isinstance(result, PermissionContext)
@@ -107,7 +107,7 @@ class TestTaskToolResolvePermission:
def test_glob_pattern_in_denylist(self) -> None:
config = TaskToolConfig(denylist=["danger*"])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="dangerous_agent")
result = tool.resolve_permission(args)
assert isinstance(result, PermissionContext)
@@ -115,7 +115,7 @@ class TestTaskToolResolvePermission:
def test_empty_lists_returns_none(self) -> None:
config = TaskToolConfig(allowlist=[], denylist=[])
tool = Task(config=config, state=BaseToolState())
tool = Task(config_getter=lambda: config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert result is None

View File

@@ -5,9 +5,13 @@ import time
import pytest
from textual.widgets import Static
from tests.conftest import build_test_agent_loop, build_test_vibe_app
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage, ErrorMessage
from vibe.core.types import Role
async def _wait_for_bash_output_message(
@@ -118,3 +122,38 @@ async def test_ui_handles_non_utf8_stderr(vibe_app: VibeApp) -> None:
output_widget = message.query_one(".bash-output", Static)
assert str(output_widget.render()) == "<EFBFBD><EFBFBD>"
assert_no_command_error(vibe_app)
@pytest.mark.asyncio
async def test_ui_sends_manual_command_output_to_next_agent_turn() -> None:
backend = FakeBackend(mock_llm_chunk(content="I saw it."))
vibe_app = build_test_vibe_app(agent_loop=build_test_agent_loop(backend=backend))
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!echo hello"
await pilot.press("enter")
await _wait_for_bash_output_message(vibe_app, pilot)
injected_message = vibe_app.agent_loop.messages[-1]
assert injected_message.role == Role.user
assert injected_message.injected is True
assert injected_message.content is not None
assert "Manual `!` command result from the user." in injected_message.content
assert "Command: `echo hello`" in injected_message.content
assert "Exit code: 0" in injected_message.content
assert "Stdout:\n```text\nhello\n```" in injected_message.content
chat_input.value = "what did the command print?"
await pilot.press("enter")
await pilot.app.workers.wait_for_complete()
assert len(backend.requests_messages) == 1
user_messages = [
msg for msg in backend.requests_messages[0] if msg.role == Role.user
]
assert len(user_messages) >= 2
assert user_messages[-2].content == injected_message.content
assert user_messages[-2].injected is True
assert user_messages[-1].content == "what did the command print?"

View File

@@ -12,13 +12,13 @@ from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchCo
@pytest.fixture
def webfetch():
config = WebFetchConfig()
return WebFetch(config=config, state=BaseToolState())
return WebFetch(config_getter=lambda: config, state=BaseToolState())
@pytest.fixture
def webfetch_small():
config = WebFetchConfig(max_content_bytes=100)
return WebFetch(config=config, state=BaseToolState())
return WebFetch(config_getter=lambda: config, state=BaseToolState())
@pytest.mark.asyncio
@@ -32,6 +32,7 @@ async def test_bare_domain_gets_https(webfetch):
result = await collect_result(webfetch.run(WebFetchArgs(url="example.com")))
assert result.url == "https://example.com"
assert result.content == "ok"
assert result.was_truncated is False
@pytest.mark.asyncio
@@ -167,6 +168,7 @@ async def test_truncates_to_max_bytes_with_disclaimer(webfetch_small):
)
assert result.content.startswith("a" * 100)
assert "[Content truncated due to size limit]" in result.content
assert result.was_truncated is True
@pytest.mark.asyncio
@@ -189,6 +191,7 @@ async def test_truncates_html_with_disclaimer(webfetch_small):
assert "## first title" in result.content
assert "## second title" not in result.content
assert "[Content truncated due to size limit]" in result.content
assert result.was_truncated is True
@pytest.mark.asyncio

View File

@@ -38,7 +38,7 @@ def _make_response(
def websearch(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
config = WebSearchConfig()
return WebSearch(config=config, state=BaseToolState())
return WebSearch(config_getter=lambda: config, state=BaseToolState())
def test_parse_text_chunks(websearch):
@@ -104,7 +104,7 @@ def test_parse_skips_non_message_entries(websearch):
async def test_run_missing_api_key(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
config = WebSearchConfig()
ws = WebSearch(config=config, state=BaseToolState())
ws = WebSearch(config_getter=lambda: config, state=BaseToolState())
with pytest.raises(ToolError, match="MISTRAL_API_KEY"):
await collect_result(ws.run(WebSearchArgs(query="test")))

46
uv.lock generated
View File

@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2026-03-27T13:28:45.258057Z"
exclude-newer = "2026-04-02T14:58:56.644057Z"
exclude-newer-span = "P7D"
[options.exclude-newer-package]
@@ -787,7 +787,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.7.3"
version = "2.7.4"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@@ -834,6 +834,7 @@ build = [
dev = [
{ name = "debugpy" },
{ name = "pre-commit" },
{ name = "pyinstrument" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -893,6 +894,7 @@ build = [
dev = [
{ name = "debugpy", specifier = ">=1.8.19" },
{ name = "pre-commit", specifier = ">=4.2.0" },
{ name = "pyinstrument", specifier = ">=5.1.2" },
{ name = "pyright", specifier = ">=1.1.403" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
@@ -1327,6 +1329,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/3b/1efef5ff4d4d150f646b873e963437c0b800cb375a37df01fefab149f4d9/pyinstaller_hooks_contrib-2026.2-py3-none-any.whl", hash = "sha256:fc29f0481b58adf78ce9c1d9cf135fe96f38c708f74b2aa0670ef93e59578ab9", size = 453939, upload-time = "2026-03-02T23:06:59.469Z" },
]
[[package]]
name = "pyinstrument"
version = "5.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889, upload-time = "2026-01-04T18:38:58.464Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/d9/8fa5571ddd21b2b7189bd8b0bb4e90be1659a54dda5af51c7f6bf2b5666f/pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2519865d4bf58936f2506c1c46a82d29a20f3239aa50c941df1ca9618c7da5f0", size = 131419, upload-time = "2026-01-04T18:37:46.843Z" },
{ url = "https://files.pythonhosted.org/packages/6f/50/0512adb83cadfeaa1a215dc9784defff5043c5aa052d15015e3d8013af75/pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:059442106b8b5de29ae5ac1bdc20d044fed4da534b8caba434b6ffb119037bf5", size = 124446, upload-time = "2026-01-04T18:37:48.572Z" },
{ url = "https://files.pythonhosted.org/packages/9b/78/c45f0b668fb3c8c0d32058a451a8e1d34737cd7586387982185e12df1977/pyinstrument-5.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd51f2d54fc39a4cfd73ba6be27cd0187123132ce3f445b639bff5e1b23d7e26", size = 149694, upload-time = "2026-01-04T18:37:49.876Z" },
{ url = "https://files.pythonhosted.org/packages/91/4d/2ca3ca9906ce6e05070f431c54d54ccbaf57a980cfa58032d35b0b0ac1f8/pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12af1e83795b6c640d657d339014dd1ff718b182dec736d7d1f1d8a97534eb53", size = 148461, upload-time = "2026-01-04T18:37:51.544Z" },
{ url = "https://files.pythonhosted.org/packages/18/d2/bfe84a4326172ef68655b65b49fd041eeb94c8e59ee47258589b8b79dd3b/pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2565513658e742c5eb691a779cb29d19d01bc9ee951d0eb76482e9f343c38c2e", size = 148560, upload-time = "2026-01-04T18:37:52.931Z" },
{ url = "https://files.pythonhosted.org/packages/d0/00/db7f5def351e869230b0165828c4edacbf3fdda8d66aff30dd73a62082c2/pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5afd0ba788a1d112da49fb77966918e01df1f9e7d62e72894d82f7acb0996c2d", size = 148178, upload-time = "2026-01-04T18:37:54.278Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bc/aea3329576e20b987d205027b8e6442ece845d681b9f9d8682d5404f81f3/pyinstrument-5.1.2-cp312-cp312-win32.whl", hash = "sha256:554077b031b278593cb2301f0057be771ea62a729878c69aaf29fcdfb7b71281", size = 125927, upload-time = "2026-01-04T18:37:55.615Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/d928434ec3a840478e95fd0d73b0dfc0b8060a07b06f4b45e9df30444e9a/pyinstrument-5.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:55a905384ba43efc924b8863aa6cfd276f029e4aa70c4a0e3b7389e27b191e45", size = 126675, upload-time = "2026-01-04T18:37:57.278Z" },
{ url = "https://files.pythonhosted.org/packages/b4/8e/b9aea969eec67c129652000446384d550a0df45c297adc9fd74da2f8482c/pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29", size = 131418, upload-time = "2026-01-04T18:37:58.642Z" },
{ url = "https://files.pythonhosted.org/packages/8f/62/76418eb29b5591f3e5500369a6777ce928135c3aa6ccdb0c861a9c6ca93b/pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3", size = 124448, upload-time = "2026-01-04T18:37:59.95Z" },
{ url = "https://files.pythonhosted.org/packages/07/73/874bccc04bcf6f4babc3de1a9568e209e7e40998563974f5030b0fb4d3e0/pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a", size = 149853, upload-time = "2026-01-04T18:38:01.405Z" },
{ url = "https://files.pythonhosted.org/packages/cf/85/268446c4388d77ff4abdeaff202356e1527b3ff9576f5587443a24980bec/pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0", size = 148641, upload-time = "2026-01-04T18:38:03.086Z" },
{ url = "https://files.pythonhosted.org/packages/fc/15/4f8dea3381483e68d00582a9b823a21a088acfe77a847a7991a1a8feed76/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5", size = 148674, upload-time = "2026-01-04T18:38:04.805Z" },
{ url = "https://files.pythonhosted.org/packages/fa/61/72c180454b6511d5b90166f8828e1bab3b45d0489952a1fe48c5c585233d/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e", size = 148315, upload-time = "2026-01-04T18:38:06.114Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f0/4c27cebddf22a8840bd8b419366bb321ce41f921ca1893e309c932ab28bf/pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0", size = 125926, upload-time = "2026-01-04T18:38:07.507Z" },
{ url = "https://files.pythonhosted.org/packages/6c/20/6b1bee88ddef065b0df3a3ba4ba60ed8a9ca443d5cded7152a8a9750914f/pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b", size = 126678, upload-time = "2026-01-04T18:38:08.826Z" },
{ url = "https://files.pythonhosted.org/packages/66/0f/7d5154c92904bdf25be067a7fe4cad4ba48919f16ccbb51bb953d9ae1a20/pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e", size = 131388, upload-time = "2026-01-04T18:38:10.491Z" },
{ url = "https://files.pythonhosted.org/packages/17/28/bf83231a3f951e11b4dfaf160e1eeba1ce29377eab30e3d2eb6ee22ff3ba/pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568", size = 124456, upload-time = "2026-01-04T18:38:11.792Z" },
{ url = "https://files.pythonhosted.org/packages/ac/98/762cf10896d907268629e1db08a48f128984a53e8d92b99ea96f862597e5/pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4", size = 149594, upload-time = "2026-01-04T18:38:13.434Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1b/48580e16e623d89af58b89c552c95a2ae65f70a1f4fab1d97879f34791db/pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5", size = 148339, upload-time = "2026-01-04T18:38:14.767Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/38157a8a6ec67789d8ee109fd09877ea3340df44e1a7add8f249e30a8ade/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f", size = 148485, upload-time = "2026-01-04T18:38:16.121Z" },
{ url = "https://files.pythonhosted.org/packages/4b/34/31ee72b19cfc48a82801024b5d653f07982154a11381a3ae65bbfdbf2c7b/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6", size = 148106, upload-time = "2026-01-04T18:38:17.623Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b4/7ab20243187262d66ab062778b1ccac4ca55090752f32a83f603f4e5e3a2/pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b", size = 126593, upload-time = "2026-01-04T18:38:18.968Z" },
{ url = "https://files.pythonhosted.org/packages/9e/a0/db6a8ae3182546227f5a043b1be29b8d5f98bf973e20d922981ef206de85/pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e", size = 127358, upload-time = "2026-01-04T18:38:20.28Z" },
{ url = "https://files.pythonhosted.org/packages/59/d2/719f439972b3f80e35fb5b1bcd888c3218d60dbc91957b99ffafd7ac9221/pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2", size = 132317, upload-time = "2026-01-04T18:38:21.669Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1c/0ebfef69ae926665fae635424c5647411235c3689c9a9ad69fd68de6cae2/pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d", size = 124917, upload-time = "2026-01-04T18:38:23.385Z" },
{ url = "https://files.pythonhosted.org/packages/a6/ee/5599f769f515a0f1c97443edc7394fe2b9829bf39f404c046499c1a62378/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee", size = 157407, upload-time = "2026-01-04T18:38:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/fd/40/32aa865252288caef301237488ee309bd6701125888bf453d23ab764e357/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d", size = 155068, upload-time = "2026-01-04T18:38:26.112Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/0b56a1540fe1c357dfcda82d4f5b52c87fada5962cbf18703ea39ccbbe69/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee", size = 155186, upload-time = "2026-01-04T18:38:27.914Z" },
{ url = "https://files.pythonhosted.org/packages/7a/48/7ef84abfc3e41148cf993095214f104e75ecff585e94c6e8be001e672573/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6", size = 153979, upload-time = "2026-01-04T18:38:29.236Z" },
{ url = "https://files.pythonhosted.org/packages/8f/cf/a28ad117d58b33c1d74bcdfbbcf1603b67346883800ac7d510cff8d3bcee/pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8", size = 127267, upload-time = "2026-01-04T18:38:30.619Z" },
{ url = "https://files.pythonhosted.org/packages/8e/97/03635143a12a5d941f545548b00f8ac39d35565321a2effb4154ed267338/pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db", size = 128164, upload-time = "2026-01-04T18:38:32.281Z" },
]
[[package]]
name = "pyjwt"
version = "2.11.0"

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from contextlib import aclosing
import os
from pathlib import Path
import sys
@@ -445,14 +446,14 @@ class VibeAcpAgentLoop(AcpAgent):
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
)
if text_update := create_assistant_message_replay(msg):
await self.client.session_update(
session_id=session_id, update=text_update
)
await self._replay_tool_calls(session_id, msg)
elif msg.role == Role.tool:
@@ -885,61 +886,64 @@ class VibeAcpAgentLoop(AcpAgent):
) -> AsyncGenerator[SessionUpdate]:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
async for event in session.agent_loop.act(
rendered_prompt, client_message_id=client_message_id
):
if isinstance(event, AssistantEvent):
yield AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
elif isinstance(event, ReasoningEvent):
yield AgentThoughtChunk(
session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
elif isinstance(event, ToolCallEvent):
if issubclass(event.tool_class, BaseAcpTool):
event.tool_class.update_tool_state(
tool_manager=session.agent_loop.tool_manager,
client=self.client,
session_id=session.id,
tool_call_id=event.tool_call_id,
async with aclosing(
session.agent_loop.act(rendered_prompt, client_message_id=client_message_id)
) as events:
async for event in events:
if isinstance(event, AssistantEvent):
yield AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
session_update = tool_call_session_update(event)
if session_update:
yield session_update
elif isinstance(event, ReasoningEvent):
yield AgentThoughtChunk(
session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
elif isinstance(event, ToolResultEvent):
session_update = tool_result_session_update(event)
if session_update:
yield session_update
elif isinstance(event, ToolStreamEvent):
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(type="text", text=event.message),
elif isinstance(event, ToolCallEvent):
if issubclass(event.tool_class, BaseAcpTool):
event.tool_class.update_tool_state(
tool_manager=session.agent_loop.tool_manager,
client=self.client,
session_id=session.id,
tool_call_id=event.tool_call_id,
)
],
)
elif isinstance(event, CompactStartEvent):
yield create_compact_start_session_update(event)
session_update = tool_call_session_update(event)
if session_update:
yield session_update
elif isinstance(event, CompactEndEvent):
yield create_compact_end_session_update(event)
elif isinstance(event, ToolResultEvent):
session_update = tool_result_session_update(event)
if session_update:
yield session_update
elif isinstance(event, AgentProfileChangedEvent):
pass
elif isinstance(event, ToolStreamEvent):
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(
type="text", text=event.message
),
)
],
)
elif isinstance(event, CompactStartEvent):
yield create_compact_start_session_update(event)
elif isinstance(event, CompactEndEvent):
yield create_compact_end_session_update(event)
elif isinstance(event, AgentProfileChangedEvent):
pass
@override
async def close_session(

View File

@@ -8,7 +8,7 @@ import sys
import tomli_w
from vibe import __version__
from vibe.core.config import MissingAPIKeyError, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.config.harness_files import (
get_harness_files_manager,
init_harness_files_manager,
@@ -92,8 +92,8 @@ def main() -> None:
try:
config = VibeConfig.load()
setup_tracing(config)
except MissingAPIKeyError:
pass # tracing disabled, but server can still handle the error properly in new_session
except Exception:
pass # tracing disabled
run_acp_server()

View File

@@ -9,8 +9,6 @@ import subprocess
import pyperclip
from textual.app import App
_PREVIEW_MAX_LENGTH = 40
def _copy_osc52(text: str) -> None:
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
@@ -117,13 +115,6 @@ def _copy_to_clipboard(text: str) -> None:
raise RuntimeError("All clipboard strategies failed")
def _shorten_preview(texts: list[str]) -> str:
dense_text = "".join(texts).replace("\n", "")
if len(dense_text) > _PREVIEW_MAX_LENGTH:
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}"
return dense_text
def _get_selected_texts(app: App) -> list[str]:
selected_texts = []
@@ -156,7 +147,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None
_copy_to_clipboard(combined_text)
if show_toast:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
"Selection copied to clipboard",
severity="information",
timeout=2,
markup=False,

View File

@@ -36,7 +36,7 @@ class CommandRegistry:
),
"reload": Command(
aliases=frozenset(["/reload"]),
description="Reload configuration from disk",
description="Reload configuration, agent instructions, and skills from disk",
handler="_reload_config",
),
"clear": Command(
@@ -49,6 +49,11 @@ class CommandRegistry:
description="Show path to current interaction log file",
handler="_show_log_path",
),
"debug": Command(
aliases=frozenset(["/debug"]),
description="Toggle debug console",
handler="action_toggle_debug_console",
),
"compact": Command(
aliases=frozenset(["/compact"]),
description="Compact conversation history by summarizing",
@@ -85,6 +90,11 @@ class CommandRegistry:
description="Browse and resume past sessions",
handler="_show_session_picker",
),
"mcp": Command(
aliases=frozenset(["/mcp"]),
description="Display available MCP servers. Pass the name of a server to list its tools",
handler="_show_mcp",
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Configure voice settings",
@@ -120,13 +130,23 @@ class CommandRegistry:
for alias in cmd.aliases:
self._alias_map[alias] = cmd_name
def find_command(self, user_input: str) -> Command | None:
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 parse_command(self, user_input: str) -> tuple[str, Command, str] | None:
parts = user_input.strip().split(None, 1)
if not parts:
return None
cmd_word = parts[0]
cmd_args = parts[1] if len(parts) > 1 else ""
cmd_name = self.get_command_name(cmd_word)
if cmd_name is None:
return None
command = self.commands[cmd_name]
return cmd_name, command, cmd_args
def get_help_text(self) -> str:
lines: list[str] = [
"### Keyboard Shortcuts",

View File

@@ -44,7 +44,7 @@ class HistoryManager:
def add(self, text: str) -> None:
text = text.strip()
if not text or text.startswith("/"):
if not text:
return
if self._entries and self._entries[-1] == text:

81
vibe/cli/profiler.py Normal file
View File

@@ -0,0 +1,81 @@
"""General-purpose profiler for measuring any section of the application.
Wraps pyinstrument (dev-only dependency). Silently no-ops when not installed.
Activated by the VIBE_PROFILE=1 environment variable.
Usage:
from vibe.cli import profiler
profiler.start("startup")
# ... code to profile ...
profiler.stop_and_print()
"""
from __future__ import annotations
import dataclasses
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pyinstrument import Profiler
@dataclasses.dataclass
class _State:
profiler: Profiler | None = None
label: str = "default"
_state = _State()
def is_enabled() -> bool:
"""Return True when profiling is activated via environment variable."""
return bool(os.environ.get("VIBE_PROFILE"))
def start(label: str = "default") -> None:
"""Start profiling. The label is used to name the output file.
No-op if pyinstrument is missing or env var unset.
"""
if not is_enabled():
return
try:
from pyinstrument import Profiler
except ImportError:
return
if _state.profiler is not None:
import warnings
warnings.warn(
"Profiler already running; stop it before starting a new one.", stacklevel=2
)
return
_state.label = label
_state.profiler = Profiler()
_state.profiler.start()
def stop_and_print() -> None:
"""Stop profiling, write an HTML report, and print a text summary to stderr."""
if _state.profiler is None:
return
_state.profiler.stop()
from pathlib import Path
import sys
output_path = Path(f"{_state.label}-profile.html")
output_path.write_text(_state.profiler.output_html(), encoding="utf-8")
print(
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
file=sys.stderr,
)
print(_state.profiler.output_text(color=True), file=sys.stderr)
_state.profiler = None
_state.label = "default"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from contextlib import aclosing
from dataclasses import dataclass
from enum import StrEnum, auto
import gc
@@ -56,9 +57,11 @@ from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.debug_console import DebugConsole
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp
from vibe.cli.textual_ui.widgets.messages import (
BashOutputMessage,
ErrorMessage,
@@ -112,6 +115,7 @@ from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.log_reader import LogReader
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
@@ -171,6 +175,7 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
Input = auto()
MCP = auto()
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
@@ -184,7 +189,7 @@ class ChatScroll(VerticalScroll):
@property
def is_at_bottom(self) -> bool:
return self.scroll_target_y >= (self.max_scroll_y - 3)
return self.scroll_target_y >= self.max_scroll_y
_reanchor_pending: bool = False
_scrolling_down: bool = False
@@ -281,6 +286,7 @@ class VibeApp(App): # noqa: PLR0904
Binding(
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
),
Binding("ctrl+backslash", "toggle_debug_console", "Debug Console", show=False),
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("alt+down", "rewind_next", "Rewind Next", show=False, priority=True),
@@ -354,6 +360,8 @@ class VibeApp(App): # noqa: PLR0904
self._cached_messages_area: Widget | None = None
self._cached_chat: ChatScroll | None = None
self._cached_loading_area: Widget | None = None
self._log_reader = LogReader()
self._debug_console: DebugConsole | None = None
self._switch_agent_generation = 0
self._plan_info: PlanInfo | None = None
self._narrator_manager: NarratorManagerPort = (
@@ -663,6 +671,10 @@ class VibeApp(App): # noqa: PLR0904
) -> None:
await self._switch_to_input_app()
async def on_mcpapp_mcpclosed(self, _message: MCPApp.MCPClosed) -> None:
await self._mount_and_scroll(UserCommandMessage("MCP servers closed."))
await self._switch_to_input_app()
async def on_proxy_setup_app_proxy_setup_closed(
self, message: ProxySetupApp.ProxySetupClosed
) -> None:
@@ -700,17 +712,17 @@ class VibeApp(App): # noqa: PLR0904
await widget.remove()
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"
)
if resolved := self.commands.parse_command(user_input):
cmd_name, command, cmd_args = resolved
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):
await handler()
await handler(cmd_args=cmd_args)
else:
handler()
handler(cmd_args=cmd_args)
return True
return False
@@ -781,17 +793,103 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(
BashOutputMessage(command, str(Path.cwd()), output, exit_code)
)
except subprocess.TimeoutExpired:
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
)
)
except subprocess.TimeoutExpired as error:
stdout = (
error.stdout.decode("utf-8", errors="replace")
if isinstance(error.stdout, bytes)
else (error.stdout or "")
)
stderr = (
error.stderr.decode("utf-8", errors="replace")
if isinstance(error.stderr, bytes)
else (error.stderr or "")
)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status="timed out after 30 seconds",
)
)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
status=f"failed before completion: {e}",
)
)
def _get_bash_max_output_bytes(self) -> int:
from vibe.core.tools.builtins.bash import BashToolConfig
config = self.agent_loop.tool_manager.get_tool_config("bash")
if isinstance(config, BashToolConfig):
return config.max_output_bytes
return BashToolConfig().max_output_bytes
@staticmethod
def _cap_output(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit] + "\n... [truncated]"
def _format_manual_command_context(
self,
*,
command: str,
cwd: str,
stdout: str = "",
stderr: str = "",
exit_code: int | None = None,
status: str | None = None,
) -> str:
limit = self._get_bash_max_output_bytes()
stdout = self._cap_output(stdout, limit)
stderr = self._cap_output(stderr, limit)
sections = [
"Manual `!` command result from the user. Use this as context only.",
f"Command: `{command}`",
f"Working directory: `{cwd}`",
]
if status is not None:
sections.append(f"Status: {status}")
if exit_code is not None:
sections.append(f"Exit code: {exit_code}")
if stdout:
sections.append(f"Stdout:\n```text\n{stdout.rstrip()}\n```")
if stderr:
sections.append(f"Stderr:\n```text\n{stderr.rstrip()}\n```")
if not stdout and not stderr:
sections.append("Output:\n```text\n(no output)\n```")
return "\n\n".join(sections)
async def _handle_user_message(self, message: str) -> None:
if self._remote_manager.is_active:
@@ -967,20 +1065,21 @@ class VibeApp(App): # noqa: PLR0904
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._narrator_manager.on_turn_event(event)
if isinstance(event, WaitingForInputEvent):
await self._remove_loading_widget()
if self._remote_manager.is_active:
await self._handle_remote_waiting_input(event)
elif self._loading_widget is None and is_progress_event(event):
await self._ensure_loading_widget()
if self.event_handler:
await self.event_handler.handle_event(
event,
loading_active=self._loading_widget is not None,
loading_widget=self._loading_widget,
)
async with aclosing(self.agent_loop.act(rendered_prompt)) as events:
async for event in events:
self._narrator_manager.on_turn_event(event)
if isinstance(event, WaitingForInputEvent):
await self._remove_loading_widget()
if self._remote_manager.is_active:
await self._handle_remote_waiting_input(event)
elif self._loading_widget is None and is_progress_event(event):
await self._ensure_loading_widget()
if self.event_handler:
await self.event_handler.handle_event(
event,
loading_active=self._loading_widget is not None,
loading_widget=self._loading_widget,
)
except asyncio.CancelledError:
await self._handle_turn_error()
@@ -1020,7 +1119,7 @@ class VibeApp(App): # noqa: PLR0904
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
return "Rate limits exceeded. Please wait a moment before trying again."
async def _teleport_command(self) -> None:
async def _teleport_command(self, **kwargs: Any) -> None:
await self._handle_teleport_command(show_message=False)
async def _handle_teleport_command(
@@ -1172,11 +1271,39 @@ class VibeApp(App): # noqa: PLR0904
self._interrupt_requested = False
async def _show_help(self) -> None:
async def _show_help(self, **kwargs: Any) -> None:
help_text = self.commands.get_help_text()
await self._mount_and_scroll(UserCommandMessage(help_text))
async def _show_status(self) -> None:
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
mcp_servers = self.config.mcp_servers
if not mcp_servers:
await self._mount_and_scroll(
UserCommandMessage("No MCP servers configured.")
)
return
if self._current_bottom_app == BottomApp.MCP:
return
name = cmd_args.strip()
if name and not any(s.name == name for s in mcp_servers):
await self._mount_and_scroll(
ErrorMessage(
f"Unknown MCP server: {name}. Known servers: "
+ ", ".join(s.name for s in mcp_servers),
collapsed=self._tools_collapsed,
)
)
return
await self._mount_and_scroll(UserCommandMessage("MCP servers opened..."))
await self._switch_from_input(
MCPApp(
mcp_servers=mcp_servers,
tool_manager=self.agent_loop.tool_manager,
initial_server=name,
)
)
async def _show_status(self, **kwargs: Any) -> None:
stats = self.agent_loop.stats
status_text = f"""## Agent Statistics
@@ -1189,27 +1316,27 @@ class VibeApp(App): # noqa: PLR0904
"""
await self._mount_and_scroll(UserCommandMessage(status_text))
async def _show_config(self) -> None:
async def _show_config(self, **kwargs: Any) -> None:
"""Switch to the configuration app in the bottom panel."""
if self._current_bottom_app == BottomApp.Config:
return
await self._switch_to_config_app()
async def _show_model(self) -> None:
async def _show_model(self, **kwargs: Any) -> None:
"""Switch to the model picker in the bottom panel."""
if self._current_bottom_app == BottomApp.ModelPicker:
return
await self._switch_to_model_picker_app()
async def _show_proxy_setup(self) -> None:
async def _show_proxy_setup(self, **kwargs: Any) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
await self._switch_to_proxy_setup_app()
async def _show_data_retention(self) -> None:
async def _show_data_retention(self, **kwargs: Any) -> None:
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
async def _show_session_picker(self) -> None:
async def _show_session_picker(self, **kwargs: Any) -> None:
cwd = str(Path.cwd())
local_sessions = (
list_local_resume_sessions(self.config, cwd)
@@ -1405,7 +1532,7 @@ class VibeApp(App): # noqa: PLR0904
def loading_widget(self) -> LoadingWidget | None:
return self._loading_widget
async def _reload_config(self) -> None:
async def _reload_config(self, **kwargs: Any) -> None:
try:
self._reset_ui_state()
await self._load_more.hide()
@@ -1422,7 +1549,11 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
)
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
await self._mount_and_scroll(
UserCommandMessage(
"Configuration reloaded (includes agent instructions and skills)."
)
)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
@@ -1430,7 +1561,7 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _install_lean(self) -> None:
async def _install_lean(self, **kwargs: Any) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" in current:
await self._mount_and_scroll(
@@ -1440,7 +1571,7 @@ class VibeApp(App): # noqa: PLR0904
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
await self._reload_config()
async def _uninstall_lean(self) -> None:
async def _uninstall_lean(self, **kwargs: Any) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" not in current:
await self._mount_and_scroll(
@@ -1452,7 +1583,7 @@ class VibeApp(App): # noqa: PLR0904
})
await self._reload_config()
async def _clear_history(self) -> None:
async def _clear_history(self, **kwargs: Any) -> None:
try:
self._reset_ui_state()
if self._remote_manager.is_active:
@@ -1482,7 +1613,7 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _show_log_path(self) -> None:
async def _show_log_path(self, **kwargs: Any) -> None:
if not self.agent_loop.session_logger.enabled:
await self._mount_and_scroll(
ErrorMessage(
@@ -1506,7 +1637,7 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _compact_history(self) -> None:
async def _compact_history(self, **kwargs: Any) -> None:
if self._agent_running:
await self._mount_and_scroll(
ErrorMessage(
@@ -1570,11 +1701,12 @@ class VibeApp(App): # noqa: PLR0904
return None
return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self) -> None:
async def _exit_app(self, **kwargs: Any) -> None:
self._log_reader.shutdown()
await self._narrator_manager.close()
self.exit(result=self._get_session_resume_info())
async def _setup_terminal(self) -> None:
async def _setup_terminal(self, **kwargs: Any) -> None:
result = setup_terminal()
if result.success:
@@ -1612,7 +1744,7 @@ class VibeApp(App): # noqa: PLR0904
telemetry_client=self.agent_loop.telemetry_client,
)
async def _show_voice_settings(self) -> None:
async def _show_voice_settings(self, **kwargs: Any) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._switch_to_voice_app()
@@ -1720,6 +1852,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case BottomApp.MCP:
self.query_one(MCPApp).focus()
case BottomApp.Rewind:
self.query_one(RewindApp).focus()
case BottomApp.Voice:
@@ -1794,7 +1928,7 @@ class VibeApp(App): # noqa: PLR0904
if isinstance(child, UserMessage) and child.message_index is not None
]
def _start_rewind_mode(self) -> None:
def _start_rewind_mode(self, **kwargs: Any) -> None:
self.action_rewind_prev()
def action_rewind_prev(self) -> None:
@@ -1987,6 +2121,15 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
def _handle_bottom_app_close_escape(
self, widget_type: type[MCPApp] | type[ProxySetupApp]
) -> None:
try:
self.query_one(widget_type).action_close()
except Exception:
pass
self._last_escape_time = None
def action_interrupt(self) -> None: # noqa: PLR0911
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
self._voice_manager.cancel_recording()
@@ -2002,13 +2145,12 @@ class VibeApp(App): # noqa: PLR0904
self._handle_voice_app_escape()
return
if self._current_bottom_app == BottomApp.MCP:
self._handle_bottom_app_close_escape(MCPApp)
return
if self._current_bottom_app == BottomApp.ProxySetup:
try:
proxy_setup_app = self.query_one(ProxySetupApp)
proxy_setup_app.action_close()
except Exception:
pass
self._last_escape_time = None
self._handle_bottom_app_close_escape(ProxySetupApp)
return
if self._current_bottom_app == BottomApp.Approval:
@@ -2170,6 +2312,14 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(schedule_switch)
async def action_toggle_debug_console(self) -> None:
if self._debug_console is not None:
await self._debug_console.remove()
self._debug_console = None
else:
self._debug_console = DebugConsole(log_reader=self._log_reader)
await self.mount(self._debug_console)
def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer)
if input_widgets:
@@ -2185,6 +2335,7 @@ class VibeApp(App): # noqa: PLR0904
self._agent_task.cancel()
self._remote_manager.cancel_stream_task()
self._log_reader.shutdown()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info())

View File

@@ -84,10 +84,17 @@ TextArea > .text-area--cursor {
}
#completion-popup {
width: 100%;
padding: 1;
padding-left: 3;
overlay: screen;
constrain: inside inside;
display: none;
width: auto;
max-width: 80;
height: auto;
max-height: 12;
padding: 0 1;
color: ansi_default;
background: $surface;
border: solid ansi_bright_black;
}
#input-box {
@@ -679,7 +686,7 @@ StatusMessage {
}
#config-app, #voice-app {
#config-app, #voice-app, #mcp-app {
width: 100%;
height: auto;
background: transparent;
@@ -688,7 +695,7 @@ StatusMessage {
margin: 0;
}
#config-content, #voice-content {
#config-content, #voice-content, #mcp-content {
width: 100%;
height: auto;
}
@@ -699,13 +706,13 @@ StatusMessage {
color: ansi_blue;
}
#config-options {
#config-options, #mcp-options {
width: 100%;
max-height: 50vh;
border: none;
}
#config-options:focus {
#config-options:focus, #mcp-options:focus {
border: none;
}
@@ -1089,6 +1096,35 @@ NarratorStatus {
margin-top: 1;
}
/* Debug Console */
#debug-console {
dock: right;
width: 40%;
max-width: 80;
min-width: 40;
height: 100%;
background: transparent;
border-left: solid ansi_bright_black;
padding: 0 1;
}
#debug-console-header {
width: 100%;
height: auto;
color: ansi_default;
padding: 0 0 1 0;
text-align: left;
}
#debug-console-log {
width: 100%;
height: 1fr;
background: transparent;
overflow-x: hidden;
scrollbar-size: 1 1;
}
#modelpicker-app {
width: 100%;
height: auto;

View File

@@ -33,15 +33,12 @@ class CompletionPopup(Static):
text.append(description, style=description_style)
self.update(text)
self.show()
self.styles.display = "block"
def hide(self) -> None:
self.update("")
self.styles.display = "none"
def show(self) -> None:
self.styles.display = "block"
def _display_label(self, label: str) -> str:
if label.startswith("@"):
return label[1:]

View File

@@ -72,7 +72,6 @@ class ChatInputContainer(Vertical):
self,
),
])
self._completion_popup: CompletionPopup | None = None
self._body: ChatInputBody | None = None
def _get_slash_entries(self) -> list[tuple[str, str]]:
@@ -86,8 +85,7 @@ class ChatInputContainer(Vertical):
return sorted(entries)
def compose(self) -> ComposeResult:
self._completion_popup = CompletionPopup()
yield self._completion_popup
yield CompletionPopup()
border_class = self._get_border_class()
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
@@ -138,12 +136,32 @@ class ChatInputContainer(Vertical):
def render_completion_suggestions(
self, suggestions: list[tuple[str, str]], selected_index: int
) -> None:
if self._completion_popup:
self._completion_popup.update_suggestions(suggestions, selected_index)
try:
popup = self.query_one(CompletionPopup)
except Exception:
return
popup.update_suggestions(suggestions, selected_index)
self._position_popup(popup, len(suggestions))
def clear_completion_suggestions(self) -> None:
if self._completion_popup:
self._completion_popup.hide()
try:
popup = self.query_one(CompletionPopup)
except Exception:
return
popup.hide()
def _position_popup(self, popup: CompletionPopup, line_count: int) -> None:
widget = self.input_widget
if not widget:
return
cursor = widget.cursor_screen_offset
my_region = self.region
# Place popup bottom edge just above the cursor row
popup_height = line_count + 2 # +2 for solid border
popup.styles.offset = (
cursor.x - my_region.x,
cursor.y - popup_height - my_region.y,
)
def _format_insertion(self, replacement: str, suffix: str) -> str:
"""Format the insertion text with appropriate spacing.

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
import bisect
from collections.abc import Callable
from rich.markup import escape
from rich.text import Text
from textual import events
from textual.app import ComposeResult
from textual.cache import LRUCache
from textual.containers import Vertical
from textual.geometry import Size
from textual.scroll_view import ScrollView
from textual.strip import Strip
from textual.widgets import Static
from vibe.core.log_reader import LogEntry, LogReader
from vibe.core.logger import decode_log_message
LOG_LEVEL_COLORS: dict[str, str] = {
"DEBUG": "dim",
"INFO": "cyan",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "bold red",
}
DEFAULT_LOG_PAGE_SIZE = 30
class _LogView(ScrollView, can_focus=True):
def __init__(
self,
load_page: Callable[[], None],
has_more: Callable[[], bool],
*,
id: str | None = None,
) -> None:
super().__init__(id=id)
self._lines: list[str] = []
self._wrap_counts: list[int] = []
self._wrap_prefix: list[int] = [0]
self._total_visual: int = 0
self._cached_width: int = 0
self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024)
self._load_page = load_page
self._has_more = has_more
def _wrap_markup(self, markup: str) -> int:
"""Return the number of visual lines this markup produces at current width."""
width = self._cached_width
if width <= 0:
return 1
text = Text.from_markup(markup, style=self.rich_style)
return len(text.wrap(self.app.console, width))
def _recompute_prefix(self) -> None:
self._wrap_prefix = [0]
for count in self._wrap_counts:
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
self._total_visual = self._wrap_prefix[-1]
def _reflow(self) -> None:
"""Re-wrap all lines at current widget width."""
width = self.size.width
if width <= 0:
return
self._cached_width = width
self._render_line_cache.clear()
self._wrap_counts = [self._wrap_markup(m) for m in self._lines]
self._recompute_prefix()
self.virtual_size = Size(width, self._total_visual)
def write_line(self, markup: str, scroll_end: bool | None = None) -> None:
at_bottom = self.is_vertical_scroll_end
width = self._cached_width or self.size.width
self._cached_width = width
self._lines.append(markup)
count = self._wrap_markup(markup)
self._wrap_counts.append(count)
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
self._total_visual += count
self.virtual_size = Size(width, self._total_visual)
if scroll_end or (scroll_end is None and at_bottom):
self.scroll_end(animate=False, immediate=True, x_axis=False)
def prepend_lines(self, markups: list[str]) -> None:
if not markups:
return
width = self._cached_width or self.size.width
self._cached_width = width
new_counts = [self._wrap_markup(m) for m in markups]
new_visual = sum(new_counts)
self._lines[0:0] = markups
self._wrap_counts[0:0] = new_counts
self._recompute_prefix()
self._render_line_cache.clear()
self.virtual_size = Size(width, self._total_visual)
self.scroll_to(y=self.scroll_y + new_visual, animate=False, immediate=True)
def render_line(self, y: int) -> Strip:
_, scroll_y = self.scroll_offset
abs_y = scroll_y + y
width = self.size.width
wrap_width = self._cached_width or width
rich_style = self.rich_style
if abs_y >= self._total_visual:
return Strip.blank(width, rich_style)
if abs_y in self._render_line_cache:
return self._render_line_cache[abs_y]
logical_idx = bisect.bisect_right(self._wrap_prefix, abs_y) - 1
text = Text.from_markup(self._lines[logical_idx], style=rich_style)
wrapped = text.wrap(self.app.console, wrap_width)
base = self._wrap_prefix[logical_idx]
for i, line_text in enumerate(wrapped):
strip = Strip(line_text.render(self.app.console), line_text.cell_len)
strip = strip.crop_extend(0, width, rich_style)
self._render_line_cache[base + i] = strip
try:
return self._render_line_cache[abs_y]
except KeyError:
return Strip.blank(width, rich_style)
def notify_style_update(self) -> None:
super().notify_style_update()
self._render_line_cache.clear()
def on_resize(self, event: events.Resize) -> None:
if event.size.width != self._cached_width:
self._reflow()
def on_click(self, event: events.Click) -> None:
_, scroll_y = self.scroll_offset
visual_y = scroll_y + event.y
logical_idx = bisect.bisect_right(self._wrap_prefix, visual_y) - 1
if 0 <= logical_idx < len(self._lines):
plain = Text.from_markup(self._lines[logical_idx]).plain
self.app.copy_to_clipboard(plain)
self.app.notify("Copied to clipboard", timeout=2.0)
def _try_load_previous(self) -> None:
if not self._has_more() or self.scroll_y > 0:
return
self._load_page()
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
super()._on_mouse_scroll_up(event)
self._try_load_previous()
def action_scroll_up(self) -> None:
super().action_scroll_up()
self._try_load_previous()
def action_page_up(self) -> None:
super().action_page_up()
self._try_load_previous()
def action_scroll_home(self) -> None:
super().action_scroll_home()
self._try_load_previous()
class DebugConsole(Vertical):
def __init__(
self, log_reader: LogReader, page_size: int = DEFAULT_LOG_PAGE_SIZE
) -> None:
super().__init__(id="debug-console")
self._log_reader = log_reader
self._log_view: _LogView | None = None
self._cursor: int | None = None
self._has_more: bool = True
self._page_size = page_size
def compose(self) -> ComposeResult:
yield Static(
"Debug Console [dim](ctrl+\\ to close)[/dim]", id="debug-console-header"
)
self._log_view = _LogView(
load_page=self._load_page,
has_more=lambda: self._has_more and self._cursor is not None,
id="debug-console-log",
)
yield self._log_view
def on_mount(self) -> None:
self._fill_viewport()
self._log_reader.set_consumer(self._on_log_entry)
self._log_reader.start_watching()
def on_unmount(self) -> None:
self._log_reader.set_consumer(None)
self._log_reader.stop_watching()
def _load_page(self) -> None:
if self._log_view is None:
return
result = self._log_reader.get_logs(
limit=self._page_size, offset=self._cursor or 0
)
self._cursor = result.cursor
self._has_more = result.has_more
markups = [self._format_entry(e) for e in reversed(result.entries)]
self._log_view.prepend_lines(markups)
def _fill_viewport(self) -> None:
"""Load enough logs to fill the viewport, then scroll to the bottom."""
if self._log_view is None or not self._has_more:
return
self.call_after_refresh(self._check_and_fill)
def _check_and_fill(self) -> None:
if self._log_view is None or not self._has_more:
return
if self._log_view.virtual_size.height <= self._log_view.size.height:
self._load_page()
self._fill_viewport()
else:
self._log_view.scroll_end(animate=False)
def _on_log_entry(self, entry: LogEntry) -> None:
self.app.call_from_thread(self._append_log_entry, entry)
def _append_log_entry(self, entry: LogEntry) -> None:
if self._log_view is None:
return
self._log_view.write_line(self._format_entry(entry))
@staticmethod
def _format_entry(entry: LogEntry) -> str:
color = LOG_LEVEL_COLORS.get(entry.level, "dim")
ts = entry.timestamp.astimezone().strftime("%Y-%m-%d %H:%M:%S")
message = decode_log_message(entry.message)
safe_message = escape(message)
return f"[dim]{ts}[/dim] [{color}]{entry.level:<8}[/{color}] {safe_message}"

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.events import DescendantBlur
from textual.message import Message
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.mcp.tools import MCPTool
if TYPE_CHECKING:
from vibe.core.config import MCPServer
from vibe.core.tools.manager import ToolManager
class MCPToolIndex(NamedTuple):
server_tools: dict[str, list[tuple[str, type[MCPTool]]]]
enabled_tools: dict[str, type[Any]]
def collect_mcp_tool_index(
mcp_servers: Sequence[MCPServer], tool_manager: ToolManager
) -> MCPToolIndex:
registered = tool_manager.registered_tools
available = tool_manager.available_tools
configured_servers = {server.name for server in mcp_servers}
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
for tool_name, cls in registered.items():
if not issubclass(cls, MCPTool):
continue
server_name = cls.get_server_name()
if server_name is None or server_name not in configured_servers:
continue
server_tools.setdefault(server_name, []).append((tool_name, cls))
return MCPToolIndex(server_tools, enabled_tools=available)
class MCPApp(Container):
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close", "Close", show=False),
Binding("backspace", "back", "Back", show=False),
]
class MCPClosed(Message):
pass
def __init__(
self,
mcp_servers: Sequence[MCPServer],
tool_manager: ToolManager,
initial_server: str = "",
) -> None:
super().__init__(id="mcp-app")
self._mcp_servers = mcp_servers
self._tool_manager = tool_manager
self._index = collect_mcp_tool_index(mcp_servers, tool_manager)
self._viewing_server: str | None = initial_server.strip() or None
def compose(self) -> ComposeResult:
with Vertical(id="mcp-content"):
yield NoMarkupStatic("", id="mcp-title", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(id="mcp-options")
yield NoMarkupStatic("")
yield NoMarkupStatic("", id="mcp-help", classes="settings-help")
def on_mount(self) -> None:
self._refresh_view(self._viewing_server)
self.query_one(OptionList).focus()
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id or ""
if option_id.startswith("server:"):
self._refresh_view(option_id.removeprefix("server:"))
def action_back(self) -> None:
if self._viewing_server is not None:
self._refresh_view(None)
def action_close(self) -> None:
self.post_message(self.MCPClosed())
def _refresh_view(self, server_name: str | None) -> None:
index = self._index
option_list = self.query_one(OptionList)
option_list.clear_options()
server_names = {s.name for s in self._mcp_servers}
if server_name is None or server_name not in server_names:
self._viewing_server = None
self.query_one("#mcp-title", NoMarkupStatic).update("MCP Servers")
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Enter Show tools Esc Close"
)
for srv in self._mcp_servers:
tools = index.server_tools.get(srv.name, [])
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
status = _server_status(enabled)
label = Text(no_wrap=True)
label.append(srv.name)
label.append(f" [{srv.transport}] {status}")
option_list.add_option(Option(label, id=f"server:{srv.name}"))
if self._mcp_servers:
option_list.highlighted = 0
return
self._viewing_server = server_name
self.query_one("#mcp-title", NoMarkupStatic).update(
f"MCP Server: {server_name}"
)
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Backspace Back Esc Close"
)
enabled_tools = [
(tool_name, cls)
for tool_name, cls in sorted(
index.server_tools.get(server_name, []), key=lambda t: t[0]
)
if tool_name in index.enabled_tools
]
if not enabled_tools:
option_list.add_option(
Option("No enabled tools for this server", disabled=True)
)
return
for tool_name, cls in enabled_tools:
remote_name = cls.get_remote_name()
raw_desc = (
(cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0]
)
label = Text(no_wrap=True)
label.append(remote_name, style="bold")
if raw_desc:
label.append(f" - {raw_desc}")
option_list.add_option(Option(label, id=f"tool:{tool_name}"))
if enabled_tools:
option_list.highlighted = 0
def _server_status(enabled: int) -> str:
if enabled == 0:
return "unavailable"
noun = "tool" if enabled == 1 else "tools"
return f"{enabled} {noun} enabled"

View File

@@ -1,5 +1,15 @@
from __future__ import annotations
__all__ = ["run_programmatic"]
from typing import TYPE_CHECKING
from vibe.core.programmatic import run_programmatic
if TYPE_CHECKING:
from vibe.core.programmatic import run_programmatic as run_programmatic
def __getattr__(name: str) -> object:
if name == "run_programmatic":
from vibe.core.programmatic import run_programmatic
return run_programmatic
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -159,11 +159,14 @@ class AgentLoop:
backend: BackendLike | None = None,
enable_streaming: bool = False,
entrypoint_metadata: EntrypointMetadata | None = None,
is_subagent: bool = False,
) -> None:
self._base_config = config
self.mcp_registry = MCPRegistry()
self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name
lambda: self._base_config,
initial_agent=agent_name,
allow_subagent=is_subagent,
)
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self.mcp_registry
@@ -209,6 +212,7 @@ class AgentLoop:
self._is_user_prompt_call: bool = False
self._session_rules: list[ApprovedRule] = []
self._approval_lock = asyncio.Lock()
self.telemetry_client = TelemetryClient(
config_getter=lambda: self.config, session_id_getter=lambda: self.session_id
@@ -267,7 +271,6 @@ class AgentLoop:
self.config.tools[tool_name] = {}
self.config.tools[tool_name]["permission"] = permission.value
self.tool_manager.invalidate_tool(tool_name)
def add_session_rule(self, rule: ApprovedRule) -> None:
self._session_rules.append(rule)
@@ -350,6 +353,10 @@ class AgentLoop:
self.agent_profile,
)
async def inject_user_context(self, content: str) -> None:
self.messages.append(LLMMessage(role=Role.user, content=content, injected=True))
await self._save_messages()
async def act(
self, msg: str, client_message_id: str | None = None
) -> AsyncGenerator[BaseEvent, None]:
@@ -1019,40 +1026,41 @@ class AgentLoop:
approval_type=ToolPermission.ALWAYS,
)
tool_name = tool.get_name()
ctx = tool.resolve_permission(args)
async with self._approval_lock:
tool_name = tool.get_name()
ctx = tool.resolve_permission(args)
if ctx is None:
config_perm = self.tool_manager.get_tool_config(tool_name).permission
ctx = PermissionContext(permission=config_perm)
if ctx is None:
config_perm = self.tool_manager.get_tool_config(tool_name).permission
ctx = PermissionContext(permission=config_perm)
match ctx.permission:
case ToolPermission.ALWAYS:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
case ToolPermission.NEVER:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=ctx.reason
or f"Tool '{tool_name}' is permanently disabled",
)
case _:
uncovered = [
rp
for rp in ctx.required_permissions
if not self._is_permission_covered(tool_name, rp)
]
if ctx.required_permissions and not uncovered:
match ctx.permission:
case ToolPermission.ALWAYS:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
return await self._ask_approval(
tool_name, args, tool_call_id, uncovered
)
case ToolPermission.NEVER:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=ctx.reason
or f"Tool '{tool_name}' is permanently disabled",
)
case _:
uncovered = [
rp
for rp in ctx.required_permissions
if not self._is_permission_covered(tool_name, rp)
]
if ctx.required_permissions and not uncovered:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
return await self._ask_approval(
tool_name, args, tool_call_id, uncovered
)
async def _ask_approval(
self,

View File

@@ -23,6 +23,7 @@ class AgentManager:
self,
config_getter: Callable[[], VibeConfig],
initial_agent: str = BuiltinAgentName.DEFAULT,
allow_subagent: bool = False,
) -> None:
self._config_getter = config_getter
self._search_paths = self._compute_search_paths(self._config)
@@ -39,9 +40,18 @@ class AgentManager:
" ".join(str(p) for p in self._search_paths),
)
self.active_profile = self._available.get(
initial_agent, self._available[BuiltinAgentName.DEFAULT]
)
profile = self._available.get(initial_agent)
if (
not allow_subagent
and profile is not None
and profile.agent_type != AgentType.AGENT
):
raise ValueError(
f"Agent '{initial_agent}' is a {profile.agent_type} and cannot be used"
f" as the primary agent. Only agents of type 'agent' can be selected"
f" with --agent."
)
self.active_profile = profile or self._available[BuiltinAgentName.DEFAULT]
self._cached_config: VibeConfig | None = None
@property

View File

@@ -177,7 +177,7 @@ LEAN = AgentProfile(
}
],
"compaction_model": {
"name": "mistral-vibe-cli-latest",
"name": "mistral-small-latest",
"provider": "mistral-testing",
"alias": "devstral-compact",
"temperature": 0.2,

View File

@@ -41,6 +41,10 @@ class WatchController:
thread.start()
ready_event.wait(timeout=0.5)
@property
def is_watching(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def stop(self) -> None:
thread = self._thread
if self._stop_event:

View File

@@ -15,7 +15,7 @@ from vibe.core.config._settings import (
MissingAPIKeyError,
MissingPromptFileError,
ModelConfig,
OtelExporterConfig,
OtelSpanExporterConfig,
ProjectContextConfig,
ProviderConfig,
SessionLoggingConfig,
@@ -45,7 +45,7 @@ __all__ = [
"MissingAPIKeyError",
"MissingPromptFileError",
"ModelConfig",
"OtelExporterConfig",
"OtelSpanExporterConfig",
"ProjectContextConfig",
"ProviderConfig",
"SessionLoggingConfig",

View File

@@ -11,6 +11,9 @@ from typing import Annotated, Any, Literal
from urllib.parse import urljoin
from dotenv import dotenv_values
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
DEFAULT_TRACES_EXPORT_PATH,
)
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.fields import FieldInfo
from pydantic_core import to_jsonable_python
@@ -291,7 +294,7 @@ class TTSModelConfig(BaseModel):
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class OtelExporterConfig(BaseModel):
class OtelSpanExporterConfig(BaseModel):
model_config = ConfigDict(frozen=True)
endpoint: str
@@ -299,7 +302,7 @@ class OtelExporterConfig(BaseModel):
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
MISTRAL_OTEL_TRACES_PATH = "/telemetry/v1/traces"
MISTRAL_OTEL_PATH = "/telemetry"
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
DEFAULT_PROVIDERS = [
@@ -523,12 +526,17 @@ class VibeConfig(BaseSettings):
return os.getenv(self.nuage_api_key_env_var, "")
@property
def otel_exporter_config(self) -> OtelExporterConfig | None:
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
# When otel_endpoint is set explicitly, authentication is the user's responsibility
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty.
# Otherwise endpoint and API key are derived from the first MISTRAL provider.
traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/")
if self.otel_endpoint:
return OtelExporterConfig(endpoint=self.otel_endpoint)
return OtelSpanExporterConfig(
endpoint=urljoin(
f"{self.otel_endpoint.rstrip('/')}/", traces_export_path
)
)
provider = next(
(p for p in self.providers if p.backend == Backend.MISTRAL), None
@@ -542,7 +550,8 @@ class VibeConfig(BaseSettings):
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
endpoint = urljoin(
server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_TRACES_PATH
f"{urljoin(server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
traces_export_path,
)
if not (api_key := os.getenv(api_key_env)):
@@ -551,7 +560,7 @@ class VibeConfig(BaseSettings):
)
return None
return OtelExporterConfig(
return OtelSpanExporterConfig(
endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"}
)

View File

@@ -11,7 +11,6 @@ import httpx
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter
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 (
@@ -160,14 +159,27 @@ class OpenAIAdapter(APIAdapter):
return LLMChunk(message=message, usage=usage)
ADAPTERS: dict[str, APIAdapter] = {
_ADAPTERS: dict[str, APIAdapter] = {
"openai": OpenAIAdapter(),
"anthropic": AnthropicAdapter(),
"vertex-anthropic": VertexAnthropicAdapter(),
"reasoning": ReasoningAdapter(),
}
def _get_adapter(api_style: str) -> APIAdapter:
"""Loads the appropriate adapter for the given API style,
lazily if the adapter is not already loaded.
"""
if api_style not in _ADAPTERS:
if api_style == "vertex-anthropic":
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
_ADAPTERS["vertex-anthropic"] = VertexAnthropicAdapter()
else:
raise KeyError(api_style)
return _ADAPTERS[api_style]
class GenericBackend:
def __init__(
self,
@@ -232,7 +244,7 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = ADAPTERS[api_style]
adapter = _get_adapter(api_style)
req = adapter.prepare_request(
model_name=model.name,
@@ -300,7 +312,7 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = ADAPTERS[api_style]
adapter = _get_adapter(api_style)
req = adapter.prepare_request(
model_name=model.name,

View File

@@ -32,10 +32,7 @@ from mistralai.client.models import (
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import (
merge_consecutive_user_messages,
strip_reasoning as strip_reasoning_message,
)
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
Content,
@@ -171,9 +168,6 @@ class MistralMapper:
for tool_call in tool_calls
]
def strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
return strip_reasoning_message(msg)
ReasoningEffortValue = Literal["none", "high"]
@@ -271,10 +265,6 @@ class MistralBackend:
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
if reasoning_effort is not None:
temperature = 1.0
else:
merged_messages = [
strip_reasoning_message(msg) for msg in merged_messages
]
response = await self._get_client().chat.complete_async(
model=model.name,
@@ -355,10 +345,6 @@ class MistralBackend:
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
if reasoning_effort is not None:
temperature = 1.0
else:
merged_messages = [
strip_reasoning_message(msg) for msg in merged_messages
]
stream = await self._get_client().chat.stream_async(
model=model.name,

View File

@@ -6,7 +6,7 @@ from typing import Any, ClassVar
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.message_utils import merge_consecutive_user_messages, strip_reasoning
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
FunctionCall,
@@ -122,8 +122,6 @@ class ReasoningAdapter(APIAdapter):
thinking: str = "off",
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
if thinking == "off":
merged_messages = [strip_reasoning(msg) for msg in merged_messages]
converted_messages = [self._convert_message(msg) for msg in merged_messages]
payload = self._build_payload(

View File

@@ -5,14 +5,6 @@ from collections.abc import Sequence
from vibe.core.types import LLMMessage, Role
def strip_reasoning(msg: LLMMessage) -> LLMMessage:
if msg.role != Role.assistant or not msg.reasoning_content:
return msg
return msg.model_copy(
update={"reasoning_content": None, "reasoning_signature": None}
)
def merge_consecutive_user_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
"""Merge consecutive user messages into a single message.

224
vibe/core/log_reader.py Normal file
View File

@@ -0,0 +1,224 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import re
import threading
from vibe.core.logger import logger
from vibe.core.paths import LOG_FILE
@dataclass(frozen=True, slots=True)
class LogEntry:
timestamp: datetime
ppid: int
pid: int
level: str
message: str
raw_line: str
line_number: int
@dataclass(frozen=True, slots=True)
class PaginatedLogs:
entries: list[LogEntry]
has_more: bool
cursor: int | None
LogConsumer = Callable[[LogEntry], None]
# Format: timestamp ppid pid level message [exception]
# Timestamp is ISO 8601 from datetime.isoformat(), e.g. 2026-02-21T10:28:51.529718+00:00
DEFAULT_LOG_PATTERN = re.compile(
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+(?:[+-]\d{2}:\d{2})?)\s+"
r"(\d+)\s+(\d+)\s+"
r"(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+"
r"(.+)$"
)
LOG_POLL_INTERVAL = 0.5
class LogReader:
def __init__(
self,
log_file: Path | None = None,
consumer: LogConsumer | None = None,
log_pattern: re.Pattern[str] = DEFAULT_LOG_PATTERN,
poll_interval: float = LOG_POLL_INTERVAL,
) -> None:
self._log_file = log_file if log_file is not None else LOG_FILE.path
self._consumer = consumer
self._log_pattern = log_pattern
self._lock = threading.Lock()
self._last_position: int = 0
self._new_lines_count: int = 0
self._stop_event: threading.Event | None = None
self._thread: threading.Thread | None = None
self._poll_interval = poll_interval
@property
def log_file(self) -> Path:
return self._log_file
@property
def is_watching(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def get_logs(self, limit: int = 100, offset: int = 0) -> PaginatedLogs:
if not self._log_file.exists():
return PaginatedLogs(entries=[], has_more=False, cursor=None)
entries: list[LogEntry] = []
for line, relative_position in self._read_lines_backward(start_position=offset):
if entry := self._parse_line(line, relative_position):
entries.append(entry)
if len(entries) >= limit:
return PaginatedLogs(
entries=entries, has_more=True, cursor=offset + len(entries)
)
return PaginatedLogs(entries=entries, has_more=False, cursor=None)
def _read_lines_backward(self, start_position: int) -> Iterator[tuple[str, int]]:
if not self._log_file.exists():
return
with self._lock:
# Snapshot once: file end-position is also captured once (seek EOF below),
# so new lines appended by the poll thread after this point are invisible
# to this iteration and the skip count stays consistent.
adjusted_skip = start_position + self._new_lines_count
chunk_size = 8192
relative_position = 0
skipped = 0
with self._log_file.open("rb") as f:
f.seek(0, 2)
position = f.tell()
remainder = b""
while position > 0:
read_size = min(chunk_size, position)
position -= read_size
f.seek(position)
chunk = f.read(read_size) + remainder
lines = chunk.split(b"\n")
remainder = lines[0]
for line in reversed(lines[1:]):
if not line:
continue
if skipped < adjusted_skip:
skipped += 1
continue
relative_position += 1
yield line.decode("utf-8", errors="replace"), relative_position
if remainder:
if skipped < adjusted_skip:
return
relative_position += 1
yield remainder.decode("utf-8", errors="replace"), relative_position
def set_consumer(self, consumer: LogConsumer | None) -> None:
self._consumer = consumer
def start_watching(self) -> None:
if self._thread and self._thread.is_alive():
return
with self._lock:
self._last_position = (
self._log_file.stat().st_size if self._log_file.exists() else 0
)
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._poll_log_loop, name="log-reader-poll", daemon=True
)
self._thread.start()
def stop_watching(self) -> None:
if self._stop_event:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=1)
self._thread = None
self._stop_event = None
with self._lock:
self._new_lines_count = 0
def shutdown(self) -> None:
self.stop_watching()
def _poll_log_loop(self) -> None:
stop_event = self._stop_event
if stop_event is None:
return
while not stop_event.is_set():
stop_event.wait(self._poll_interval)
if stop_event.is_set():
break
self._process_new_content()
def _process_new_content(self) -> None:
consumer = self._consumer
if consumer is None:
return
lines: list[str]
with self._lock:
if not self._log_file.exists():
self._last_position = 0
return
current_size = self._log_file.stat().st_size
if current_size < self._last_position:
self._last_position = 0
self._new_lines_count = 0
if current_size == self._last_position:
return
with self._log_file.open("r") as f:
f.seek(self._last_position)
new_content = f.read()
self._last_position = f.tell()
lines = new_content.splitlines()
self._new_lines_count += len(lines)
for line in lines:
if entry := self._parse_line(line, 0):
consumer(entry)
def _parse_line(self, line: str, line_number: int) -> LogEntry | None:
try:
match = self._log_pattern.match(line)
if not match:
return None
timestamp_str, ppid_str, pid_str, level, message = match.groups()
timestamp = datetime.fromisoformat(timestamp_str)
return LogEntry(
timestamp=timestamp,
ppid=int(ppid_str),
pid=int(pid_str),
level=level,
message=message,
raw_line=line,
line_number=line_number,
)
except Exception:
logger.debug("Failed to parse log line: %s", line[:100])
return None

View File

@@ -4,6 +4,7 @@ from datetime import UTC, datetime
import logging
from logging.handlers import RotatingFileHandler
import os
import re
from vibe.core.paths import LOG_DIR, LOG_FILE
@@ -18,17 +19,27 @@ class StructuredLogFormatter(logging.Formatter):
ppid = os.getppid()
pid = os.getpid()
level = record.levelname
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
message = encode_log_message(record.getMessage())
line = f"{timestamp} {ppid} {pid} {level} {message}"
if record.exc_info:
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
exc_text = encode_log_message(self.formatException(record.exc_info))
line = f"{line} {exc_text}"
return line
def encode_log_message(message: str) -> str:
return message.replace("\\", "\\\\").replace("\n", "\\n")
def decode_log_message(encoded: str) -> str:
return re.sub(
r"\\(.)", lambda m: "\n" if m.group(1) == "n" else m.group(1), encoded
)
def apply_logging_config(target_logger: logging.Logger) -> None:
LOG_DIR.path.mkdir(parents=True, exist_ok=True)

View File

@@ -23,37 +23,48 @@ _MAX_DIRS = 2000
def _collect_config_dirs_at(
path: Path,
entries: set[str],
entry_names: set[str],
tools: list[Path],
skills: list[Path],
agents: list[Path],
) -> None:
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
if _VIBE_DIR in entries:
if _VIBE_DIR in entry_names:
if (candidate := path / _TOOLS_SUBDIR).is_dir():
tools.append(candidate)
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
if (candidate := path / _AGENTS_SUBDIR).is_dir():
agents.append(candidate)
if _AGENTS_DIR in entries:
if _AGENTS_DIR in entry_names:
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
def _iter_child_dirs(path: Path, entries: set[str]) -> list[Path]:
"""Return sorted child directories to descend into, skipping ignored and dot-dirs."""
def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
"""Scan a directory, returning entry names and sorted child directories to descend into.
Uses ``os.scandir`` so that ``DirEntry.is_dir()`` leverages the dirent
d_type field and avoids a separate ``stat`` syscall on most filesystems.
"""
try:
entries = list(os.scandir(path))
except OSError:
return set(), []
entry_names = {e.name for e in entries}
children: list[Path] = []
for name in sorted(entries):
for entry in entries:
name = entry.name
if name in WALK_SKIP_DIR_NAMES or name.startswith("."):
continue
child = path / name
try:
if child.is_dir():
children.append(child)
if entry.is_dir():
children.append(path / name)
except OSError:
continue
return children
children.sort()
return entry_names, children
@cache
@@ -77,17 +88,16 @@ def walk_local_config_dirs_all(
current, depth = queue.popleft()
visited += 1
try:
entries = set(os.listdir(current))
except OSError:
entry_names, children = _scandir_entries(current)
if not entry_names:
continue
_collect_config_dirs_at(current, entries, tools_dirs, skills_dirs, agents_dirs)
_collect_config_dirs_at(
current, entry_names, tools_dirs, skills_dirs, agents_dirs
)
if depth < WALK_MAX_DEPTH:
queue.extend(
(child, depth + 1) for child in _iter_child_dirs(current, entries)
)
queue.extend((child, depth + 1) for child in children)
if visited >= _MAX_DIRS:
logger.warning(
@@ -116,18 +126,15 @@ def has_config_dirs_nearby(
current, depth = queue.popleft()
visited += 1
try:
entries = set(os.listdir(current))
except OSError:
entry_names, children = _scandir_entries(current)
if not entry_names:
continue
_collect_config_dirs_at(current, entries, found, found, found)
_collect_config_dirs_at(current, entry_names, found, found, found)
if found:
return True
if depth < max_depth:
queue.extend(
(child, depth + 1) for child in _iter_child_dirs(current, entries)
)
queue.extend((child, depth + 1) for child in children)
return False

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from contextlib import aclosing
from vibe import __version__
from vibe.core.agent_loop import AgentLoop, TeleportError
@@ -79,13 +80,14 @@ def run_programmatic(
)
formatter.on_event(next_event)
else:
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)
async with aclosing(agent_loop.act(prompt)) as events:
async for event in events:
formatter.on_event(event)
if (
isinstance(event, AssistantEvent)
and event.stopped_by_middleware
):
raise ConversationLimitException(event.content)
return formatter.finalize()
finally:

View File

@@ -82,7 +82,7 @@ class GitHubPublicData(BaseModel):
class ChatAssistantPublicData(BaseModel):
chat_url: str
chat_url: str | None = None
class GetChatAssistantIntegrationResponse(BaseModel):
@@ -195,7 +195,7 @@ class NuageClient:
await asyncio.sleep(min(interval, remaining))
raise ServiceTeleportError("GitHub connection timed out")
async def get_chat_assistant_url(self, execution_id: str) -> str:
async def get_chat_assistant_url(self, execution_id: str) -> str | None:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
headers=self._headers(),

View File

@@ -193,6 +193,9 @@ class TeleportService:
yield TeleportFetchingUrlEvent()
chat_url = await self._nuage_client.get_chat_assistant_url(execution_id)
if not chat_url:
raise ServiceTeleportError("Chat assistant URL is not available yet")
yield TeleportCompleteEvent(url=chat_url)
async def _push_or_fail(self) -> None:

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