v2.6.0 (#524)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin <torroba.q@gmail.com> Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
33
.github/workflows/build-and-upload.yml
vendored
@@ -64,10 +64,8 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync --no-dev --group build
|
||||
@@ -86,19 +84,20 @@ jobs:
|
||||
shell: pwsh
|
||||
run: python -c "import subprocess; version = subprocess.check_output(['uv', 'version']).decode().split()[1]; print(f'version={version}')" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload binary as artifact (Unix)
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
- name: Smoke test bundled binary (Unix)
|
||||
if: ${{ matrix.os != 'windows' }}
|
||||
with:
|
||||
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_unix.outputs.version }}
|
||||
path: dist/vibe-acp
|
||||
run: ./dist/vibe-acp-dir/vibe-acp --version
|
||||
|
||||
- name: Upload binary as artifact (Windows)
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
- name: Smoke test bundled binary (Windows)
|
||||
if: ${{ matrix.os == 'windows' }}
|
||||
shell: pwsh
|
||||
run: .\dist\vibe-acp-dir\vibe-acp.exe --version
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_windows.outputs.version }}
|
||||
path: dist/vibe-acp.exe
|
||||
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.os == 'windows' && steps.get_version_windows.outputs.version || steps.get_version_unix.outputs.version }}
|
||||
path: dist/vibe-acp-dir/
|
||||
|
||||
attach-to-release:
|
||||
needs: build-and-upload
|
||||
@@ -117,11 +116,9 @@ jobs:
|
||||
mkdir release-assets
|
||||
for dir in artifacts/*; do
|
||||
name=$(basename "$dir")
|
||||
if [ -f "$dir/vibe-acp" ]; then
|
||||
chmod +x "$dir/vibe-acp"
|
||||
zip -j "release-assets/${name}.zip" "$dir/vibe-acp"
|
||||
elif [ -f "$dir/vibe-acp.exe" ]; then
|
||||
zip -j "release-assets/${name}.zip" "$dir/vibe-acp.exe"
|
||||
if [ -f "$dir/vibe-acp" ] || [ -f "$dir/vibe-acp.exe" ]; then
|
||||
chmod -f +x "$dir/vibe-acp" 2>/dev/null || true
|
||||
(cd "$dir" && zip -r "../../release-assets/${name}.zip" .)
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
2
.vscode/launch.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
|
||||
37
CHANGELOG.md
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.6.0] - 2026-03-23
|
||||
|
||||
### Added
|
||||
|
||||
- OTEL tracing support for observability
|
||||
- Skill tool for managing task lists and workflows
|
||||
- Text-to-speech (TTS) functionality
|
||||
- Standalone --resume command for session picker
|
||||
- BFS for vibe folders to improve startup performance
|
||||
- List-based model picker for /model command
|
||||
- is_user_prompt flag to Mistral metadata header
|
||||
- Correlation ID in user feedback calls
|
||||
- Current date added to system prompt in vibe-work
|
||||
- TypeScript type inference for large tool outputs in vibe-work-harness
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated agent-client-protocol to 0.9.0a1
|
||||
- Changed inline code color from yellow to green
|
||||
- Removed "You have no internet access" from CLI prompt
|
||||
- Fine-grained permission system improvements
|
||||
- Inject system certs into vibe-acp frozen binary via truststore
|
||||
|
||||
### Fixed
|
||||
|
||||
- Streaming for currently streamed message when switching agents
|
||||
- Proper UI updates when tools switch current agents
|
||||
- Space key functionality when holding shift
|
||||
- Empty TextChunk not appended when reasoning has no text content
|
||||
- Messages removed from user feedback event
|
||||
- Bash allowlist/denylist activation on Windows
|
||||
- Improved scrolling performance
|
||||
- ACP error handling in webview
|
||||
- Context usage updates sent via ACP
|
||||
- Include `exit_plan_mode` tool only in plan mode
|
||||
|
||||
|
||||
## [2.5.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
@@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
||||
icon = "./icons/mistral_vibe.svg"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-darwin-aarch64-2.5.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-darwin-aarch64-2.6.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-darwin-x86_64-2.5.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-darwin-x86_64-2.6.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-linux-aarch64-2.5.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-linux-aarch64-2.6.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-linux-x86_64-2.5.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-linux-x86_64-2.6.0.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-windows-aarch64-2.5.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-windows-aarch64-2.6.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-windows-x86_64-2.5.0.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-windows-x86_64-2.6.0.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
6
pyinstaller/runtime_hook_truststore.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import truststore
|
||||
|
||||
# Inject system certificates into ssl before the frozen app starts.
|
||||
truststore.inject_into_ssl()
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -27,7 +27,7 @@ classifiers = [
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-client-protocol==0.8.1",
|
||||
"agent-client-protocol==0.9.0a1",
|
||||
"anyio>=4.12.0",
|
||||
"cachetools>=5.5.0",
|
||||
"cryptography>=44.0.0,<=46.0.3",
|
||||
@@ -39,6 +39,10 @@ dependencies = [
|
||||
"markdownify>=1.2.2",
|
||||
"mcp>=1.14.0",
|
||||
"mistralai==2.0.0",
|
||||
"opentelemetry-api>=1.39.1",
|
||||
"opentelemetry-exporter-otlp-proto-http>=1.39.1",
|
||||
"opentelemetry-sdk>=1.39.1",
|
||||
"opentelemetry-semantic-conventions>=0.60b1",
|
||||
"packaging>=24.1",
|
||||
"pexpect>=4.9.0",
|
||||
"pydantic>=2.12.4",
|
||||
@@ -103,18 +107,21 @@ dev = [
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
build = ["pyinstaller>=6.17.0"]
|
||||
build = ["pyinstaller>=6.17.0", "truststore>=0.10.4"]
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.12"
|
||||
reportMissingTypeStubs = false
|
||||
reportPrivateImportUsage = false
|
||||
|
||||
include = ["vibe/**/*.py", "tests/**/*.py"]
|
||||
exclude = ["pyinstaller/"]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[tool.ruff]
|
||||
include = ["vibe/**/*.py", "tests/**/*.py"]
|
||||
exclude = ["pyinstaller/"]
|
||||
line-length = 88
|
||||
target-version = "py312"
|
||||
preview = true
|
||||
|
||||
@@ -130,7 +130,9 @@ def create_release_branch(version: str) -> None:
|
||||
print(f"Created and switched to branch {branch_name}")
|
||||
|
||||
|
||||
def cherry_pick_commits(previous_version: str, current_version: str) -> None:
|
||||
def cherry_pick_commits(
|
||||
previous_version: str, current_version: str, squash: bool
|
||||
) -> None:
|
||||
previous_tag = f"v{previous_version}-private"
|
||||
current_tag = f"v{current_version}-private"
|
||||
|
||||
@@ -150,6 +152,53 @@ def cherry_pick_commits(previous_version: str, current_version: str) -> None:
|
||||
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
|
||||
print("Successfully cherry-picked all commits")
|
||||
|
||||
if squash:
|
||||
squash_commits(previous_version, current_version, previous_tag, current_tag)
|
||||
|
||||
|
||||
def squash_commits(
|
||||
previous_version: str, current_version: str, previous_tag: str, current_tag: str
|
||||
) -> None:
|
||||
print("Squashing commits into a single release commit...")
|
||||
run_git_command("reset", "--soft", f"v{previous_version}")
|
||||
|
||||
# Get all contributors between previous and current private tags
|
||||
result = run_git_command(
|
||||
"log",
|
||||
f"{previous_tag}..{current_tag}",
|
||||
"--format=%aN <%aE>",
|
||||
capture_output=True,
|
||||
)
|
||||
contributors = result.stdout.strip().split("\n")
|
||||
|
||||
# Get current user
|
||||
current_user_result = run_git_command("config", "user.email", capture_output=True)
|
||||
current_user_email = current_user_result.stdout.strip()
|
||||
|
||||
# Filter out current user and create co-authored lines
|
||||
vibe_marker = "vibe@mistral.ai"
|
||||
unique_coauthors = {
|
||||
f"Co-authored-by: {contributor}"
|
||||
for contributor in contributors
|
||||
if contributor
|
||||
and current_user_email not in contributor
|
||||
and vibe_marker not in contributor
|
||||
}
|
||||
|
||||
# Add Mistral Vibe as co-author
|
||||
coauthored_lines = sorted(unique_coauthors) + [
|
||||
"Co-authored-by: Mistral Vibe <vibe@mistral.ai>"
|
||||
]
|
||||
|
||||
# Create commit message
|
||||
commit_message = f"v{current_version}\n"
|
||||
for line in coauthored_lines:
|
||||
commit_message += f"\n{line}"
|
||||
|
||||
# Create the commit
|
||||
run_git_command("commit", "-m", commit_message)
|
||||
print("Successfully created release commit with co-authors")
|
||||
|
||||
|
||||
def get_commits_summary(previous_version: str, current_version: str) -> str:
|
||||
previous_tag = f"v{previous_version}-private"
|
||||
@@ -182,6 +231,7 @@ def print_summary(
|
||||
previous_version: str,
|
||||
commits_summary: str,
|
||||
changelog_entry: str,
|
||||
squash: bool,
|
||||
) -> None:
|
||||
print("\n" + "=" * 80)
|
||||
print("RELEASE PREPARATION SUMMARY")
|
||||
@@ -204,14 +254,15 @@ def print_summary(
|
||||
print(changelog_entry)
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("NEXT STEPS")
|
||||
print("-" * 80)
|
||||
print(
|
||||
f"To review/edit commits before publishing, use interactive rebase:\n"
|
||||
f" git rebase -i v{previous_version}"
|
||||
)
|
||||
if not squash:
|
||||
print("NEXT STEPS")
|
||||
print("-" * 80)
|
||||
print(
|
||||
f"To review/edit commits before publishing, use interactive rebase:\n"
|
||||
f" git rebase -i v{previous_version}"
|
||||
)
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print("\n" + "-" * 80)
|
||||
print("REMINDERS")
|
||||
print("-" * 80)
|
||||
print("Before publishing the release:")
|
||||
@@ -231,9 +282,17 @@ def main() -> None:
|
||||
)
|
||||
|
||||
parser.add_argument("version", help="Version to prepare release for (e.g., 1.1.3)")
|
||||
parser.add_argument(
|
||||
"--no-squash",
|
||||
action="store_false",
|
||||
dest="squash",
|
||||
default=True,
|
||||
help="Disable squashing of commits into a single release commit",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
current_version = args.version
|
||||
squash = args.squash
|
||||
|
||||
try:
|
||||
# Step 1: Ensure public remote exists
|
||||
@@ -264,7 +323,7 @@ def main() -> None:
|
||||
create_release_branch(current_version)
|
||||
|
||||
# Step 7: Cherry-pick commits
|
||||
cherry_pick_commits(previous_version, current_version)
|
||||
cherry_pick_commits(previous_version, current_version, squash)
|
||||
|
||||
# Step 8: Get summary information
|
||||
commits_summary = get_commits_summary(previous_version, current_version)
|
||||
@@ -272,7 +331,7 @@ def main() -> None:
|
||||
|
||||
# Step 9: Print summary
|
||||
print_summary(
|
||||
current_version, previous_version, commits_summary, changelog_entry
|
||||
current_version, previous_version, commits_summary, changelog_entry, squash
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -759,6 +759,42 @@ class TestToolCallStructure:
|
||||
)
|
||||
assert rejected_tool_call is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_options_include_granular_labels_for_bash(
|
||||
self, vibe_home_dir: Path
|
||||
) -> None:
|
||||
"""Bash 'npm install foo' should produce granular labels in permission options."""
|
||||
custom_results = [
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="bash", arguments='{"command":"npm install foo"}'
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
]
|
||||
),
|
||||
mock_llm_chunk(content="Done"),
|
||||
]
|
||||
mock_env = get_mocking_env(custom_results)
|
||||
async for process in get_acp_agent_loop_process(
|
||||
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||
):
|
||||
permission_request = await start_session_with_request_permission(
|
||||
process, "Run npm install foo"
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
|
||||
# Verify "Allow always" option includes the pattern label
|
||||
allow_always = next(
|
||||
o
|
||||
for o in permission_request.params.options
|
||||
if o.option_id == ToolOption.ALLOW_ALWAYS
|
||||
)
|
||||
assert "npm install *" in allow_always.name
|
||||
|
||||
@pytest.mark.skip(reason="Long running tool call updates are not implemented yet")
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_in_progress_update_structure(
|
||||
|
||||
254
tests/acp/test_acp_entrypoint_smoke.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import asyncio.subprocess as aio_subprocess
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from acp import PROTOCOL_VERSION, Client, RequestError, connect_to_agent
|
||||
from acp.schema import ClientCapabilities, Implementation
|
||||
import pexpect
|
||||
import pytest
|
||||
|
||||
from tests import TESTS_ROOT
|
||||
from tests.e2e.common import ansi_tolerant_pattern
|
||||
|
||||
|
||||
class _AcpSmokeClient(Client):
|
||||
def on_connect(self, conn: Any) -> None:
|
||||
pass
|
||||
|
||||
async def request_permission(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "session/request_permission"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def write_text_file(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "fs/write_text_file"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def read_text_file(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "fs/read_text_file"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def create_terminal(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "terminal/create"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def terminal_output(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "terminal/output"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def release_terminal(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "terminal/release"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def wait_for_terminal_exit(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "terminal/wait_for_exit"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def kill_terminal(self, *args: Any, **kwargs: Any) -> Any:
|
||||
msg = "terminal/kill"
|
||||
raise RequestError.method_not_found(msg)
|
||||
|
||||
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
_ = params
|
||||
raise RequestError.method_not_found(method)
|
||||
|
||||
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
|
||||
_ = params
|
||||
raise RequestError.method_not_found(method)
|
||||
|
||||
async def session_update(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_home_dir(tmp_path: Path) -> Path:
|
||||
return tmp_path / ".vibe"
|
||||
|
||||
|
||||
async def _spawn_vibe_acp(env: dict[str, str]) -> asyncio.subprocess.Process:
|
||||
return await asyncio.create_subprocess_exec(
|
||||
"uv",
|
||||
"run",
|
||||
"vibe-acp",
|
||||
stdin=aio_subprocess.PIPE,
|
||||
stdout=aio_subprocess.PIPE,
|
||||
stderr=aio_subprocess.PIPE,
|
||||
cwd=TESTS_ROOT.parent,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
|
||||
if proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.terminate()
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
|
||||
if proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
|
||||
def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["VIBE_HOME"] = str(vibe_home_dir)
|
||||
|
||||
if include_api_key:
|
||||
env["MISTRAL_API_KEY"] = "mock"
|
||||
else:
|
||||
env.pop("MISTRAL_API_KEY", None)
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def _build_client_capabilities(*, terminal_auth: bool = False) -> ClientCapabilities:
|
||||
if not terminal_auth:
|
||||
return ClientCapabilities()
|
||||
|
||||
return ClientCapabilities(field_meta={"terminal-auth": True})
|
||||
|
||||
|
||||
async def _connect_and_initialize(
|
||||
*, vibe_home_dir: Path, include_api_key: bool, terminal_auth: bool = False
|
||||
) -> tuple[asyncio.subprocess.Process, Any, Any]:
|
||||
env = _build_env(vibe_home_dir, include_api_key=include_api_key)
|
||||
proc = await _spawn_vibe_acp(env)
|
||||
|
||||
try:
|
||||
assert proc.stdin is not None
|
||||
assert proc.stdout is not None
|
||||
|
||||
conn = connect_to_agent(_AcpSmokeClient(), proc.stdin, proc.stdout)
|
||||
initialize_response = await asyncio.wait_for(
|
||||
conn.initialize(
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
client_capabilities=_build_client_capabilities(
|
||||
terminal_auth=terminal_auth
|
||||
),
|
||||
client_info=Implementation(
|
||||
name="pytest-smoke", title="Pytest Smoke", version="0.0.0"
|
||||
),
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
await _terminate_process(proc)
|
||||
raise
|
||||
|
||||
return proc, initialize_response, conn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vibe_acp_initialize_and_new_session(vibe_home_dir: Path) -> None:
|
||||
proc, initialize_response, conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir, include_api_key=True
|
||||
)
|
||||
|
||||
try:
|
||||
assert initialize_response.protocol_version == PROTOCOL_VERSION
|
||||
assert initialize_response.agent_info.name == "@mistralai/mistral-vibe"
|
||||
assert initialize_response.agent_info.title == "Mistral Vibe"
|
||||
|
||||
session = await asyncio.wait_for(
|
||||
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
|
||||
)
|
||||
|
||||
assert session.session_id
|
||||
finally:
|
||||
await _terminate_process(proc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vibe_acp_bootstraps_default_files(vibe_home_dir: Path) -> None:
|
||||
proc, _initialize_response, conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir, include_api_key=True
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
|
||||
)
|
||||
finally:
|
||||
await _terminate_process(proc)
|
||||
assert (vibe_home_dir / "config.toml").is_file()
|
||||
assert (vibe_home_dir / "vibehistory").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported(
|
||||
vibe_home_dir: Path,
|
||||
) -> None:
|
||||
proc, initialize_response, conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir, include_api_key=True, terminal_auth=True
|
||||
)
|
||||
|
||||
try:
|
||||
assert initialize_response.auth_methods is not None
|
||||
assert len(initialize_response.auth_methods) == 1
|
||||
|
||||
auth_method = initialize_response.auth_methods[0]
|
||||
assert auth_method.id == "vibe-setup"
|
||||
assert auth_method.field_meta is not None
|
||||
|
||||
terminal_auth = auth_method.field_meta["terminal-auth"]
|
||||
assert terminal_auth["label"] == "Mistral Vibe Setup"
|
||||
assert terminal_auth["command"]
|
||||
assert terminal_auth["args"]
|
||||
finally:
|
||||
await _terminate_process(proc)
|
||||
|
||||
|
||||
@pytest.mark.timeout(15)
|
||||
def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel(
|
||||
vibe_home_dir: Path,
|
||||
) -> None:
|
||||
env = cast("os._Environ[str]", _build_env(vibe_home_dir, include_api_key=False))
|
||||
env["TERM"] = "xterm-256color"
|
||||
|
||||
captured = io.StringIO()
|
||||
child = pexpect.spawn(
|
||||
"uv",
|
||||
["run", "vibe-acp", "--setup"],
|
||||
cwd=str(TESTS_ROOT.parent),
|
||||
env=env,
|
||||
encoding="utf-8",
|
||||
timeout=10,
|
||||
dimensions=(36, 120),
|
||||
)
|
||||
child.logfile_read = captured
|
||||
|
||||
try:
|
||||
child.expect(ansi_tolerant_pattern("Welcome to Mistral Vibe"), timeout=10)
|
||||
child.sendcontrol("c")
|
||||
child.expect(pexpect.EOF, timeout=10)
|
||||
finally:
|
||||
if child.isalive():
|
||||
child.terminate(force=True)
|
||||
if not child.closed:
|
||||
child.close()
|
||||
|
||||
output = captured.getvalue()
|
||||
assert "Setup cancelled" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vibe_acp_new_session_fails_without_api_key(vibe_home_dir: Path) -> None:
|
||||
proc, _initialize_response, conn = await _connect_and_initialize(
|
||||
vibe_home_dir=vibe_home_dir, include_api_key=False
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(RequestError, match="Missing API key"):
|
||||
await asyncio.wait_for(
|
||||
conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10
|
||||
)
|
||||
finally:
|
||||
await _terminate_process(proc)
|
||||
@@ -28,7 +28,7 @@ class TestACPInitialize:
|
||||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
@@ -52,7 +52,7 @@ class TestACPInitialize:
|
||||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.0"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
||||
@@ -100,13 +100,11 @@ class TestLoadSession:
|
||||
|
||||
assert response.config_options is not None
|
||||
assert len(response.config_options) == 2
|
||||
assert response.config_options[0].root.id == "mode"
|
||||
assert response.config_options[0].root.category == "mode"
|
||||
assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(response.config_options[0].root.options) == 5
|
||||
mode_option_values = {
|
||||
opt.value for opt in response.config_options[0].root.options
|
||||
}
|
||||
assert response.config_options[0].id == "mode"
|
||||
assert response.config_options[0].category == "mode"
|
||||
assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(response.config_options[0].options) == 5
|
||||
mode_option_values = {opt.value for opt in response.config_options[0].options}
|
||||
assert mode_option_values == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
@@ -114,13 +112,11 @@ class TestLoadSession:
|
||||
BuiltinAgentName.PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
}
|
||||
assert response.config_options[1].root.id == "model"
|
||||
assert response.config_options[1].root.category == "model"
|
||||
assert response.config_options[1].root.current_value == "devstral-latest"
|
||||
assert len(response.config_options[1].root.options) == 2
|
||||
model_option_values = {
|
||||
opt.value for opt in response.config_options[1].root.options
|
||||
}
|
||||
assert response.config_options[1].id == "model"
|
||||
assert response.config_options[1].category == "model"
|
||||
assert response.config_options[1].current_value == "devstral-latest"
|
||||
assert len(response.config_options[1].options) == 2
|
||||
model_option_values = {opt.value for opt in response.config_options[1].options}
|
||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -103,11 +103,11 @@ class TestACPNewSession:
|
||||
|
||||
# Mode config option
|
||||
mode_config = session_response.config_options[0]
|
||||
assert mode_config.root.id == "mode"
|
||||
assert mode_config.root.category == "mode"
|
||||
assert mode_config.root.current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(mode_config.root.options) == 5
|
||||
mode_option_values = {opt.value for opt in mode_config.root.options}
|
||||
assert mode_config.id == "mode"
|
||||
assert mode_config.category == "mode"
|
||||
assert mode_config.current_value == BuiltinAgentName.DEFAULT
|
||||
assert len(mode_config.options) == 5
|
||||
mode_option_values = {opt.value for opt in mode_config.options}
|
||||
assert mode_option_values == {
|
||||
BuiltinAgentName.DEFAULT,
|
||||
BuiltinAgentName.CHAT,
|
||||
@@ -118,11 +118,11 @@ class TestACPNewSession:
|
||||
|
||||
# Model config option
|
||||
model_config = session_response.config_options[1]
|
||||
assert model_config.root.id == "model"
|
||||
assert model_config.root.category == "model"
|
||||
assert model_config.root.current_value == "devstral-latest"
|
||||
assert len(model_config.root.options) == 2
|
||||
model_option_values = {opt.value for opt in model_config.root.options}
|
||||
assert model_config.id == "model"
|
||||
assert model_config.category == "model"
|
||||
assert model_config.current_value == "devstral-latest"
|
||||
assert len(model_config.options) == 2
|
||||
model_option_values = {opt.value for opt in model_config.options}
|
||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||
|
||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||
|
||||
@@ -83,8 +83,8 @@ class TestACPSetConfigOptionMode:
|
||||
|
||||
# Verify config_options reflect the new state
|
||||
mode_config = response.config_options[0]
|
||||
assert mode_config.root.id == "mode"
|
||||
assert mode_config.root.current_value == BuiltinAgentName.AUTO_APPROVE
|
||||
assert mode_config.id == "mode"
|
||||
assert mode_config.current_value == BuiltinAgentName.AUTO_APPROVE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_mode_to_plan(
|
||||
@@ -133,8 +133,8 @@ class TestACPSetConfigOptionMode:
|
||||
) # Chat mode auto-approves read-only tools
|
||||
|
||||
mode_config = response.config_options[0]
|
||||
assert mode_config.root.id == "mode"
|
||||
assert mode_config.root.current_value == BuiltinAgentName.CHAT
|
||||
assert mode_config.id == "mode"
|
||||
assert mode_config.current_value == BuiltinAgentName.CHAT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_mode_invalid_returns_none(
|
||||
@@ -205,8 +205,8 @@ class TestACPSetConfigOptionModel:
|
||||
|
||||
# Verify config_options reflect the new state
|
||||
model_config = response.config_options[1]
|
||||
assert model_config.root.id == "model"
|
||||
assert model_config.root.current_value == "devstral-small"
|
||||
assert model_config.id == "model"
|
||||
assert model_config.current_value == "devstral-small"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_model_invalid_returns_none(
|
||||
|
||||
254
tests/acp/test_usage_update.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from acp.schema import TextContentBlock, UsageUpdate
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
def _make_backend(prompt_tokens: int = 100, completion_tokens: int = 50) -> FakeBackend:
|
||||
return FakeBackend(
|
||||
LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content="Hi"),
|
||||
usage=LLMUsage(
|
||||
prompt_tokens=prompt_tokens, completion_tokens=completion_tokens
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _make_acp_agent(backend: FakeBackend) -> VibeAcpAgentLoop:
|
||||
config = build_test_vibe_config()
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
def _get_fake_client(agent: VibeAcpAgentLoop) -> FakeClient:
|
||||
return agent.client # type: ignore[return-value]
|
||||
|
||||
|
||||
def _get_usage_updates(client: FakeClient) -> list[UsageUpdate]:
|
||||
return [
|
||||
update.update
|
||||
for update in client._session_updates
|
||||
if isinstance(update.update, UsageUpdate)
|
||||
]
|
||||
|
||||
|
||||
class TestPromptResponseUsage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_returns_usage_in_response(self) -> None:
|
||||
agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50))
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
response = await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert response.usage is not None
|
||||
assert response.usage.input_tokens == 100
|
||||
assert response.usage.output_tokens == 50
|
||||
assert response.usage.total_tokens == 150
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_usage_optional_fields_are_none(self) -> None:
|
||||
agent = _make_acp_agent(_make_backend())
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
response = await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert response.usage is not None
|
||||
assert response.usage.thought_tokens is None
|
||||
assert response.usage.cached_read_tokens is None
|
||||
assert response.usage.cached_write_tokens is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_usage_accumulates_across_turns(self) -> None:
|
||||
backend = _make_backend(prompt_tokens=100, completion_tokens=50)
|
||||
agent = _make_acp_agent(backend)
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
first = await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
second = await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello again")],
|
||||
)
|
||||
|
||||
assert first.usage is not None
|
||||
assert second.usage is not None
|
||||
# Second turn should have strictly more cumulative tokens
|
||||
assert second.usage.input_tokens > first.usage.input_tokens
|
||||
assert second.usage.output_tokens > first.usage.output_tokens
|
||||
assert second.usage.total_tokens > first.usage.total_tokens
|
||||
|
||||
|
||||
class TestUsageUpdateNotification:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_sends_usage_update(self) -> None:
|
||||
agent = _make_acp_agent(_make_backend())
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
usage_updates = _get_usage_updates(_get_fake_client(agent))
|
||||
assert len(usage_updates) == 1
|
||||
assert usage_updates[0].session_update == "usage_update"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_update_contains_context_window_info(self) -> None:
|
||||
agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50))
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
usage_updates = _get_usage_updates(_get_fake_client(agent))
|
||||
assert len(usage_updates) == 1
|
||||
assert usage_updates[0].size > 0
|
||||
assert usage_updates[0].used > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_update_contains_cost_when_pricing_set(self) -> None:
|
||||
agent = _make_acp_agent(
|
||||
_make_backend(prompt_tokens=1_000_000, completion_tokens=500_000)
|
||||
)
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
# Set pricing directly on the session stats (config loading uses fixture defaults)
|
||||
acp_session = agent.sessions[session.session_id]
|
||||
acp_session.agent_loop.stats.update_pricing(input_price=0.4, output_price=2.0)
|
||||
|
||||
await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
usage_updates = _get_usage_updates(_get_fake_client(agent))
|
||||
assert len(usage_updates) == 1
|
||||
cost = usage_updates[0].cost
|
||||
assert cost is not None
|
||||
assert cost.currency == "USD"
|
||||
assert cost.amount > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_update_no_cost_when_zero_pricing(self) -> None:
|
||||
agent = _make_acp_agent(_make_backend())
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
usage_updates = _get_usage_updates(_get_fake_client(agent))
|
||||
assert len(usage_updates) == 1
|
||||
assert usage_updates[0].cost is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_update_sent_per_prompt(self) -> None:
|
||||
backend = _make_backend()
|
||||
agent = _make_acp_agent(backend)
|
||||
session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello")],
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await agent.prompt(
|
||||
session_id=session.session_id,
|
||||
prompt=[TextContentBlock(type="text", text="Hello again")],
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
usage_updates = _get_usage_updates(_get_fake_client(agent))
|
||||
assert len(usage_updates) == 2
|
||||
|
||||
|
||||
class TestLoadSessionUsageUpdate:
|
||||
def _make_session_dir(self, tmp_path: Path, session_id: str, cwd: str) -> Path:
|
||||
session_folder = tmp_path / f"session_20240101_120000_{session_id[:8]}"
|
||||
session_folder.mkdir()
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
with messages_file.open("w") as f:
|
||||
f.write(json.dumps({"role": "user", "content": "Hello"}) + "\n")
|
||||
meta = {
|
||||
"session_id": session_id,
|
||||
"start_time": "2024-01-01T12:00:00Z",
|
||||
"end_time": "2024-01-01T12:05:00Z",
|
||||
"environment": {"working_directory": cwd},
|
||||
}
|
||||
with (session_folder / "meta.json").open("w") as f:
|
||||
json.dump(meta, f)
|
||||
return session_folder
|
||||
|
||||
def _make_agent_with_session_logging(
|
||||
self, backend: FakeBackend, session_dir: Path
|
||||
) -> VibeAcpAgentLoop:
|
||||
session_config = SessionLoggingConfig(
|
||||
save_dir=str(session_dir), session_prefix="session", enabled=True
|
||||
)
|
||||
config = build_test_vibe_config(session_logging=session_config)
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||
self._base_config = config
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
|
||||
agent = _create_acp_agent()
|
||||
patch.object(agent, "_load_config", return_value=config).start()
|
||||
return agent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_sends_usage_update(self, tmp_path: Path) -> None:
|
||||
backend = _make_backend()
|
||||
agent = self._make_agent_with_session_logging(backend, tmp_path)
|
||||
session_id = "test-session-load-usage"
|
||||
self._make_session_dir(tmp_path, session_id, str(Path.cwd()))
|
||||
|
||||
await agent.load_session(cwd=str(Path.cwd()), session_id=session_id)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client = _get_fake_client(agent)
|
||||
usage_updates = _get_usage_updates(client)
|
||||
assert len(usage_updates) == 1
|
||||
assert usage_updates[0].session_update == "usage_update"
|
||||
assert usage_updates[0].size > 0
|
||||
@@ -1,8 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.acp.utils import get_proxy_help_text
|
||||
from vibe.acp.utils import (
|
||||
TOOL_OPTIONS,
|
||||
ToolOption,
|
||||
build_permission_options,
|
||||
get_proxy_help_text,
|
||||
)
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
|
||||
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
|
||||
|
||||
|
||||
def _write_env_file(content: str) -> None:
|
||||
@@ -53,3 +59,65 @@ class TestGetProxyHelpText:
|
||||
|
||||
assert "HTTP_PROXY=http://proxy:8080" in result
|
||||
assert "HTTPS_PROXY=" not in result
|
||||
|
||||
|
||||
class TestBuildPermissionOptions:
|
||||
def test_no_permissions_returns_default_options(self) -> None:
|
||||
result = build_permission_options(None)
|
||||
assert result is TOOL_OPTIONS
|
||||
|
||||
def test_empty_list_returns_default_options(self) -> None:
|
||||
result = build_permission_options([])
|
||||
assert result is TOOL_OPTIONS
|
||||
|
||||
def test_with_permissions_includes_labels_in_allow_always(self) -> None:
|
||||
permissions = [
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
invocation_pattern="npm install foo",
|
||||
session_pattern="npm install *",
|
||||
label="npm install *",
|
||||
)
|
||||
]
|
||||
result = build_permission_options(permissions)
|
||||
|
||||
assert len(result) == 3
|
||||
allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS)
|
||||
assert "npm install *" in allow_always.name
|
||||
assert "session" in allow_always.name.lower()
|
||||
|
||||
def test_allow_always_has_field_meta(self) -> None:
|
||||
permissions = [
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
invocation_pattern="mkdir foo",
|
||||
session_pattern="mkdir *",
|
||||
label="mkdir *",
|
||||
)
|
||||
]
|
||||
result = build_permission_options(permissions)
|
||||
|
||||
allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS)
|
||||
assert allow_always.field_meta is not None
|
||||
assert "required_permissions" in allow_always.field_meta
|
||||
meta_perms = allow_always.field_meta["required_permissions"]
|
||||
assert len(meta_perms) == 1
|
||||
assert meta_perms[0]["session_pattern"] == "mkdir *"
|
||||
|
||||
def test_allow_once_and_reject_unchanged(self) -> None:
|
||||
permissions = [
|
||||
RequiredPermission(
|
||||
scope=PermissionScope.URL_PATTERN,
|
||||
invocation_pattern="example.com",
|
||||
session_pattern="example.com",
|
||||
label="fetching from example.com",
|
||||
)
|
||||
]
|
||||
result = build_permission_options(permissions)
|
||||
|
||||
allow_once = next(o for o in result if o.option_id == ToolOption.ALLOW_ONCE)
|
||||
reject_once = next(o for o in result if o.option_id == ToolOption.REJECT_ONCE)
|
||||
assert allow_once.name == "Allow once"
|
||||
assert reject_once.name == "Reject once"
|
||||
assert allow_once.field_meta is None
|
||||
assert reject_once.field_meta is None
|
||||
|
||||
206
tests/audio_player/test_audio_player.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import sounddevice as sd
|
||||
except OSError:
|
||||
pytest.skip("PortAudio library not available", allow_module_level=True)
|
||||
|
||||
from vibe.core.audio_player.audio_player import AudioPlayer
|
||||
from vibe.core.audio_player.audio_player_port import (
|
||||
AlreadyPlayingError,
|
||||
AudioBackendUnavailableError,
|
||||
AudioFormat,
|
||||
NoAudioOutputDeviceError,
|
||||
UnsupportedAudioFormatError,
|
||||
)
|
||||
|
||||
|
||||
def _make_wav_bytes(
|
||||
n_frames: int = 1024, sample_rate: int = 48_000, channels: int = 1
|
||||
) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(channels)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(
|
||||
struct.pack(f"<{n_frames * channels}h", *([1000] * n_frames * channels))
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _get_callback(mock_stream_cls: MagicMock):
|
||||
return mock_stream_cls.call_args.kwargs["callback"]
|
||||
|
||||
|
||||
def _get_finished_callback(mock_stream_cls: MagicMock):
|
||||
return mock_stream_cls.call_args.kwargs["finished_callback"]
|
||||
|
||||
|
||||
class TestAudioPlayerInitialState:
|
||||
def test_not_playing(self) -> None:
|
||||
player = AudioPlayer()
|
||||
assert player.is_playing is False
|
||||
|
||||
|
||||
class TestPlayback:
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_play_sets_playing_state(self, mock_stream_cls: MagicMock) -> None:
|
||||
player = AudioPlayer()
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
assert player.is_playing is True
|
||||
mock_stream_cls.return_value.start.assert_called_once()
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_play_when_already_playing_raises(self, mock_stream_cls: MagicMock) -> None:
|
||||
player = AudioPlayer()
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
with pytest.raises(AlreadyPlayingError):
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_callback_feeds_audio_data(self, mock_stream_cls: MagicMock) -> None:
|
||||
wav_data = _make_wav_bytes(n_frames=512)
|
||||
player = AudioPlayer()
|
||||
player.play(wav_data, AudioFormat.WAV)
|
||||
|
||||
callback = _get_callback(mock_stream_cls)
|
||||
outdata = bytearray(512 * 2) # 512 frames * 2 bytes per sample
|
||||
callback(outdata, 512, {}, sd.CallbackFlags())
|
||||
|
||||
assert outdata != bytearray(512 * 2)
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_callback_pads_silence_at_end(self, mock_stream_cls: MagicMock) -> None:
|
||||
wav_data = _make_wav_bytes(n_frames=256)
|
||||
player = AudioPlayer()
|
||||
player.play(wav_data, AudioFormat.WAV)
|
||||
|
||||
callback = _get_callback(mock_stream_cls)
|
||||
# First callback consumes all 256 frames
|
||||
outdata1 = bytearray(256 * 2)
|
||||
callback(outdata1, 256, {}, sd.CallbackFlags())
|
||||
|
||||
# Second callback should raise CallbackStop (no data left)
|
||||
outdata2 = bytearray(256 * 2)
|
||||
with pytest.raises(sd.CallbackStop):
|
||||
callback(outdata2, 256, {}, sd.CallbackFlags())
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_on_finished_called_after_natural_completion(
|
||||
self, mock_stream_cls: MagicMock
|
||||
) -> None:
|
||||
finished = []
|
||||
player = AudioPlayer()
|
||||
player.play(
|
||||
_make_wav_bytes(),
|
||||
AudioFormat.WAV,
|
||||
on_finished=lambda: finished.append(True),
|
||||
)
|
||||
|
||||
finished_callback = _get_finished_callback(mock_stream_cls)
|
||||
finished_callback()
|
||||
|
||||
assert player.is_playing is False
|
||||
assert len(finished) == 1
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_can_play_multiple_times(self, mock_stream_cls: MagicMock) -> None:
|
||||
player = AudioPlayer()
|
||||
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
finished_callback = _get_finished_callback(mock_stream_cls)
|
||||
finished_callback()
|
||||
assert player.is_playing is False
|
||||
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
assert player.is_playing is True
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_creates_stream_with_correct_params(
|
||||
self, mock_stream_cls: MagicMock
|
||||
) -> None:
|
||||
wav_data = _make_wav_bytes(sample_rate=24_000, channels=1)
|
||||
player = AudioPlayer()
|
||||
player.play(wav_data, AudioFormat.WAV)
|
||||
|
||||
call_kwargs = mock_stream_cls.call_args.kwargs
|
||||
assert call_kwargs["samplerate"] == 24_000
|
||||
assert call_kwargs["channels"] == 1
|
||||
assert call_kwargs["dtype"] == "int16"
|
||||
|
||||
|
||||
class TestStop:
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_stop_closes_stream(self, mock_stream_cls: MagicMock) -> None:
|
||||
player = AudioPlayer()
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
player.stop()
|
||||
mock_stream_cls.return_value.close.assert_called_once()
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_finished_callback_resets_state(self, mock_stream_cls: MagicMock) -> None:
|
||||
player = AudioPlayer()
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
|
||||
finished_callback = _get_finished_callback(mock_stream_cls)
|
||||
finished_callback()
|
||||
|
||||
assert player.is_playing is False
|
||||
|
||||
def test_stop_when_not_playing_is_noop(self) -> None:
|
||||
player = AudioPlayer()
|
||||
player.stop()
|
||||
assert player.is_playing is False
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_stop_triggers_on_finished_via_callback(
|
||||
self, mock_stream_cls: MagicMock
|
||||
) -> None:
|
||||
finished = []
|
||||
player = AudioPlayer()
|
||||
player.play(
|
||||
_make_wav_bytes(),
|
||||
AudioFormat.WAV,
|
||||
on_finished=lambda: finished.append(True),
|
||||
)
|
||||
|
||||
# Simulate sounddevice calling finished_callback after stop
|
||||
finished_callback = _get_finished_callback(mock_stream_cls)
|
||||
finished_callback()
|
||||
|
||||
assert len(finished) == 1
|
||||
|
||||
|
||||
class TestUnsupportedFormat:
|
||||
def test_unsupported_format_raises(self) -> None:
|
||||
player = AudioPlayer()
|
||||
with pytest.raises(UnsupportedAudioFormatError):
|
||||
player.play(b"fake data", "mp3") # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestGuardAudioOutput:
|
||||
def test_raises_when_no_sounddevice(self) -> None:
|
||||
with patch("vibe.core.audio_player.audio_player.sd", None):
|
||||
player = AudioPlayer()
|
||||
with pytest.raises(AudioBackendUnavailableError):
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
|
||||
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
||||
def test_raises_when_no_output_device(self, mock_stream_cls: MagicMock) -> None:
|
||||
with patch(
|
||||
"vibe.core.audio_player.audio_player.sd.query_devices",
|
||||
side_effect=sd.PortAudioError(-1),
|
||||
):
|
||||
player = AudioPlayer()
|
||||
with pytest.raises(NoAudioOutputDeviceError):
|
||||
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
||||
assert player.is_playing is False
|
||||
mock_stream_cls.assert_not_called()
|
||||
@@ -16,6 +16,7 @@ import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
from mistralai.client.models import AssistantMessage
|
||||
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
|
||||
import pytest
|
||||
import respx
|
||||
@@ -33,13 +34,13 @@ from tests.backend.data.mistral import (
|
||||
STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS,
|
||||
TOOL_CONVERSATION_PARAMS as MISTRAL_TOOL_CONVERSATION_PARAMS,
|
||||
)
|
||||
from vibe.core.config import Backend, ModelConfig, ProviderConfig
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.backend.generic import GenericBackend
|
||||
from vibe.core.llm.backend.mistral import MistralBackend
|
||||
from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper
|
||||
from vibe.core.llm.exceptions import BackendError
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.types import LLMChunk, LLMMessage, Role, ToolCall
|
||||
from vibe.core.types import Backend, FunctionCall, LLMChunk, LLMMessage, Role, ToolCall
|
||||
from vibe.core.utils import get_user_agent
|
||||
|
||||
|
||||
@@ -435,3 +436,93 @@ class TestMistralRetry:
|
||||
timeout_ms=720000,
|
||||
retry_config=backend._retry_config,
|
||||
)
|
||||
|
||||
|
||||
class TestMistralMapperPrepareMessage:
|
||||
"""Tests for MistralMapper.prepare_message thinking-block handling.
|
||||
|
||||
The Mistral API returns assistant messages with reasoning as a single
|
||||
ThinkChunk (no trailing TextChunk when there is no text content). When
|
||||
the mapper rebuilds the message for the next request it must NOT append
|
||||
an empty TextChunk, otherwise the proxy's history-consistency check
|
||||
sees a content mismatch on every turn and creates spurious conversation
|
||||
segments.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mapper(self) -> MistralMapper:
|
||||
return MistralMapper()
|
||||
|
||||
def test_reasoning_only_no_empty_text_chunk(self, mapper: MistralMapper) -> None:
|
||||
"""Assistant with reasoning_content but no text content should produce
|
||||
only a ThinkChunk — no trailing empty TextChunk.
|
||||
"""
|
||||
msg = LLMMessage(
|
||||
role=Role.assistant,
|
||||
content=None,
|
||||
reasoning_content="Let me think step by step.",
|
||||
)
|
||||
result = mapper.prepare_message(msg)
|
||||
content = result.content
|
||||
assert isinstance(content, list)
|
||||
assert len(content) == 1
|
||||
assert content[0].type == "thinking"
|
||||
|
||||
def test_reasoning_with_empty_string_content(self, mapper: MistralMapper) -> None:
|
||||
"""content='' (empty string) should also not produce a trailing TextChunk."""
|
||||
msg = LLMMessage(
|
||||
role=Role.assistant, content="", reasoning_content="Thinking..."
|
||||
)
|
||||
result = mapper.prepare_message(msg)
|
||||
content = result.content
|
||||
assert isinstance(content, list)
|
||||
assert len(content) == 1
|
||||
assert content[0].type == "thinking"
|
||||
|
||||
def test_reasoning_with_text_content(self, mapper: MistralMapper) -> None:
|
||||
"""When there is actual text content, both ThinkChunk and TextChunk
|
||||
should be present.
|
||||
"""
|
||||
msg = LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="Here is the answer.",
|
||||
reasoning_content="Let me reason.",
|
||||
)
|
||||
result = mapper.prepare_message(msg)
|
||||
content = result.content
|
||||
assert isinstance(content, list)
|
||||
assert len(content) == 2
|
||||
assert content[0].type == "thinking"
|
||||
assert content[1].type == "text"
|
||||
assert content[1].text == "Here is the answer."
|
||||
|
||||
def test_reasoning_with_tool_calls_no_text(self, mapper: MistralMapper) -> None:
|
||||
"""Reasoning + tool_calls but no text content — only ThinkChunk."""
|
||||
msg = LLMMessage(
|
||||
role=Role.assistant,
|
||||
content=None,
|
||||
reasoning_content="I should run a command.",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="tc_1",
|
||||
index=0,
|
||||
function=FunctionCall(name="bash", arguments='{"cmd": "ls"}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
result = mapper.prepare_message(msg)
|
||||
assert isinstance(result, AssistantMessage)
|
||||
content = result.content
|
||||
assert isinstance(content, list)
|
||||
assert len(content) == 1
|
||||
assert content[0].type == "thinking"
|
||||
# Tool calls should still be present
|
||||
assert isinstance(result.tool_calls, list)
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].function.name == "bash"
|
||||
|
||||
def test_no_reasoning_plain_string(self, mapper: MistralMapper) -> None:
|
||||
"""Without reasoning_content, content is a plain string."""
|
||||
msg = LLMMessage(role=Role.assistant, content="Hello!")
|
||||
result = mapper.prepare_message(msg)
|
||||
assert result.content == "Hello!"
|
||||
|
||||
@@ -13,7 +13,8 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
||||
resolve_api_key_for_plan,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
from vibe.core.config import Backend, ProviderConfig
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -8,7 +8,7 @@ class TestCommandRegistry:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/help") == "help"
|
||||
assert registry.get_command_name("/config") == "config"
|
||||
assert registry.get_command_name("/model") == "config"
|
||||
assert registry.get_command_name("/model") == "model"
|
||||
assert registry.get_command_name("/clear") == "clear"
|
||||
assert registry.get_command_name("/exit") == "exit"
|
||||
|
||||
|
||||
77
tests/cli/test_feedback_bar.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
|
||||
|
||||
|
||||
class TestFeedbackBarState:
|
||||
def test_maybe_show_shows_when_random_below_threshold(self):
|
||||
bar = FeedbackBar()
|
||||
bar.display = False
|
||||
bar._set_active = MagicMock()
|
||||
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0
|
||||
):
|
||||
bar.maybe_show()
|
||||
|
||||
bar._set_active.assert_called_once_with(True)
|
||||
|
||||
def test_maybe_show_does_not_show_when_random_above_threshold(self):
|
||||
bar = FeedbackBar()
|
||||
bar.display = False
|
||||
bar._set_active = MagicMock()
|
||||
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=1.0
|
||||
):
|
||||
bar.maybe_show()
|
||||
|
||||
bar._set_active.assert_not_called()
|
||||
|
||||
def test_hide_calls_set_active_false_when_displayed(self):
|
||||
bar = FeedbackBar()
|
||||
bar.display = True
|
||||
bar._set_active = MagicMock()
|
||||
|
||||
bar.hide()
|
||||
|
||||
bar._set_active.assert_called_once_with(False)
|
||||
|
||||
def test_hide_does_nothing_when_already_hidden(self):
|
||||
bar = FeedbackBar()
|
||||
bar.display = False
|
||||
bar._set_active = MagicMock()
|
||||
|
||||
bar.hide()
|
||||
|
||||
bar._set_active.assert_not_called()
|
||||
|
||||
def test_handle_feedback_key_posts_message_and_deactivates(self):
|
||||
bar = FeedbackBar()
|
||||
bar.set_timer = MagicMock()
|
||||
bar.post_message = MagicMock()
|
||||
bar.query_one = MagicMock()
|
||||
mock_text_area = MagicMock()
|
||||
mock_text_area.feedback_active = True
|
||||
mock_app = MagicMock()
|
||||
mock_app.query_one.return_value = mock_text_area
|
||||
|
||||
with patch.object(
|
||||
type(bar), "app", new_callable=lambda: property(lambda self: mock_app)
|
||||
):
|
||||
bar.handle_feedback_key(3)
|
||||
|
||||
assert mock_text_area.feedback_active is False
|
||||
bar.post_message.assert_called_once()
|
||||
msg = bar.post_message.call_args[0][0]
|
||||
assert isinstance(msg, FeedbackBar.FeedbackGiven)
|
||||
assert msg.rating == 3
|
||||
bar.set_timer.assert_called_once()
|
||||
|
||||
|
||||
class TestFeedbackGivenMessage:
|
||||
def test_message_stores_rating(self):
|
||||
msg = FeedbackBar.FeedbackGiven(rating=2)
|
||||
assert msg.rating == 2
|
||||
284
tests/cli/test_ui_config_and_model_picker.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.textual_ui.app import BottomApp
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
|
||||
from vibe.core.config._settings import ModelConfig
|
||||
|
||||
|
||||
def _make_config_with_models():
|
||||
models = [
|
||||
ModelConfig(name="model-a", provider="mistral", alias="alpha"),
|
||||
ModelConfig(name="model-b", provider="mistral", alias="beta"),
|
||||
ModelConfig(name="model-c", provider="mistral", alias="gamma"),
|
||||
]
|
||||
return build_test_vibe_config(models=models, active_model="alpha")
|
||||
|
||||
|
||||
# --- /config command ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_opens_config_app() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Config
|
||||
assert len(app.query(ConfigApp)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_escape_returns_to_input() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ConfigApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_toggle_autocopy() -> None:
|
||||
config = _make_config_with_models()
|
||||
config.autocopy_to_clipboard = False
|
||||
app = build_test_vibe_app(config=config)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Navigate down to Auto-copy (second item) and toggle
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
# Verify the toggle happened in the widget
|
||||
config_app = app.query_one(ConfigApp)
|
||||
assert config_app.changes.get("autocopy_to_clipboard") == "On"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_escape_saves_changes() -> None:
|
||||
config = _make_config_with_models()
|
||||
config.autocopy_to_clipboard = False
|
||||
app = build_test_vibe_app(config=config)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Toggle auto-copy
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
mock_save.assert_called_once()
|
||||
changes = mock_save.call_args[0][0]
|
||||
assert changes["autocopy_to_clipboard"] is True
|
||||
|
||||
|
||||
# --- /model command ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_opens_model_picker() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_model()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.ModelPicker
|
||||
assert len(app.query(ModelPickerApp)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_picker_shows_all_models() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_model()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
picker = app.query_one(ModelPickerApp)
|
||||
assert picker._model_aliases == ["alpha", "beta", "gamma"]
|
||||
assert picker._current_model == "alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_picker_escape_returns_to_input() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_model()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ModelPickerApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_picker_escape_does_not_save() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_model()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_picker_select_model() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_model()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Navigate down to "beta" and select
|
||||
await pilot.press("down")
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
mock_save.assert_called_once_with({"active_model": "beta"})
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ModelPickerApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_picker_select_current_model() -> None:
|
||||
"""Selecting the already-active model still saves (idempotent)."""
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_model()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
mock_save.assert_called_once_with({"active_model": "alpha"})
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
|
||||
|
||||
# --- config -> model picker flow ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_model_entry_opens_model_picker() -> None:
|
||||
"""Pressing Enter on the Model row in /config opens the model picker."""
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Model row is the first item, already highlighted. Press enter.
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.ModelPicker
|
||||
assert len(app.query(ModelPickerApp)) == 1
|
||||
assert len(app.query(ConfigApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_to_model_picker_escape_returns_to_input() -> None:
|
||||
"""Opening model picker from config, then ESC, returns to input (not config)."""
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Open model picker from config
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
||||
# Escape model picker
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ModelPickerApp)) == 0
|
||||
assert len(app.query(ConfigApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_to_model_picker_select_returns_to_input() -> None:
|
||||
"""Opening model picker from config, selecting a model, returns to input."""
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Open model picker from config
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
||||
# Select second model
|
||||
await pilot.press("down")
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
mock_save.assert_called_once_with({"active_model": "beta"})
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_pending_changes_saved_before_model_picker() -> None:
|
||||
"""Toggle changes in config are saved before switching to model picker."""
|
||||
config = _make_config_with_models()
|
||||
config.autocopy_to_clipboard = False
|
||||
app = build_test_vibe_app(config=config)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Toggle auto-copy (second row)
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
# Go back up to model row and open model picker
|
||||
await pilot.press("up")
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
||||
mock_save.assert_called_once()
|
||||
changes = mock_save.call_args[0][0]
|
||||
assert changes["autocopy_to_clipboard"] is True
|
||||
45
tests/cli/test_ui_session_exit.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.textual_ui.session_exit import print_session_resume_message
|
||||
from vibe.core.types import AgentStats
|
||||
|
||||
|
||||
def test_print_session_resume_message_skips_output_without_session_id(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
print_session_resume_message(None, AgentStats())
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_print_session_resume_message_prints_resume_commands_and_usage(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
print_session_resume_message(
|
||||
"12345678-1234-1234-1234-123456789abc",
|
||||
AgentStats(session_prompt_tokens=14_867, session_completion_tokens=6),
|
||||
)
|
||||
|
||||
assert capsys.readouterr().out == (
|
||||
"\n"
|
||||
"Total tokens used this session: input=14,867 output=6 (total=14,873)\n"
|
||||
"\n"
|
||||
"To continue this session, run: vibe --continue\n"
|
||||
"Or: vibe --resume 12345678-1234-1234-1234-123456789abc\n"
|
||||
)
|
||||
|
||||
|
||||
def test_print_session_resume_message_prints_zero_usage_for_resumed_run_without_llm_activity(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
print_session_resume_message("12345678", AgentStats())
|
||||
|
||||
assert capsys.readouterr().out == (
|
||||
"\n"
|
||||
"Total tokens used this session: input=0 output=0 (total=0)\n"
|
||||
"\n"
|
||||
"To continue this session, run: vibe --continue\n"
|
||||
"Or: vibe --resume 12345678\n"
|
||||
)
|
||||
@@ -15,7 +15,7 @@ from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||
)
|
||||
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
||||
from vibe.cli.textual_ui.app import CORE_VERSION, VibeApp
|
||||
from vibe.cli.textual_ui.app import CORE_VERSION, StartupOptions, VibeApp
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import (
|
||||
@@ -121,14 +121,28 @@ def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 0
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
def record_telemetry(
|
||||
self: Any, event_name: str, properties: dict[str, Any]
|
||||
self: Any,
|
||||
event_name: str,
|
||||
properties: dict[str, Any],
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
events.append({"event_name": event_name, "properties": properties})
|
||||
event: dict[str, Any] = {"event_name": event_name, "properties": properties}
|
||||
if correlation_id is not None:
|
||||
event["correlation_id"] = correlation_id
|
||||
events.append(event)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.telemetry.send.TelemetryClient.send_telemetry_event",
|
||||
@@ -236,11 +250,11 @@ def build_test_vibe_app(
|
||||
|
||||
return VibeApp(
|
||||
agent_loop=resolved_agent_loop,
|
||||
startup=StartupOptions(initial_prompt=kwargs.pop("initial_prompt", None)),
|
||||
current_version=resolved_current_version,
|
||||
update_notifier=resolved_update_notifier,
|
||||
update_cache_repository=resolved_update_cache_repository,
|
||||
plan_offer_gateway=resolved_plan_offer_gateway,
|
||||
initial_prompt=kwargs.pop("initial_prompt", None),
|
||||
voice_manager=voice_manager,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
127
tests/core/test_config_otel.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import OtelExporterConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
class TestOtelExporterConfig:
|
||||
def test_derives_endpoint_from_mistral_provider(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-test")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://customer.mistral.ai/v1",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://customer.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-test"}
|
||||
|
||||
def test_uses_first_mistral_provider(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("EU_KEY", "sk-eu")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="mistral-eu",
|
||||
api_base="https://eu.mistral.ai/v1",
|
||||
api_key_env_var="EU_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
ProviderConfig(
|
||||
name="mistral-us",
|
||||
api_base="https://us.mistral.ai/v1",
|
||||
api_key_env_var="US_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://eu.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-eu"}
|
||||
|
||||
def test_falls_back_to_default_when_no_mistral_provider(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-fallback")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="anthropic", api_base="https://api.anthropic.com/v1"
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-fallback"}
|
||||
|
||||
def test_default_providers(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-default")
|
||||
result = vibe_config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
||||
|
||||
def test_returns_none_and_warns_when_api_key_missing(
|
||||
self,
|
||||
vibe_config: VibeConfig,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
with caplog.at_level("WARNING"):
|
||||
assert vibe_config.otel_exporter_config is None
|
||||
assert "OTEL tracing enabled but MISTRAL_API_KEY is not set" in caplog.text
|
||||
|
||||
def test_custom_api_key_env_var(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MY_CUSTOM_KEY", "sk-custom")
|
||||
config = vibe_config.model_copy(
|
||||
update={
|
||||
"providers": [
|
||||
ProviderConfig(
|
||||
name="mistral-onprem",
|
||||
api_base="https://onprem.corp.com/v1",
|
||||
api_key_env_var="MY_CUSTOM_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://onprem.corp.com/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-custom"}
|
||||
|
||||
def test_explicit_otel_endpoint_bypasses_provider_derivation(
|
||||
self, vibe_config: VibeConfig
|
||||
) -> None:
|
||||
config = vibe_config.model_copy(
|
||||
update={"otel_endpoint": "https://my-collector:4318/v1/traces"}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
assert result is not None
|
||||
assert result == OtelExporterConfig(
|
||||
endpoint="https://my-collector:4318/v1/traces"
|
||||
)
|
||||
assert result.headers is None
|
||||
135
tests/core/test_local_config_walk.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.paths._local_config_walk import (
|
||||
_MAX_DIRS,
|
||||
WALK_MAX_DEPTH,
|
||||
has_config_dirs_nearby,
|
||||
walk_local_config_dirs_all,
|
||||
)
|
||||
|
||||
|
||||
class TestBoundedWalk:
|
||||
def test_finds_config_at_root(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".vibe" / "tools" in tools
|
||||
|
||||
def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None:
|
||||
nested = tmp_path
|
||||
for i in range(WALK_MAX_DEPTH):
|
||||
nested = nested / f"level{i}"
|
||||
(nested / ".vibe" / "skills").mkdir(parents=True)
|
||||
_, skills, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert nested / ".vibe" / "skills" in skills
|
||||
|
||||
def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None:
|
||||
nested = tmp_path
|
||||
for i in range(WALK_MAX_DEPTH + 1):
|
||||
nested = nested / f"level{i}"
|
||||
(nested / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert not tools
|
||||
assert not skills
|
||||
assert not agents
|
||||
|
||||
def test_respects_dir_count_limit(self, tmp_path: Path) -> None:
|
||||
# Create more directories than _MAX_DIRS at depth 1
|
||||
for i in range(_MAX_DIRS + 10):
|
||||
(tmp_path / f"dir{i:05d}").mkdir()
|
||||
# Place config in a directory that would be scanned late
|
||||
(tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True)
|
||||
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
# The walk should stop before visiting all dirs.
|
||||
# Whether zzz_last is found depends on sort order and limit,
|
||||
# but total visited dirs should be bounded.
|
||||
# We just verify no crash and the function returns.
|
||||
assert isinstance(tools, tuple)
|
||||
|
||||
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tools == (tmp_path / ".vibe" / "tools",)
|
||||
|
||||
def test_skips_dot_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert not tools
|
||||
|
||||
def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tools == (
|
||||
tmp_path / ".vibe" / "tools",
|
||||
tmp_path / "aaa" / ".vibe" / "tools",
|
||||
tmp_path / "bbb" / ".vibe" / "tools",
|
||||
)
|
||||
|
||||
def test_finds_agents_skills(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
_, skills, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".agents" / "skills" in skills
|
||||
|
||||
def test_finds_all_config_types(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".vibe" / "tools" in tools
|
||||
assert tmp_path / ".vibe" / "skills" in skills
|
||||
assert tmp_path / ".vibe" / "agents" in agents
|
||||
assert tmp_path / ".agents" / "skills" in skills
|
||||
|
||||
|
||||
class TestHasConfigDirsNearby:
|
||||
def test_returns_true_when_vibe_tools_exist(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_vibe_skills_exist(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_true_when_agents_skills_exist(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_false_when_empty(self, tmp_path: Path) -> None:
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
||||
def test_returns_false_for_vibe_dir_without_subdirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
||||
def test_returns_true_for_shallow_nested(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_true_at_depth_2(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_returns_false_beyond_default_depth(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
||||
def test_custom_depth(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path, max_depth=5) is True
|
||||
|
||||
def test_early_exit_on_first_match(self, tmp_path: Path) -> None:
|
||||
# Create many dirs but put config early; function should return quickly
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
for i in range(100):
|
||||
(tmp_path / f"dir{i}").mkdir()
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
|
||||
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.core.slug import _ADJECTIVES, _NOUNS, create_slug
|
||||
from vibe.core.utils.slug import _ADJECTIVES, _NOUNS, create_slug
|
||||
|
||||
|
||||
class TestCreateSlug:
|
||||
|
||||
@@ -9,10 +9,10 @@ import pytest
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
||||
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
|
||||
from vibe.core.tools.base import BaseTool, ToolPermission
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_user_agent
|
||||
|
||||
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
|
||||
@@ -258,6 +258,8 @@ class TestTelemetryClient:
|
||||
nb_mcp_servers=1,
|
||||
nb_models=3,
|
||||
entrypoint="cli",
|
||||
client_name="vscode",
|
||||
client_version="1.96.0",
|
||||
terminal_emulator="vscode",
|
||||
)
|
||||
|
||||
@@ -270,6 +272,8 @@ class TestTelemetryClient:
|
||||
assert properties["nb_mcp_servers"] == 1
|
||||
assert properties["nb_models"] == 3
|
||||
assert properties["entrypoint"] == "cli"
|
||||
assert properties["client_name"] == "vscode"
|
||||
assert properties["client_version"] == "1.96.0"
|
||||
assert properties["terminal_emulator"] == "vscode"
|
||||
assert "version" in properties
|
||||
|
||||
@@ -372,3 +376,41 @@ class TestTelemetryClient:
|
||||
assert (
|
||||
calls[1].kwargs["json"]["properties"]["session_id"] == "second-session-id"
|
||||
)
|
||||
|
||||
def test_send_user_rating_feedback_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_rating_feedback(rating=2, model="mistral-large")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["event_name"] == "vibe.user_rating_feedback"
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["rating"] == 2
|
||||
assert properties["model"] == "mistral-large"
|
||||
assert "version" in properties
|
||||
|
||||
def test_send_user_rating_feedback_includes_correlation_id(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
client.last_correlation_id = "corr-abc-123"
|
||||
|
||||
client.send_user_rating_feedback(rating=1, model="mistral-large")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert telemetry_events[0]["correlation_id"] == "corr-abc-123"
|
||||
|
||||
def test_send_user_rating_feedback_omits_correlation_id_when_none(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_user_rating_feedback(rating=1, model="mistral-large")
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert "correlation_id" not in telemetry_events[0]
|
||||
|
||||
107
tests/core/test_tts_config.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.config import (
|
||||
DEFAULT_TTS_MODELS,
|
||||
DEFAULT_TTS_PROVIDERS,
|
||||
TTSClient,
|
||||
TTSModelConfig,
|
||||
TTSProviderConfig,
|
||||
)
|
||||
|
||||
|
||||
class TestTTSConfigDefaults:
|
||||
def test_default_tts_providers_loaded(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
assert len(config.tts_providers) == len(DEFAULT_TTS_PROVIDERS)
|
||||
assert config.tts_providers[0].name == "mistral"
|
||||
assert config.tts_providers[0].api_base == "https://api.mistral.ai"
|
||||
|
||||
def test_default_tts_models_loaded(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
assert len(config.tts_models) == len(DEFAULT_TTS_MODELS)
|
||||
assert config.tts_models[0].alias == "voxtral-tts"
|
||||
assert config.tts_models[0].name == "voxtral-mini-tts-latest"
|
||||
|
||||
def test_default_active_tts_model(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
assert config.active_tts_model == "voxtral-tts"
|
||||
|
||||
|
||||
class TestGetActiveTTSModel:
|
||||
def test_resolves_by_alias(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
model = config.get_active_tts_model()
|
||||
assert model.alias == "voxtral-tts"
|
||||
assert model.name == "voxtral-mini-tts-latest"
|
||||
|
||||
def test_raises_for_unknown_alias(self) -> None:
|
||||
config = build_test_vibe_config(active_tts_model="nonexistent")
|
||||
with pytest.raises(ValueError, match="not found in configuration"):
|
||||
config.get_active_tts_model()
|
||||
|
||||
|
||||
class TestGetTTSProviderForModel:
|
||||
def test_resolves_by_name(self) -> None:
|
||||
config = build_test_vibe_config()
|
||||
model = config.get_active_tts_model()
|
||||
provider = config.get_tts_provider_for_model(model)
|
||||
assert provider.name == "mistral"
|
||||
assert provider.api_base == "https://api.mistral.ai"
|
||||
|
||||
def test_raises_for_unknown_provider(self) -> None:
|
||||
config = build_test_vibe_config(
|
||||
tts_models=[
|
||||
TTSModelConfig(name="test-model", provider="nonexistent", alias="test")
|
||||
],
|
||||
active_tts_model="test",
|
||||
)
|
||||
model = config.get_active_tts_model()
|
||||
with pytest.raises(ValueError, match="not found in configuration"):
|
||||
config.get_tts_provider_for_model(model)
|
||||
|
||||
|
||||
class TestTTSModelUniqueness:
|
||||
def test_duplicate_aliases_raise(self) -> None:
|
||||
with pytest.raises(ValueError, match="Duplicate TTS model alias"):
|
||||
build_test_vibe_config(
|
||||
tts_models=[
|
||||
TTSModelConfig(
|
||||
name="model-a", provider="mistral", alias="same-alias"
|
||||
),
|
||||
TTSModelConfig(
|
||||
name="model-b", provider="mistral", alias="same-alias"
|
||||
),
|
||||
],
|
||||
active_tts_model="same-alias",
|
||||
)
|
||||
|
||||
|
||||
class TestTTSModelConfig:
|
||||
def test_alias_defaults_to_name(self) -> None:
|
||||
model = TTSModelConfig.model_validate({
|
||||
"name": "my-model",
|
||||
"provider": "mistral",
|
||||
})
|
||||
assert model.alias == "my-model"
|
||||
|
||||
def test_explicit_alias(self) -> None:
|
||||
model = TTSModelConfig(
|
||||
name="my-model", provider="mistral", alias="custom-alias"
|
||||
)
|
||||
assert model.alias == "custom-alias"
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
model = TTSModelConfig(name="my-model", provider="mistral", alias="my-model")
|
||||
assert model.voice == "gb_jane_neutral"
|
||||
assert model.response_format == "wav"
|
||||
|
||||
|
||||
class TestTTSProviderConfig:
|
||||
def test_default_values(self) -> None:
|
||||
provider = TTSProviderConfig(name="test")
|
||||
assert provider.api_base == "https://api.mistral.ai"
|
||||
assert provider.api_key_env_var == ""
|
||||
assert provider.client == TTSClient.MISTRAL
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -17,3 +20,45 @@ from vibe.core.utils import get_server_url_from_api_base
|
||||
)
|
||||
def test_get_server_url_from_api_base(api_base, expected):
|
||||
assert get_server_url_from_api_base(api_base) == expected
|
||||
|
||||
|
||||
class TestReadSafe:
|
||||
def test_reads_utf8(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "hello.txt"
|
||||
f.write_text("café\n", encoding="utf-8")
|
||||
assert read_safe(f) == "café\n"
|
||||
|
||||
def test_falls_back_on_non_utf8(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "latin.txt"
|
||||
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
|
||||
f.write_bytes(b"maf\x81\n")
|
||||
result = read_safe(f)
|
||||
assert result == "maf<EFBFBD>\n"
|
||||
|
||||
def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "hello.txt"
|
||||
f.write_text("café\n", encoding="utf-8")
|
||||
assert read_safe(f, raise_on_error=True) == "café\n"
|
||||
|
||||
def test_raise_on_error_true_non_utf8_raises(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "bad.txt"
|
||||
# Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate
|
||||
f.write_bytes(b"maf\x81\n")
|
||||
assert read_safe(f, raise_on_error=False) == "maf<EFBFBD>\n"
|
||||
with pytest.raises(UnicodeDecodeError):
|
||||
read_safe(f, raise_on_error=True)
|
||||
|
||||
def test_empty_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "empty.txt"
|
||||
f.write_bytes(b"")
|
||||
assert read_safe(f) == ""
|
||||
|
||||
def test_binary_garbage_does_not_raise(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "garbage.bin"
|
||||
f.write_bytes(bytes(range(256)))
|
||||
result = read_safe(f)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_file_not_found_raises(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_safe(tmp_path / "nope.txt")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import AbstractContextManager
|
||||
import io
|
||||
from pathlib import Path
|
||||
@@ -13,7 +13,7 @@ import pexpect
|
||||
|
||||
class SpawnedVibeProcessFixture(Protocol):
|
||||
def __call__(
|
||||
self, workdir: Path
|
||||
self, workdir: Path, extra_args: Sequence[str] | None = None
|
||||
) -> AbstractContextManager[tuple[pexpect.spawn, io.StringIO]]: ...
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
import io
|
||||
import os
|
||||
@@ -52,17 +52,21 @@ type SpawnedVibeContext = Iterator[tuple[pexpect.spawn, io.StringIO]]
|
||||
type SpawnedVibeContextManager = AbstractContextManager[
|
||||
tuple[pexpect.spawn, io.StringIO]
|
||||
]
|
||||
type SpawnedVibeFactory = Callable[[Path], SpawnedVibeContextManager]
|
||||
type SpawnedVibeFactory = Callable[
|
||||
[Path, Sequence[str] | None], SpawnedVibeContextManager
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spawned_vibe_process() -> SpawnedVibeFactory:
|
||||
@contextmanager
|
||||
def spawn(workdir: Path) -> SpawnedVibeContext:
|
||||
def spawn(
|
||||
workdir: Path, extra_args: Sequence[str] | None = None
|
||||
) -> SpawnedVibeContext:
|
||||
captured = io.StringIO()
|
||||
child = pexpect.spawn(
|
||||
"uv",
|
||||
["run", "vibe", "--workdir", str(workdir)],
|
||||
["run", "vibe", "--workdir", str(workdir), *(extra_args or [])],
|
||||
cwd=str(TESTS_ROOT.parent),
|
||||
env=os.environ,
|
||||
encoding="utf-8",
|
||||
|
||||
138
tests/e2e/test_cli_tui_session_exit.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
|
||||
import pexpect
|
||||
import pytest
|
||||
|
||||
from tests.e2e.common import (
|
||||
SpawnedVibeProcessFixture,
|
||||
ansi_tolerant_pattern,
|
||||
strip_ansi,
|
||||
wait_for_main_screen,
|
||||
wait_for_request_count,
|
||||
)
|
||||
from tests.e2e.mock_server import StreamingMockServer
|
||||
|
||||
|
||||
def _usage_by_run_factory(
|
||||
request_index: int, _payload: object
|
||||
) -> list[dict[str, object]]:
|
||||
return [
|
||||
StreamingMockServer.build_chunk(
|
||||
created=123,
|
||||
delta={"role": "assistant", "content": f"Reply {request_index + 1}"},
|
||||
finish_reason=None,
|
||||
),
|
||||
StreamingMockServer.build_chunk(
|
||||
created=124,
|
||||
delta={},
|
||||
finish_reason="stop",
|
||||
usage=(
|
||||
{"prompt_tokens": 11, "completion_tokens": 7}
|
||||
if request_index == 0
|
||||
else {"prompt_tokens": 2, "completion_tokens": 1}
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _finish_turn(
|
||||
child: pexpect.spawn,
|
||||
captured: io.StringIO,
|
||||
expected_reply: str,
|
||||
expected_request_count: int,
|
||||
request_count_getter: Callable[[], int],
|
||||
) -> None:
|
||||
wait_for_request_count(
|
||||
request_count_getter, expected_count=expected_request_count, timeout=10
|
||||
)
|
||||
child.expect(ansi_tolerant_pattern(expected_reply), timeout=10)
|
||||
|
||||
start = time.monotonic()
|
||||
last_change = start
|
||||
last_size = len(captured.getvalue())
|
||||
|
||||
while time.monotonic() - start < 5:
|
||||
try:
|
||||
child.expect(r"\S", timeout=0.05)
|
||||
except pexpect.TIMEOUT:
|
||||
pass
|
||||
|
||||
current_size = len(captured.getvalue())
|
||||
if current_size != last_size:
|
||||
last_size = current_size
|
||||
last_change = time.monotonic()
|
||||
continue
|
||||
|
||||
if time.monotonic() - last_change >= 0.3:
|
||||
return
|
||||
|
||||
rendered_tail = strip_ansi(captured.getvalue())[-1200:]
|
||||
raise AssertionError(
|
||||
f"Timed out waiting for the turn to finish.\n\nRendered tail:\n{rendered_tail}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30)
|
||||
@pytest.mark.parametrize(
|
||||
"streaming_mock_server",
|
||||
[pytest.param(_usage_by_run_factory, id="fresh-usage-after-resume")],
|
||||
indirect=True,
|
||||
)
|
||||
def test_resumed_session_prints_only_fresh_token_usage_on_exit(
|
||||
streaming_mock_server: StreamingMockServer,
|
||||
setup_e2e_env: None,
|
||||
e2e_workdir: Path,
|
||||
spawned_vibe_process: SpawnedVibeProcessFixture,
|
||||
) -> None:
|
||||
with spawned_vibe_process(e2e_workdir) as (child, captured):
|
||||
wait_for_main_screen(child, timeout=15)
|
||||
child.send("First run")
|
||||
child.send("\r")
|
||||
|
||||
_finish_turn(
|
||||
child,
|
||||
captured,
|
||||
expected_reply="Reply 1",
|
||||
expected_request_count=1,
|
||||
request_count_getter=lambda: len(streaming_mock_server.requests),
|
||||
)
|
||||
|
||||
child.sendcontrol("c")
|
||||
child.expect(pexpect.EOF, timeout=10)
|
||||
|
||||
first_output = strip_ansi(captured.getvalue())
|
||||
resume_match = re.search(r"Or: vibe --resume ([0-9a-f-]+)", first_output)
|
||||
assert resume_match is not None
|
||||
session_id = resume_match.group(1)
|
||||
assert (
|
||||
"Total tokens used this session: input=11 output=7 (total=18)" in first_output
|
||||
)
|
||||
|
||||
with spawned_vibe_process(e2e_workdir, extra_args=["--resume", session_id]) as (
|
||||
resumed_child,
|
||||
resumed_captured,
|
||||
):
|
||||
wait_for_main_screen(resumed_child, timeout=15)
|
||||
resumed_child.send("Second run")
|
||||
resumed_child.send("\r")
|
||||
|
||||
_finish_turn(
|
||||
resumed_child,
|
||||
resumed_captured,
|
||||
expected_reply="Reply 2",
|
||||
expected_request_count=2,
|
||||
request_count_getter=lambda: len(streaming_mock_server.requests),
|
||||
)
|
||||
|
||||
resumed_child.sendcontrol("c")
|
||||
resumed_child.expect(pexpect.EOF, timeout=10)
|
||||
|
||||
second_output = strip_ansi(resumed_captured.getvalue())
|
||||
|
||||
assert "Total tokens used this session: input=2 output=1 (total=3)" in second_output
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -1008,12 +1008,13 @@ class TestSessionLoaderUTF8Encoding:
|
||||
session_folder = session_dir / "test_20230101_120000_latin100"
|
||||
session_folder.mkdir()
|
||||
|
||||
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
|
||||
metadata_content = {
|
||||
"session_id": "latin1-test",
|
||||
"start_time": "2023-01-01T12:00:00Z",
|
||||
"end_time": "2023-01-01T12:05:00Z",
|
||||
"username": "testuser",
|
||||
"environment": {"working_directory": "/home/user/café_project"},
|
||||
"environment": {"working_directory": "/home/user/caf\x81_project"},
|
||||
"git_commit": None,
|
||||
"git_branch": None,
|
||||
}
|
||||
@@ -1027,7 +1028,7 @@ class TestSessionLoaderUTF8Encoding:
|
||||
|
||||
metadata = SessionLoader.load_metadata(session_folder)
|
||||
assert metadata.session_id == "latin1-test"
|
||||
assert metadata.environment["working_directory"] == "/home/user/caf_project"
|
||||
assert metadata.environment["working_directory"] == "/home/user/caf<EFBFBD>_project"
|
||||
|
||||
def test_load_session_with_utf8_metadata_and_messages(
|
||||
self, session_config: SessionLoggingConfig
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
.terminal-r6 { fill: #d2d2d2 }
|
||||
.terminal-r7 { fill: #292929 }
|
||||
.terminal-r8 { fill: #4b4e55 }
|
||||
.terminal-r9 { fill: #d0b344;font-weight: bold }
|
||||
.terminal-r9 { fill: #98a84b;font-weight: bold }
|
||||
.terminal-r10 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,200 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="556.8" textLength="146.4" clip-path="url(#terminal-line-22)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="556.8" textLength="122" clip-path="url(#terminal-line-22)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="556.8" textLength="183" clip-path="url(#terminal-line-22)">devstral-latest</text><text class="terminal-r1" x="622.2" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="581.2" textLength="414.8" clip-path="url(#terminal-line-23)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">Type </text><text class="terminal-r3" x="231.8" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">/help</text><text class="terminal-r1" x="292.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)"> for more information</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎣</text><text class="terminal-r1" x="48.8" y="654.4" textLength="280.6" clip-path="url(#terminal-line-26)">Configuration opened...</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="488" clip-path="url(#terminal-line-27)">Configuration closed (no changes saved).</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="1220" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,204 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #c5c8c6;font-weight: bold }
|
||||
.terminal-r7 { fill: #98a84b;font-weight: bold }
|
||||
.terminal-r8 { fill: #868887 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#608ab1" x="36.6" y="709.1" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="122" y="709.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="305" y="709.1" width="878.4" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="622.2" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> for more information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎣</text><text class="terminal-r1" x="48.8" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Configuration opened...</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="1220" clip-path="url(#terminal-line-26)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">Settings</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r6" x="36.6" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">Model: </text><text class="terminal-r6" x="122" y="727.6" textLength="183" clip-path="url(#terminal-line-29)">devstral-latest</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="36.6" y="752" textLength="134.2" clip-path="url(#terminal-line-30)">Auto-copy: </text><text class="terminal-r7" x="170.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">On</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="36.6" y="776.4" textLength="671" clip-path="url(#terminal-line-31)">Autocomplete watcher (may delay first autocompletion): </text><text class="terminal-r8" x="707.6" y="776.4" textLength="36.6" clip-path="url(#terminal-line-31)">Off</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select/Toggle  Esc Exit</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,204 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #c5c8c6;font-weight: bold }
|
||||
.terminal-r7 { fill: #98a84b;font-weight: bold }
|
||||
.terminal-r8 { fill: #868887 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#608ab1" x="36.6" y="733.5" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="170.8" y="733.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="195.2" y="733.5" width="988.2" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="622.2" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> for more information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎣</text><text class="terminal-r1" x="48.8" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Configuration opened...</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="1220" clip-path="url(#terminal-line-26)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">Settings</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="36.6" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">Model: </text><text class="terminal-r6" x="122" y="727.6" textLength="183" clip-path="url(#terminal-line-29)">devstral-latest</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r6" x="36.6" y="752" textLength="134.2" clip-path="url(#terminal-line-30)">Auto-copy: </text><text class="terminal-r7" x="170.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">On</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="36.6" y="776.4" textLength="671" clip-path="url(#terminal-line-31)">Autocomplete watcher (may delay first autocompletion): </text><text class="terminal-r8" x="707.6" y="776.4" textLength="36.6" clip-path="url(#terminal-line-31)">Off</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select/Toggle  Esc Exit</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,204 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #c5c8c6;font-weight: bold }
|
||||
.terminal-r7 { fill: #9cafbd;font-weight: bold }
|
||||
.terminal-r8 { fill: #868887 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ConfigTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#608ab1" x="36.6" y="733.5" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="170.8" y="733.5" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="207.4" y="733.5" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="622.2" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> for more information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎣</text><text class="terminal-r1" x="48.8" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">Configuration opened...</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="1220" clip-path="url(#terminal-line-26)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r5" x="24.4" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">Settings</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="36.6" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">Model: </text><text class="terminal-r6" x="122" y="727.6" textLength="183" clip-path="url(#terminal-line-29)">devstral-latest</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r6" x="36.6" y="752" textLength="134.2" clip-path="url(#terminal-line-30)">Auto-copy: </text><text class="terminal-r7" x="170.8" y="752" textLength="36.6" clip-path="url(#terminal-line-30)">Off</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="36.6" y="776.4" textLength="671" clip-path="url(#terminal-line-31)">Autocomplete watcher (may delay first autocompletion): </text><text class="terminal-r8" x="707.6" y="776.4" textLength="36.6" clip-path="url(#terminal-line-31)">Off</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="24.4" y="825.2" textLength="512.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select/Toggle  Esc Exit</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,202 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #ff8205 }
|
||||
.terminal-r5 { fill: #608ab1 }
|
||||
.terminal-r6 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1463.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">FeedbackBarSnapshotApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="329.4" clip-path="url(#terminal-line-27)">Sure, I can help with that.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="841.8" y="727.6" textLength="329.4" clip-path="url(#terminal-line-29)">How is Vibe doing so far?  </text><text class="terminal-r5" x="1171.2" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">1</text><text class="terminal-r1" x="1183.4" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">: good  </text><text class="terminal-r5" x="1281" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">2</text><text class="terminal-r1" x="1293.2" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">: fine  </text><text class="terminal-r5" x="1390.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">3</text><text class="terminal-r1" x="1403" y="727.6" textLength="61" clip-path="url(#terminal-line-29)">: bad</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r6" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r6" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r6" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r6" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r6" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r6" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r6" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r6" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r6" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r6" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,200 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="605.6" textLength="146.4" clip-path="url(#terminal-line-24)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="605.6" textLength="122" clip-path="url(#terminal-line-24)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="605.6" textLength="97.6" clip-path="url(#terminal-line-24)">devstral</text><text class="terminal-r1" x="536.8" y="605.6" textLength="256.2" clip-path="url(#terminal-line-24)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="630" textLength="427" clip-path="url(#terminal-line-25)">5 models · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="0" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">Type </text><text class="terminal-r3" x="231.8" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/help</text><text class="terminal-r1" x="292.8" y="654.4" textLength="256.2" clip-path="url(#terminal-line-26)"> for more information</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="1220" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,203 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #98a84b;font-weight: bold }
|
||||
.terminal-r7 { fill: #c5c8c6;font-weight: bold }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#608ab1" x="36.6" y="684.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="61" y="684.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="158.6" y="684.7" width="1024.8" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="483.6" textLength="97.6" clip-path="url(#terminal-line-19)">devstral</text><text class="terminal-r1" x="536.8" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="427" clip-path="url(#terminal-line-20)">5 models · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> for more information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="1220" clip-path="url(#terminal-line-25)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r5" x="24.4" y="654.4" textLength="146.4" clip-path="url(#terminal-line-26)">Select Model</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="36.6" y="678.8" textLength="183" clip-path="url(#terminal-line-27)">  mistral-large</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r6" x="36.6" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">› </text><text class="terminal-r7" x="61" y="703.2" textLength="97.6" clip-path="url(#terminal-line-28)">devstral</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="36.6" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">  codestral</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="36.6" y="752" textLength="183" clip-path="url(#terminal-line-30)">  mistral-small</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="36.6" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">  local</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="24.4" y="825.2" textLength="451.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select  Esc Cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,203 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
.terminal-r5 { fill: #608ab1;font-weight: bold }
|
||||
.terminal-r6 { fill: #98a84b }
|
||||
.terminal-r7 { fill: #c5c8c6;font-weight: bold }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#608ab1" x="36.6" y="709.1" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="170.8" y="709.1" width="1012.6" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="483.6" textLength="97.6" clip-path="url(#terminal-line-19)">devstral</text><text class="terminal-r1" x="536.8" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="427" clip-path="url(#terminal-line-20)">5 models · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> for more information</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="1220" clip-path="url(#terminal-line-25)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r5" x="24.4" y="654.4" textLength="146.4" clip-path="url(#terminal-line-26)">Select Model</text><text class="terminal-r4" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="36.6" y="678.8" textLength="183" clip-path="url(#terminal-line-27)">  mistral-large</text><text class="terminal-r4" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r4" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r6" x="36.6" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">› </text><text class="terminal-r7" x="61" y="703.2" textLength="97.6" clip-path="url(#terminal-line-28)">devstral</text><text class="terminal-r4" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r4" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r7" x="36.6" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">  codestral</text><text class="terminal-r4" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="36.6" y="752" textLength="183" clip-path="url(#terminal-line-30)">  mistral-small</text><text class="terminal-r4" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="36.6" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">  local</text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="24.4" y="825.2" textLength="451.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select  Esc Cancel</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,200 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1238 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1219.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1220" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">ModelPickerTestApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="581.2" textLength="146.4" clip-path="url(#terminal-line-23)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="581.2" textLength="122" clip-path="url(#terminal-line-23)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="581.2" textLength="183" clip-path="url(#terminal-line-23)">devstral-latest</text><text class="terminal-r1" x="622.2" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)"> · [Subscription] Pro</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="605.6" textLength="414.8" clip-path="url(#terminal-line-24)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="0" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="630" textLength="61" clip-path="url(#terminal-line-25)">Type </text><text class="terminal-r3" x="231.8" y="630" textLength="61" clip-path="url(#terminal-line-25)">/help</text><text class="terminal-r1" x="292.8" y="630" textLength="256.2" clip-path="url(#terminal-line-25)"> for more information</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="280.6" clip-path="url(#terminal-line-27)">Configuration reloaded.</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r4" x="0" y="752" textLength="1220" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r4" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r4" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r4" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r4" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r4" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r4" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r4" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r4" x="1012.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,201 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #ff8205 }
|
||||
.terminal-r5 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1463.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">NarratorFlowApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello! I can help you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r5" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r5" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r5" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r5" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r5" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r5" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r5" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r5" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,203 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #ff8205 }
|
||||
.terminal-r5 { fill: #ffa500;font-weight: bold }
|
||||
.terminal-r6 { fill: #868887 }
|
||||
.terminal-r7 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1463.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">NarratorFlowApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello! I can help you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r5" x="12.2" y="727.6" textLength="36.6" clip-path="url(#terminal-line-29)">▂▅▇</text><text class="terminal-r1" x="48.8" y="727.6" textLength="122" clip-path="url(#terminal-line-29)"> speaking </text><text class="terminal-r6" x="170.8" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">esc to stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r7" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r7" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r7" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r7" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r7" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r7" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r7" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r7" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,203 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #ff8205 }
|
||||
.terminal-r5 { fill: #ffa500;font-weight: bold }
|
||||
.terminal-r6 { fill: #868887 }
|
||||
.terminal-r7 { fill: #9a9b99 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1463.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">NarratorFlowApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">Hello</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello! I can help you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r5" x="12.2" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">█</text><text class="terminal-r1" x="24.4" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)"> summarizing </text><text class="terminal-r6" x="183" y="727.6" textLength="134.2" clip-path="url(#terminal-line-29)">esc to stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r7" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r7" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r7" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r7" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r7" x="1451.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r7" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r7" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r7" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">6% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -180,13 +180,13 @@
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type </text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)"> for more information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r2" x="24.4" y="654.4" textLength="73.2" clip-path="url(#terminal-line-26)">/voice</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="73.2" clip-path="url(#terminal-line-25)">/voice</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎣</text><text class="terminal-r1" x="48.8" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Voice settings opened...</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="244" clip-path="url(#terminal-line-27)">Voice mode disabled.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
@@ -180,13 +180,13 @@
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="532.4" textLength="146.4" clip-path="url(#terminal-line-21)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="532.4" textLength="122" clip-path="url(#terminal-line-21)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="532.4" textLength="183" clip-path="url(#terminal-line-21)">devstral-latest</text><text class="terminal-r1" x="622.2" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="0" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">Type </text><text class="terminal-r3" x="231.8" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/help</text><text class="terminal-r1" x="292.8" y="581.2" textLength="256.2" clip-path="url(#terminal-line-23)"> for more information</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="183" clip-path="url(#terminal-line-20)">devstral-latest</text><text class="terminal-r1" x="622.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r2" x="24.4" y="654.4" textLength="73.2" clip-path="url(#terminal-line-26)">/voice</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r2" x="24.4" y="630" textLength="73.2" clip-path="url(#terminal-line-25)">/voice</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎣</text><text class="terminal-r1" x="48.8" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Voice settings opened...</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="634.4" clip-path="url(#terminal-line-27)">Voice mode enabled. Press ctrl+r to start recording.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
63
tests/snapshots/test_ui_snapshot_config_app.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
|
||||
|
||||
class ConfigTestApp(BaseSnapshotTestApp):
|
||||
async def on_mount(self) -> None:
|
||||
await super().on_mount()
|
||||
await self._switch_to_config_app()
|
||||
|
||||
|
||||
def test_snapshot_config_initial(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_config_app.py:ConfigTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_config_navigate_down(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("down")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_config_app.py:ConfigTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_config_toggle_autocopy(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_config_app.py:ConfigTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_config_escape_closes(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_config_app.py:ConfigTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
46
tests/snapshots/test_ui_snapshot_feedback_bar.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 1
|
||||
)
|
||||
|
||||
|
||||
class FeedbackBarSnapshotApp(BaseSnapshotTestApp):
|
||||
def __init__(self) -> None:
|
||||
fake_backend = FakeBackend(
|
||||
mock_llm_chunk(
|
||||
content="Sure, I can help with that.",
|
||||
prompt_tokens=10_000,
|
||||
completion_tokens=2_500,
|
||||
)
|
||||
)
|
||||
super().__init__(backend=fake_backend)
|
||||
|
||||
|
||||
def test_snapshot_feedback_bar_visible(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0
|
||||
):
|
||||
await pilot.press(*"Hello")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.4)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_feedback_bar.py:FeedbackBarSnapshotApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
95
tests/snapshots/test_ui_snapshot_model_picker.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.core.config._settings import ModelConfig
|
||||
|
||||
|
||||
def _model_picker_config():
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="mistral-large-latest", provider="mistral", alias="mistral-large"
|
||||
),
|
||||
ModelConfig(name="devstral-latest", provider="mistral", alias="devstral"),
|
||||
ModelConfig(name="codestral-latest", provider="mistral", alias="codestral"),
|
||||
ModelConfig(
|
||||
name="mistral-small-latest", provider="mistral", alias="mistral-small"
|
||||
),
|
||||
ModelConfig(name="devstral", provider="llamacpp", alias="local"),
|
||||
]
|
||||
return build_test_vibe_config(
|
||||
models=models,
|
||||
active_model="devstral",
|
||||
disable_welcome_banner_animation=True,
|
||||
displayed_workdir="/test/workdir",
|
||||
)
|
||||
|
||||
|
||||
class ModelPickerTestApp(BaseSnapshotTestApp):
|
||||
def __init__(self):
|
||||
super().__init__(config=_model_picker_config())
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
await super().on_mount()
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
|
||||
def test_snapshot_model_picker_initial(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_model_picker_navigate_down(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("down")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_model_picker_select_different_model(
|
||||
snap_compare: SnapCompare,
|
||||
) -> None:
|
||||
"""Select the second model and verify the picker closes back to input."""
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates"):
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_model_picker_escape_cancels(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_model_picker.py:ModelPickerTestApp",
|
||||
terminal_size=(100, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
148
tests/snapshots/test_ui_snapshot_narrator_flow.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.stubs.fake_audio_player import FakeAudioPlayer
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_tts_client import FakeTTSClient
|
||||
import vibe.cli.textual_ui.widgets.narrator_status as narrator_status_mod
|
||||
from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus
|
||||
from vibe.cli.turn_summary import TurnSummaryTracker
|
||||
|
||||
narrator_status_mod.SHRINK_FRAMES = "█"
|
||||
narrator_status_mod.BAR_FRAMES = ["▂▅▇"]
|
||||
from vibe.core.config import ModelConfig
|
||||
from vibe.core.tts.tts_client_port import TTSResult
|
||||
from vibe.core.types import LLMChunk
|
||||
|
||||
_TEST_MODEL = ModelConfig(name="test-model", provider="test", alias="test-model")
|
||||
|
||||
|
||||
def _narrator_config():
|
||||
return build_test_vibe_config(
|
||||
narrator_enabled=True,
|
||||
disable_welcome_banner_animation=True,
|
||||
displayed_workdir="/test/workdir",
|
||||
)
|
||||
|
||||
|
||||
class GatedBackend(FakeBackend):
|
||||
def __init__(self, chunks: LLMChunk) -> None:
|
||||
super().__init__(chunks)
|
||||
self._gate = asyncio.Event()
|
||||
|
||||
def release(self) -> None:
|
||||
self._gate.set()
|
||||
|
||||
async def complete(self, **kwargs: Any) -> LLMChunk:
|
||||
await self._gate.wait()
|
||||
return await super().complete(**kwargs)
|
||||
|
||||
|
||||
class GatedTTSClient(FakeTTSClient):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._gate = asyncio.Event()
|
||||
|
||||
def release(self) -> None:
|
||||
self._gate.set()
|
||||
|
||||
async def speak(self, text: str) -> TTSResult:
|
||||
await self._gate.wait()
|
||||
return await super().speak(text)
|
||||
|
||||
|
||||
class NarratorFlowApp(BaseSnapshotTestApp):
|
||||
def __init__(self) -> None:
|
||||
self.summary_gate = GatedBackend(
|
||||
mock_llm_chunk(content="Summary of the conversation")
|
||||
)
|
||||
self.tts_gate = GatedTTSClient()
|
||||
super().__init__(
|
||||
config=_narrator_config(),
|
||||
backend=FakeBackend(
|
||||
mock_llm_chunk(
|
||||
content="Hello! I can help you.",
|
||||
prompt_tokens=10_000,
|
||||
completion_tokens=2_500,
|
||||
)
|
||||
),
|
||||
)
|
||||
self._tts_client = self.tts_gate
|
||||
self._audio_player = FakeAudioPlayer()
|
||||
self._turn_summary = TurnSummaryTracker(
|
||||
backend=self.summary_gate,
|
||||
model=_TEST_MODEL,
|
||||
on_summary=self._on_turn_summary,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_narrator_summarizing(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
app = cast(NarratorFlowApp, pilot.app)
|
||||
# Send message and wait for agent response to complete
|
||||
await pilot.press(*"Hello")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.5)
|
||||
# end_turn has fired, SUMMARIZING is set, summary backend is gated
|
||||
assert app.summary_gate._gate.is_set() is False
|
||||
# Freeze animation at frame 0 for deterministic snapshot
|
||||
app.query_one(NarratorStatus)._stop_timer()
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_narrator_speaking(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
app = cast(NarratorFlowApp, pilot.app)
|
||||
await pilot.press(*"Hello")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.5)
|
||||
# Release summary gate → summary resolves → speak task starts → blocks on TTS gate
|
||||
app.summary_gate.release()
|
||||
await pilot.pause(0.2)
|
||||
# Release TTS gate → TTS resolves → SPEAKING set
|
||||
app.tts_gate.release()
|
||||
await pilot.pause(0.2)
|
||||
# Freeze animation at frame 0 for deterministic snapshot
|
||||
app.query_one(NarratorStatus)._stop_timer()
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_narrator_idle_after_speaking(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
app = cast(NarratorFlowApp, pilot.app)
|
||||
await pilot.press(*"Hello")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.5)
|
||||
# Release both gates to reach SPEAKING
|
||||
app.summary_gate.release()
|
||||
await pilot.pause(0.2)
|
||||
app.tts_gate.release()
|
||||
await pilot.pause(0.2)
|
||||
# Simulate playback finishing (same thread, so call directly)
|
||||
app._audio_player.stop()
|
||||
app._set_narrator_state(NarratorState.IDLE)
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_narrator_flow.py:NarratorFlowApp",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
@@ -17,7 +18,14 @@ class VoiceEnableApp(BaseSnapshotTestApp):
|
||||
|
||||
class VoiceDisableApp(BaseSnapshotTestApp):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=True))
|
||||
config = build_test_vibe_config(
|
||||
disable_welcome_banner_animation=True,
|
||||
displayed_workdir="/test/workdir",
|
||||
voice_mode_enabled=True,
|
||||
)
|
||||
super().__init__(
|
||||
config=config, voice_manager=FakeVoiceManager(is_voice_ready=True)
|
||||
)
|
||||
|
||||
|
||||
class RecordingActiveApp(BaseSnapshotTestApp):
|
||||
@@ -49,6 +57,10 @@ def test_snapshot_voice_enable(snap_compare: SnapCompare) -> None:
|
||||
await pilot.press(*"/voice")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.4)
|
||||
await pilot.press("space")
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.4)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_voice_mode.py:VoiceEnableApp",
|
||||
@@ -62,6 +74,10 @@ def test_snapshot_voice_disable(snap_compare: SnapCompare) -> None:
|
||||
await pilot.press(*"/voice")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.4)
|
||||
await pilot.press("space")
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.4)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_voice_mode.py:VoiceDisableApp",
|
||||
|
||||
36
tests/stubs/fake_audio_player.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from vibe.core.audio_player import AlreadyPlayingError
|
||||
from vibe.core.audio_player.audio_player_port import AudioFormat
|
||||
|
||||
|
||||
class FakeAudioPlayer:
|
||||
def __init__(self) -> None:
|
||||
self._playing = False
|
||||
self._on_finished: Callable[[], object] | None = None
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool:
|
||||
return self._playing
|
||||
|
||||
def play(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
audio_format: AudioFormat,
|
||||
*,
|
||||
on_finished: Callable[[], object] | None = None,
|
||||
) -> None:
|
||||
if self._playing:
|
||||
raise AlreadyPlayingError("Already playing")
|
||||
self._playing = True
|
||||
self._on_finished = on_finished
|
||||
|
||||
def stop(self) -> None:
|
||||
self._playing = False
|
||||
|
||||
def simulate_finished(self) -> None:
|
||||
self._playing = False
|
||||
if self._on_finished is not None:
|
||||
self._on_finished()
|
||||
@@ -6,7 +6,7 @@ from acp import (
|
||||
Agent as AcpAgent,
|
||||
Client,
|
||||
CreateTerminalResponse,
|
||||
KillTerminalCommandResponse,
|
||||
KillTerminalResponse,
|
||||
ReadTextFileResponse,
|
||||
ReleaseTerminalResponse,
|
||||
RequestPermissionResponse,
|
||||
@@ -112,7 +112,7 @@ class FakeClient(Client):
|
||||
|
||||
async def kill_terminal(
|
||||
self, session_id: str, terminal_id: str, **kwargs: Any
|
||||
) -> KillTerminalCommandResponse | None:
|
||||
) -> KillTerminalResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
21
tests/stubs/fake_tts_client.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from vibe.core.tts.tts_client_port import TTSResult
|
||||
|
||||
|
||||
class FakeTTSClient:
|
||||
def __init__(
|
||||
self, *_args: Any, result: TTSResult | None = None, **_kwargs: Any
|
||||
) -> None:
|
||||
self._result: TTSResult = result or TTSResult(audio_data=b"fake-audio")
|
||||
|
||||
def set_result(self, result: TTSResult) -> None:
|
||||
self._result = result
|
||||
|
||||
async def speak(self, text: str) -> TTSResult:
|
||||
return self._result
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
@@ -10,11 +10,17 @@ from mcp.types import (
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from tests.conftest import (
|
||||
build_test_agent_loop,
|
||||
build_test_vibe_config,
|
||||
make_test_models,
|
||||
)
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import EntrypointMetadata
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import Backend, EntrypointMetadata, FunctionCall, ToolCall
|
||||
|
||||
|
||||
def _two_model_vibe_config(active_model: str) -> VibeConfig:
|
||||
@@ -119,7 +125,12 @@ async def test_passes_session_id_to_backend(vibe_config: VibeConfig):
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert len(backend.requests_metadata) > 0
|
||||
assert backend.requests_metadata[0] == {"session_id": agent.session_id}
|
||||
meta = backend.requests_metadata[0]
|
||||
assert meta is not None
|
||||
assert meta["session_id"] == agent.session_id
|
||||
assert "message_id" in meta
|
||||
assert meta["is_user_prompt"] == "true"
|
||||
assert meta["call_type"] == "main_call"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -141,13 +152,16 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert len(backend.requests_metadata) > 0
|
||||
assert backend.requests_metadata[0] == {
|
||||
"agent_entrypoint": "acp",
|
||||
"agent_version": "2.0.0",
|
||||
"client_name": "vibe_ide",
|
||||
"client_version": "0.5.0",
|
||||
"session_id": agent.session_id,
|
||||
}
|
||||
meta = backend.requests_metadata[0]
|
||||
assert meta is not None
|
||||
assert meta["agent_entrypoint"] == "acp"
|
||||
assert meta["agent_version"] == "2.0.0"
|
||||
assert meta["client_name"] == "vibe_ide"
|
||||
assert meta["client_version"] == "0.5.0"
|
||||
assert meta["session_id"] == agent.session_id
|
||||
assert "message_id" in meta
|
||||
assert meta["is_user_prompt"] == "true"
|
||||
assert meta["call_type"] == "main_call"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -197,3 +211,108 @@ async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_change
|
||||
result2 = await handler(context, params)
|
||||
assert isinstance(result2, CreateMessageResult)
|
||||
assert result2.model == "devstral-small-latest"
|
||||
|
||||
|
||||
def _generic_provider_vibe_config() -> VibeConfig:
|
||||
"""VibeConfig with generic backend so no metadata header is sent."""
|
||||
providers = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
]
|
||||
return build_test_vibe_config(providers=providers)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mistral_metadata_header_is_user_prompt_per_turn() -> None:
|
||||
"""First LLM call in a turn has is_user_prompt=True; second call (after tools) has is_user_prompt=False."""
|
||||
tool_call = ToolCall(
|
||||
id="call_1",
|
||||
index=0,
|
||||
function=FunctionCall(name="todo", arguments='{"action": "read"}'),
|
||||
)
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="Checking todos.", tool_calls=[tool_call])],
|
||||
[mock_llm_chunk(content="Here are your todos.")],
|
||||
])
|
||||
config = build_test_vibe_config(
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
],
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
)
|
||||
agent = build_test_agent_loop(
|
||||
config=config, backend=backend, agent_name=BuiltinAgentName.AUTO_APPROVE
|
||||
)
|
||||
|
||||
[_ async for _ in agent.act("What's on my todo list?")]
|
||||
|
||||
assert len(backend.requests_metadata) == 2
|
||||
first_metadata = backend.requests_metadata[0]
|
||||
second_metadata = backend.requests_metadata[1]
|
||||
assert first_metadata is not None and "is_user_prompt" in first_metadata
|
||||
assert second_metadata is not None and "is_user_prompt" in second_metadata
|
||||
assert first_metadata["is_user_prompt"] == "true"
|
||||
assert second_metadata["is_user_prompt"] == "false"
|
||||
assert first_metadata["call_type"] == "main_call"
|
||||
assert second_metadata["call_type"] == "secondary_call"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_compact_internal_chat_has_is_user_prompt_false_then_user_turn_true() -> (
|
||||
None
|
||||
):
|
||||
"""Compact's internal _chat() sends is_user_prompt=False; the following user turn sends is_user_prompt=True."""
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="<summary>")],
|
||||
[mock_llm_chunk(content="<final>")],
|
||||
])
|
||||
config = build_test_vibe_config(
|
||||
models=make_test_models(auto_compact_threshold=1),
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
],
|
||||
)
|
||||
agent = build_test_agent_loop(config=config, backend=backend)
|
||||
agent.stats.context_tokens = 2
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert len(backend.requests_metadata) == 2
|
||||
compact_metadata = backend.requests_metadata[0]
|
||||
user_turn_metadata = backend.requests_metadata[1]
|
||||
assert compact_metadata is not None and "is_user_prompt" in compact_metadata
|
||||
assert user_turn_metadata is not None and "is_user_prompt" in user_turn_metadata
|
||||
assert compact_metadata["is_user_prompt"] == "false"
|
||||
assert user_turn_metadata["is_user_prompt"] == "true"
|
||||
assert compact_metadata["call_type"] == "secondary_call"
|
||||
assert user_turn_metadata["call_type"] == "main_call"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_provider_has_no_metadata_header() -> None:
|
||||
"""Non-Mistral provider does not send the metadata header."""
|
||||
backend = FakeBackend([mock_llm_chunk(content="Response")])
|
||||
config = _generic_provider_vibe_config()
|
||||
agent = build_test_agent_loop(config=config, backend=backend)
|
||||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
assert len(backend.requests_extra_headers) == 1
|
||||
headers = backend.requests_extra_headers[0]
|
||||
assert headers is not None
|
||||
assert "metadata" not in headers
|
||||
|
||||
@@ -22,7 +22,6 @@ from vibe.core.middleware import (
|
||||
MiddlewareResult,
|
||||
ResetReason,
|
||||
)
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.builtins.todo import TodoArgs
|
||||
from vibe.core.types import (
|
||||
ApprovalResponse,
|
||||
@@ -54,9 +53,7 @@ class InjectBeforeMiddleware:
|
||||
|
||||
|
||||
def make_config(
|
||||
*,
|
||||
enabled_tools: list[str] | None = None,
|
||||
tools: dict[str, BaseToolConfig] | None = None,
|
||||
*, enabled_tools: list[str] | None = None, tools: dict[str, dict] | None = None
|
||||
) -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
@@ -218,8 +215,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
|
||||
),
|
||||
backend=backend,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
@@ -268,8 +264,7 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
|
||||
),
|
||||
backend=backend,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
@@ -324,8 +319,7 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
|
||||
),
|
||||
backend=backend,
|
||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||
@@ -387,8 +381,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
])
|
||||
agent = build_test_agent_loop(
|
||||
config=make_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
|
||||
enabled_tools=["todo"], tools={"todo": {"permission": "ask"}}
|
||||
),
|
||||
backend=backend,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
@@ -398,7 +391,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
agent.middleware_pipeline.add(middleware)
|
||||
|
||||
async def _reject_callback(
|
||||
_name: str, _args: BaseModel, _id: str
|
||||
_name: str, _args: BaseModel, _id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return (
|
||||
ApprovalResponse.NO,
|
||||
|
||||
@@ -13,16 +13,16 @@ from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import (
|
||||
Backend,
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
SessionLoggingConfig,
|
||||
VibeConfig,
|
||||
)
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
AssistantEvent,
|
||||
Backend,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
FunctionCall,
|
||||
@@ -95,7 +95,7 @@ def make_config(
|
||||
models=models,
|
||||
providers=providers,
|
||||
enabled_tools=enabled_tools or [],
|
||||
tools={"todo": BaseToolConfig(permission=todo_permission)},
|
||||
tools={"todo": {"permission": todo_permission.value}},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from tests.stubs.fake_tool import FakeTool
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.tools.builtins.todo import TodoItem
|
||||
from vibe.core.types import (
|
||||
ApprovalCallback,
|
||||
@@ -37,7 +37,7 @@ async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[Bas
|
||||
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
|
||||
return build_test_vibe_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=todo_permission)},
|
||||
tools={"todo": {"permission": todo_permission.value}},
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
@@ -166,7 +166,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved(
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None:
|
||||
async def approval_callback(
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
@@ -210,7 +210,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
||||
custom_feedback = "User declined tool execution"
|
||||
|
||||
async def approval_callback(
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return (ApprovalResponse.NO, custom_feedback)
|
||||
|
||||
@@ -299,14 +299,14 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
|
||||
agent_ref: AgentLoop | None = None
|
||||
|
||||
async def approval_callback(
|
||||
tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
callback_invocations.append(tool_name)
|
||||
# Set permission to ALWAYS for this tool (simulating the new behavior)
|
||||
assert agent_ref is not None
|
||||
if tool_name not in agent_ref.config.tools:
|
||||
agent_ref.config.tools[tool_name] = BaseToolConfig()
|
||||
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
|
||||
agent_ref.config.tools[tool_name] = {}
|
||||
agent_ref.config.tools[tool_name]["permission"] = "always"
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
agent_loop = make_agent_loop(
|
||||
@@ -596,7 +596,7 @@ async def test_parallel_tool_calls_with_approval_callback(
|
||||
approval_calls: list[str] = []
|
||||
|
||||
async def approval_callback(
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
approval_calls.append(tool_call_id)
|
||||
return (ApprovalResponse.YES, None)
|
||||
@@ -640,7 +640,7 @@ async def test_parallel_approvals_can_run_concurrently() -> None:
|
||||
max_concurrency = 0
|
||||
|
||||
async def approval_callback(
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
nonlocal concurrency, max_concurrency
|
||||
concurrency += 1
|
||||
@@ -674,7 +674,7 @@ async def test_parallel_mixed_approval_and_rejection(
|
||||
"""One tool approved, one rejected — both should produce correct events."""
|
||||
|
||||
async def approval_callback(
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
if tool_call_id == "call_yes":
|
||||
return (ApprovalResponse.YES, None)
|
||||
@@ -813,7 +813,7 @@ async def test_parallel_all_permission_never() -> None:
|
||||
approval_calls: list[str] = []
|
||||
|
||||
async def approval_callback(
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str
|
||||
tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
approval_calls.append(tool_call_id)
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
@@ -160,6 +160,32 @@ class TestAgentProfile:
|
||||
|
||||
|
||||
class TestAgentApplyToConfig:
|
||||
def test_profile_disabled_tools_are_merged_with_base_config(self) -> None:
|
||||
base = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_tools=["ask_user_question"],
|
||||
)
|
||||
|
||||
result = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].apply_to_config(base)
|
||||
|
||||
assert set(result.disabled_tools) == {"ask_user_question", "exit_plan_mode"}
|
||||
|
||||
def test_profile_disabled_tools_preserve_user_disabled_tools(self) -> None:
|
||||
base = VibeConfig(
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
disabled_tools=["ask_user_question", "custom_tool"],
|
||||
)
|
||||
|
||||
result = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].apply_to_config(base)
|
||||
|
||||
assert set(result.disabled_tools) == {
|
||||
"ask_user_question",
|
||||
"custom_tool",
|
||||
"exit_plan_mode",
|
||||
}
|
||||
|
||||
def test_custom_prompt_found_in_global_when_missing_from_project(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -204,8 +230,9 @@ class TestAgentApplyToConfig:
|
||||
|
||||
|
||||
class TestAgentProfileOverrides:
|
||||
def test_default_agent_has_no_overrides(self) -> None:
|
||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
|
||||
def test_default_agent_disables_exit_plan_mode(self) -> None:
|
||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides
|
||||
assert "exit_plan_mode" in overrides.get("base_disabled", [])
|
||||
|
||||
def test_auto_approve_agent_sets_auto_approve(self) -> None:
|
||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides
|
||||
|
||||
@@ -7,8 +7,7 @@ from tests.mock.mock_backend_factory import mock_backend_factory
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core import run_programmatic
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.types import LLMMessage, OutputFormat, Role
|
||||
from vibe.core.types import Backend, LLMMessage, OutputFormat, Role
|
||||
|
||||
|
||||
class SpyStreamingFormatter:
|
||||
|
||||
@@ -158,7 +158,7 @@ class TestMistralMapperPrepareMessage:
|
||||
|
||||
assert isinstance(result, AssistantMessage)
|
||||
assert isinstance(result.content, list)
|
||||
assert len(result.content) == 2
|
||||
assert len(result.content) == 1
|
||||
|
||||
think_chunk = result.content[0]
|
||||
assert isinstance(think_chunk, ThinkChunk)
|
||||
@@ -168,10 +168,6 @@ class TestMistralMapperPrepareMessage:
|
||||
assert isinstance(inner_chunk, TextChunk)
|
||||
assert inner_chunk.text == "Just thinking..."
|
||||
|
||||
text_chunk = result.content[1]
|
||||
assert isinstance(text_chunk, TextChunk)
|
||||
assert text_chunk.text == ""
|
||||
|
||||
|
||||
class TestGenericBackendReasoningContent:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
371
tests/test_tracing.py
Normal file
@@ -0,0 +1,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import (
|
||||
SimpleSpanProcessor,
|
||||
SpanExporter,
|
||||
SpanExportResult,
|
||||
)
|
||||
from opentelemetry.trace import StatusCode
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.core import tracing
|
||||
from vibe.core.config import OtelExporterConfig
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tracing import agent_span, setup_tracing, tool_span
|
||||
from vibe.core.types import BaseEvent, FunctionCall, ToolCall
|
||||
|
||||
|
||||
class _CollectingExporter(SpanExporter):
|
||||
def __init__(self) -> None:
|
||||
self.spans: list = []
|
||||
|
||||
def export(self, spans):
|
||||
self.spans.extend(spans)
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _otel_provider(monkeypatch: pytest.MonkeyPatch):
|
||||
# Patch get_tracer_provider instead of set_tracer_provider to sidestep the
|
||||
# OTEL singleton guard that rejects a second set_tracer_provider call.
|
||||
exporter = _CollectingExporter()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider)
|
||||
yield exporter
|
||||
|
||||
|
||||
class TestSetupTracing:
|
||||
def test_noop_when_disabled(self) -> None:
|
||||
config = MagicMock(enable_otel=False)
|
||||
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
|
||||
setup_tracing(config)
|
||||
mock_set.assert_not_called()
|
||||
|
||||
def test_noop_when_exporter_config_is_none(self) -> None:
|
||||
config = MagicMock(enable_otel=True, otel_exporter_config=None)
|
||||
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
|
||||
setup_tracing(config)
|
||||
mock_set.assert_not_called()
|
||||
|
||||
def test_configures_provider_from_exporter_config(self) -> None:
|
||||
config = MagicMock(
|
||||
enable_otel=True,
|
||||
otel_exporter_config=OtelExporterConfig(
|
||||
endpoint="https://customer.mistral.ai/telemetry/v1/traces",
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter"
|
||||
) as mock_exporter,
|
||||
patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set,
|
||||
):
|
||||
setup_tracing(config)
|
||||
|
||||
mock_exporter.assert_called_once_with(
|
||||
endpoint="https://customer.mistral.ai/telemetry/v1/traces",
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
mock_set.assert_called_once()
|
||||
assert isinstance(mock_set.call_args[0][0], TracerProvider)
|
||||
|
||||
def test_custom_endpoint_has_no_auth_headers(self) -> None:
|
||||
config = MagicMock(
|
||||
enable_otel=True,
|
||||
otel_exporter_config=OtelExporterConfig(
|
||||
endpoint="https://my-collector:4318/v1/traces"
|
||||
),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter"
|
||||
) as mock_exporter,
|
||||
patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set,
|
||||
):
|
||||
setup_tracing(config)
|
||||
|
||||
mock_exporter.assert_called_once_with(
|
||||
endpoint="https://my-collector:4318/v1/traces", headers=None
|
||||
)
|
||||
mock_set.assert_called_once()
|
||||
assert isinstance(mock_set.call_args[0][0], TracerProvider)
|
||||
|
||||
|
||||
class TestAgentSpan:
|
||||
@pytest.mark.asyncio
|
||||
async def test_span_name_status_and_attributes(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
async with agent_span(model="devstral", session_id="s1"):
|
||||
pass
|
||||
|
||||
assert len(_otel_provider.spans) == 1
|
||||
span = _otel_provider.spans[0]
|
||||
assert span.name == "invoke_agent mistral-vibe"
|
||||
assert span.status.status_code == StatusCode.OK
|
||||
attrs = dict(span.attributes)
|
||||
assert attrs["gen_ai.operation.name"] == "invoke_agent"
|
||||
assert attrs["gen_ai.provider.name"] == "mistral_ai"
|
||||
assert attrs["gen_ai.agent.name"] == "mistral-vibe"
|
||||
assert attrs["gen_ai.request.model"] == "devstral"
|
||||
assert attrs["gen_ai.conversation.id"] == "s1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omits_optional_attributes(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
async with agent_span():
|
||||
pass
|
||||
|
||||
attrs = dict(_otel_provider.spans[0].attributes)
|
||||
assert "gen_ai.request.model" not in attrs
|
||||
assert "gen_ai.conversation.id" not in attrs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_records_error_on_exception(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
async with agent_span():
|
||||
raise ValueError("boom")
|
||||
|
||||
span = _otel_provider.spans[0]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert "boom" in span.status.description
|
||||
|
||||
|
||||
class TestToolSpan:
|
||||
@pytest.mark.asyncio
|
||||
async def test_span_name_status_and_attributes(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
async with tool_span(tool_name="bash", call_id="c1", arguments='{"cmd": "ls"}'):
|
||||
pass
|
||||
|
||||
assert len(_otel_provider.spans) == 1
|
||||
span = _otel_provider.spans[0]
|
||||
assert span.name == "execute_tool bash"
|
||||
assert span.status.status_code == StatusCode.OK
|
||||
attrs = dict(span.attributes)
|
||||
assert attrs["gen_ai.operation.name"] == "execute_tool"
|
||||
assert attrs["gen_ai.tool.name"] == "bash"
|
||||
assert attrs["gen_ai.tool.call.id"] == "c1"
|
||||
assert attrs["gen_ai.tool.call.arguments"] == '{"cmd": "ls"}'
|
||||
assert attrs["gen_ai.tool.type"] == "function"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_records_error_and_exception_event(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
async with tool_span(tool_name="bash", call_id="c1", arguments="{}"):
|
||||
raise RuntimeError("fail")
|
||||
|
||||
span = _otel_provider.spans[0]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
exc_events = [e for e in span.events if e.name == "exception"]
|
||||
assert len(exc_events) == 1
|
||||
|
||||
|
||||
class TestSpanHierarchy:
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_and_tool_are_siblings_under_agent(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
async with agent_span(model="devstral"):
|
||||
tracer = trace.get_tracer("mistralai_sdk_tracer")
|
||||
# Simulate a chat span created by the Mistral SDK.
|
||||
with tracer.start_as_current_span("chat devstral"):
|
||||
pass
|
||||
|
||||
async with tool_span(tool_name="grep", call_id="c1", arguments="{}"):
|
||||
pass
|
||||
|
||||
with tracer.start_as_current_span("chat devstral"):
|
||||
pass
|
||||
|
||||
agent = next(s for s in _otel_provider.spans if "invoke_agent" in s.name)
|
||||
children = [
|
||||
s
|
||||
for s in _otel_provider.spans
|
||||
if s.parent and s.parent.span_id == agent.context.span_id
|
||||
]
|
||||
assert len(children) == 3
|
||||
assert [s.name for s in children] == [
|
||||
"chat devstral",
|
||||
"execute_tool grep",
|
||||
"chat devstral",
|
||||
]
|
||||
|
||||
|
||||
class TestBaggagePropagation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_span_inherits_conversation_id(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
async with agent_span(model="devstral", session_id="sess-42"):
|
||||
async with tool_span(tool_name="bash", call_id="c1", arguments="{}"):
|
||||
pass
|
||||
|
||||
tool = next(s for s in _otel_provider.spans if "execute_tool" in s.name)
|
||||
assert dict(tool.attributes)["gen_ai.conversation.id"] == "sess-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_span_omits_conversation_id_when_no_session(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
async with agent_span(model="devstral"):
|
||||
async with tool_span(tool_name="bash", call_id="c1", arguments="{}"):
|
||||
pass
|
||||
|
||||
tool = next(s for s in _otel_provider.spans if "execute_tool" in s.name)
|
||||
assert "gen_ai.conversation.id" not in dict(tool.attributes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_baggage_does_not_leak_after_agent_span(self) -> None:
|
||||
from opentelemetry import baggage as baggage_api
|
||||
|
||||
async with agent_span(model="devstral", session_id="sess-1"):
|
||||
pass
|
||||
|
||||
assert baggage_api.get_baggage("gen_ai.conversation.id") is None
|
||||
|
||||
|
||||
class TestErrorIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_yields_invalid_span_on_creation_failure(
|
||||
self, _otel_provider: _CollectingExporter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def _broken_tracer() -> trace.Tracer:
|
||||
raise RuntimeError("tracer broken")
|
||||
|
||||
monkeypatch.setattr(tracing, "_get_tracer", _broken_tracer)
|
||||
|
||||
async with agent_span():
|
||||
pass
|
||||
|
||||
assert len(_otel_provider.spans) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caller_exception_propagates_when_set_status_fails(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def _broken_set_status(self, *args, **kwargs):
|
||||
raise RuntimeError("set_status broken")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"opentelemetry.sdk.trace.Span.set_status", _broken_set_status
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="original"):
|
||||
async with agent_span():
|
||||
raise ValueError("original")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_ends_span_without_error_status(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
async with agent_span():
|
||||
raise asyncio.CancelledError
|
||||
|
||||
span = _otel_provider.spans[0]
|
||||
assert span.status.status_code != StatusCode.ERROR
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_path_swallows_span_end_failure(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def _broken_end(self, *args, **kwargs):
|
||||
raise RuntimeError("end broken")
|
||||
|
||||
monkeypatch.setattr("opentelemetry.sdk.trace.Span.end", _broken_end)
|
||||
|
||||
async with agent_span():
|
||||
pass
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
@staticmethod
|
||||
async def _collect_events(agent_loop, prompt: str) -> list[BaseEvent]:
|
||||
return [ev async for ev in agent_loop.act(prompt)]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_turn_with_tool_call_produces_spans(
|
||||
self, _otel_provider: _CollectingExporter
|
||||
) -> None:
|
||||
tool_call = ToolCall(
|
||||
id="call_1",
|
||||
index=0,
|
||||
function=FunctionCall(name="todo", arguments='{"action": "read"}'),
|
||||
)
|
||||
backend = FakeBackend([
|
||||
[mock_llm_chunk(content="Let me check.", tool_calls=[tool_call])],
|
||||
[mock_llm_chunk(content="Done.")],
|
||||
])
|
||||
config = build_test_vibe_config(
|
||||
enabled_tools=["todo"],
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
include_prompt_detail=False,
|
||||
)
|
||||
agent_loop = build_test_agent_loop(config=config, backend=backend)
|
||||
|
||||
await self._collect_events(agent_loop, "What are my todos?")
|
||||
|
||||
spans = _otel_provider.spans
|
||||
agent_spans = [s for s in spans if "invoke_agent" in s.name]
|
||||
tool_spans = [s for s in spans if "execute_tool" in s.name]
|
||||
|
||||
assert len(agent_spans) == 1
|
||||
assert len(tool_spans) == 1
|
||||
|
||||
agent = agent_spans[0]
|
||||
tool = tool_spans[0]
|
||||
|
||||
# Parent-child relationship
|
||||
assert tool.parent is not None
|
||||
assert tool.parent.span_id == agent.context.span_id
|
||||
|
||||
# -- Agent span: name, status, and every attribute set by agent_span() --
|
||||
assert agent.name == "invoke_agent mistral-vibe"
|
||||
assert agent.status.status_code == StatusCode.OK
|
||||
agent_attrs = dict(agent.attributes)
|
||||
assert agent_attrs["gen_ai.operation.name"] == "invoke_agent"
|
||||
assert agent_attrs["gen_ai.provider.name"] == "mistral_ai"
|
||||
assert agent_attrs["gen_ai.agent.name"] == "mistral-vibe"
|
||||
assert agent_attrs["gen_ai.request.model"] == "mistral-vibe-cli-latest"
|
||||
assert agent_attrs["gen_ai.conversation.id"] == agent_loop.session_id
|
||||
|
||||
# -- Tool span: name, status, and every attribute set by tool_span() + set_tool_result() --
|
||||
assert tool.name == "execute_tool todo"
|
||||
assert tool.status.status_code == StatusCode.OK
|
||||
tool_attrs = dict(tool.attributes)
|
||||
assert tool_attrs["gen_ai.operation.name"] == "execute_tool"
|
||||
assert tool_attrs["gen_ai.tool.name"] == "todo"
|
||||
assert tool_attrs["gen_ai.tool.call.id"] == "call_1"
|
||||
assert tool_attrs["gen_ai.tool.type"] == "function"
|
||||
assert (
|
||||
tool_attrs["gen_ai.tool.call.arguments"] == '{"action":"read","todos":null}'
|
||||
)
|
||||
assert tool_attrs["gen_ai.tool.call.result"] == (
|
||||
"message: Retrieved 0 todos\ntodos: []\ntotal_count: 0"
|
||||
)
|
||||
# Conversation ID propagated via baggage from agent_span
|
||||
assert tool_attrs["gen_ai.conversation.id"] == agent_loop.session_id
|
||||
349
tests/test_turn_summary.py
Normal file
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.cli.turn_summary import (
|
||||
NARRATOR_MODEL,
|
||||
NoopTurnSummary,
|
||||
TurnSummaryResult,
|
||||
TurnSummaryTracker,
|
||||
create_narrator_backend,
|
||||
)
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.llm.backend.mistral import MistralBackend
|
||||
from vibe.core.types import AssistantEvent, Backend, ToolStreamEvent, UserMessageEvent
|
||||
|
||||
_TEST_MODEL = ModelConfig(name="test-model", provider="test", alias="test-model")
|
||||
|
||||
|
||||
def _noop_callback(result: TurnSummaryResult) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TestCreateNarratorBackend:
|
||||
def test_uses_mistral_provider(self, monkeypatch):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
config = VibeConfig()
|
||||
result = create_narrator_backend(config)
|
||||
assert result is not None
|
||||
backend, model = result
|
||||
assert isinstance(backend, MistralBackend)
|
||||
assert model is NARRATOR_MODEL
|
||||
|
||||
def test_uses_custom_provider_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
custom_provider = ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://on-prem.example.com/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
config = VibeConfig(providers=[custom_provider])
|
||||
result = create_narrator_backend(config)
|
||||
assert result is not None
|
||||
backend, model = result
|
||||
assert isinstance(backend, MistralBackend)
|
||||
assert backend._provider.api_base == custom_provider.api_base
|
||||
|
||||
def test_returns_none_when_api_key_missing(self, monkeypatch):
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
config = VibeConfig()
|
||||
monkeypatch.delenv("MISTRAL_API_KEY")
|
||||
assert create_narrator_backend(config) is None
|
||||
|
||||
def test_returns_none_when_provider_missing(self):
|
||||
config = VibeConfig(providers=[])
|
||||
assert create_narrator_backend(config) is None
|
||||
|
||||
|
||||
class TestTrack:
|
||||
def _make_tracker(self, backend: FakeBackend | None = None) -> TurnSummaryTracker:
|
||||
return TurnSummaryTracker(
|
||||
backend=backend or FakeBackend(),
|
||||
model=_TEST_MODEL,
|
||||
on_summary=_noop_callback,
|
||||
)
|
||||
|
||||
def test_assistant_event(self):
|
||||
tracker = self._make_tracker()
|
||||
tracker.start_turn("test")
|
||||
tracker.track(AssistantEvent(content="chunk1"))
|
||||
tracker.track(AssistantEvent(content="chunk2"))
|
||||
assert tracker._data is not None
|
||||
assert tracker._data.assistant_fragments == ["chunk1", "chunk2"]
|
||||
|
||||
def test_assistant_event_empty_content_ignored(self):
|
||||
tracker = self._make_tracker()
|
||||
tracker.start_turn("test")
|
||||
tracker.track(AssistantEvent(content=""))
|
||||
assert tracker._data is not None
|
||||
assert tracker._data.assistant_fragments == []
|
||||
|
||||
def test_start_turn_preserves_full_message(self):
|
||||
tracker = self._make_tracker()
|
||||
long_msg = "a" * 1500
|
||||
tracker.start_turn(long_msg)
|
||||
assert tracker._data is not None
|
||||
assert len(tracker._data.user_message) == 1500
|
||||
|
||||
def test_start_turn_increments_generation(self):
|
||||
tracker = self._make_tracker()
|
||||
assert tracker.generation == 0
|
||||
tracker.start_turn("turn 1")
|
||||
assert tracker.generation == 1
|
||||
tracker.start_turn("turn 2")
|
||||
assert tracker.generation == 2
|
||||
|
||||
def test_cancel_turn_clears_data(self):
|
||||
tracker = self._make_tracker()
|
||||
tracker.start_turn("test")
|
||||
assert tracker._data is not None
|
||||
tracker.cancel_turn()
|
||||
assert tracker._data is None
|
||||
|
||||
def test_set_error_stores_message(self):
|
||||
tracker = self._make_tracker()
|
||||
tracker.start_turn("test")
|
||||
tracker.set_error("rate limit exceeded")
|
||||
assert tracker._data is not None
|
||||
assert tracker._data.error == "rate limit exceeded"
|
||||
|
||||
def test_set_error_without_start_is_noop(self):
|
||||
tracker = self._make_tracker()
|
||||
tracker.set_error("should be ignored")
|
||||
assert tracker._data is None
|
||||
|
||||
def test_cancel_turn_without_start_is_noop(self):
|
||||
tracker = self._make_tracker()
|
||||
tracker.cancel_turn()
|
||||
assert tracker._data is None
|
||||
|
||||
def test_unrelated_events_ignored(self):
|
||||
tracker = self._make_tracker()
|
||||
tracker.start_turn("test")
|
||||
tracker.track(UserMessageEvent(content="hi", message_id="m1"))
|
||||
tracker.track(
|
||||
ToolStreamEvent(tool_name="bash", message="output", tool_call_id="tc1")
|
||||
)
|
||||
assert tracker._data is not None
|
||||
assert tracker._data.assistant_fragments == []
|
||||
|
||||
|
||||
class TestTurnSummaryTracker:
|
||||
def _make_tracker(
|
||||
self,
|
||||
backend: FakeBackend,
|
||||
on_summary: Callable[[TurnSummaryResult], None] = _noop_callback,
|
||||
) -> TurnSummaryTracker:
|
||||
return TurnSummaryTracker(
|
||||
backend=backend, model=_TEST_MODEL, on_summary=on_summary
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_accumulates_events(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
tracker.start_turn("hello")
|
||||
tracker.track(AssistantEvent(content="chunk1"))
|
||||
tracker.track(AssistantEvent(content="chunk2"))
|
||||
assert tracker._data is not None
|
||||
assert tracker._data.assistant_fragments == ["chunk1", "chunk2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_turn_fires_summary(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="the summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
|
||||
tracker.start_turn("do something")
|
||||
tracker.track(AssistantEvent(content="response"))
|
||||
tracker.end_turn()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(backend.requests_messages) == 1
|
||||
summary_msgs = backend.requests_messages[0]
|
||||
assert len(summary_msgs) == 2
|
||||
assert summary_msgs[0].role.value == "system"
|
||||
assert summary_msgs[1].role.value == "user"
|
||||
assert summary_msgs[1].content is not None
|
||||
assert "do something" in summary_msgs[1].content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_turn_clears_state(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
|
||||
tracker.start_turn("hello")
|
||||
tracker.end_turn()
|
||||
assert tracker._data is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_without_start_is_noop(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
tracker.track(AssistantEvent(content="ignored"))
|
||||
assert tracker._data is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_turn_without_start_is_noop(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
tracker.end_turn()
|
||||
assert len(backend.requests_messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_turn_after_cancel_is_noop(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
tracker.start_turn("hello")
|
||||
tracker.cancel_turn()
|
||||
tracker.end_turn()
|
||||
await asyncio.sleep(0.1)
|
||||
assert len(backend.requests_messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_summary_callback_called(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="the summary text"))
|
||||
received: list[TurnSummaryResult] = []
|
||||
|
||||
def capture(result: TurnSummaryResult) -> None:
|
||||
received.append(result)
|
||||
|
||||
tracker = self._make_tracker(backend, on_summary=capture)
|
||||
tracker.start_turn("hello")
|
||||
tracker.track(AssistantEvent(content="response"))
|
||||
tracker.end_turn()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].summary == "the summary text"
|
||||
assert received[0].generation == tracker.generation
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_error_calls_callback_with_none(self):
|
||||
backend = FakeBackend(exception_to_raise=RuntimeError("backend down"))
|
||||
received: list[TurnSummaryResult] = []
|
||||
|
||||
def capture(result: TurnSummaryResult) -> None:
|
||||
received.append(result)
|
||||
|
||||
tracker = self._make_tracker(backend, on_summary=capture)
|
||||
tracker.start_turn("hello")
|
||||
tracker.end_turn()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].summary is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_error_logged_no_crash(self, caplog):
|
||||
backend = FakeBackend(exception_to_raise=RuntimeError("backend down"))
|
||||
tracker = self._make_tracker(backend)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="vibe"):
|
||||
tracker.start_turn("hello")
|
||||
tracker.end_turn()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert "Turn summary generation failed" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_cancels_pending_tasks(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
|
||||
tracker.start_turn("hello")
|
||||
tracker.end_turn()
|
||||
assert len(tracker._tasks) == 1
|
||||
|
||||
await tracker.close()
|
||||
assert len(tracker._tasks) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_only_turn_includes_error_in_summary(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="error summary"))
|
||||
received: list[TurnSummaryResult] = []
|
||||
|
||||
def capture(result: TurnSummaryResult) -> None:
|
||||
received.append(result)
|
||||
|
||||
tracker = self._make_tracker(backend, on_summary=capture)
|
||||
tracker.start_turn("do something")
|
||||
tracker.set_error("Rate limit exceeded")
|
||||
cancel = tracker.end_turn()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert cancel is not None
|
||||
assert len(backend.requests_messages) == 1
|
||||
prompt_content = backend.requests_messages[0][1].content
|
||||
assert prompt_content is not None
|
||||
assert "do something" in prompt_content
|
||||
assert "## Error" in prompt_content
|
||||
assert "Rate limit exceeded" in prompt_content
|
||||
assert "## Assistant Response" not in prompt_content
|
||||
assert len(received) == 1
|
||||
assert received[0].summary == "error summary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_with_partial_response_includes_both(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="partial error summary"))
|
||||
tracker = self._make_tracker(backend)
|
||||
tracker.start_turn("do something")
|
||||
tracker.track(AssistantEvent(content="partial response"))
|
||||
tracker.set_error("connection lost")
|
||||
tracker.end_turn()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(backend.requests_messages) == 1
|
||||
prompt_content = backend.requests_messages[0][1].content
|
||||
assert prompt_content is not None
|
||||
assert "## Assistant Response" in prompt_content
|
||||
assert "partial response" in prompt_content
|
||||
assert "## Error" in prompt_content
|
||||
assert "connection lost" in prompt_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_summary_has_old_generation(self):
|
||||
backend = FakeBackend(mock_llm_chunk(content="stale summary"))
|
||||
received: list[TurnSummaryResult] = []
|
||||
|
||||
def capture(result: TurnSummaryResult) -> None:
|
||||
received.append(result)
|
||||
|
||||
tracker = self._make_tracker(backend, on_summary=capture)
|
||||
|
||||
tracker.start_turn("turn 1")
|
||||
tracker.end_turn()
|
||||
|
||||
tracker.start_turn("turn 2")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].generation == 1
|
||||
assert tracker.generation == 2
|
||||
assert received[0].generation != tracker.generation
|
||||
|
||||
|
||||
class TestNoopTurnSummary:
|
||||
def test_all_methods_callable(self):
|
||||
noop = NoopTurnSummary()
|
||||
noop.start_turn("hello")
|
||||
noop.track(AssistantEvent(content="chunk"))
|
||||
noop.set_error("some error")
|
||||
noop.cancel_turn()
|
||||
noop.end_turn()
|
||||
|
||||
def test_generation_is_zero(self):
|
||||
noop = NoopTurnSummary()
|
||||
assert noop.generation == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_is_noop(self):
|
||||
noop = NoopTurnSummary()
|
||||
await noop.close()
|
||||
54
tests/tools/test_arity.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.core.tools.arity import build_session_pattern
|
||||
|
||||
|
||||
class TestBuildSessionPattern:
|
||||
def test_single_token_in_arity(self):
|
||||
assert build_session_pattern(["mkdir", "foo"]) == "mkdir *"
|
||||
|
||||
def test_single_token_not_in_arity(self):
|
||||
assert build_session_pattern(["whoami"]) == "whoami *"
|
||||
|
||||
def test_two_token_arity(self):
|
||||
assert build_session_pattern(["git", "checkout", "main"]) == "git checkout *"
|
||||
|
||||
def test_three_token_arity(self):
|
||||
assert build_session_pattern(["npm", "run", "dev"]) == "npm run dev *"
|
||||
|
||||
def test_longer_prefix_wins(self):
|
||||
# "git" is arity 2, but "git stash" is arity 3
|
||||
assert build_session_pattern(["git", "stash", "pop"]) == "git stash pop *"
|
||||
|
||||
def test_docker_compose(self):
|
||||
assert (
|
||||
build_session_pattern(["docker", "compose", "up", "-d"])
|
||||
== "docker compose up *"
|
||||
)
|
||||
|
||||
def test_empty_tokens(self):
|
||||
assert build_session_pattern([]) == ""
|
||||
|
||||
def test_unknown_command_returns_first_token(self):
|
||||
assert build_session_pattern(["mycommand", "arg1", "arg2"]) == "mycommand *"
|
||||
|
||||
def test_cat_is_arity_1(self):
|
||||
assert build_session_pattern(["cat", "file.txt"]) == "cat *"
|
||||
|
||||
def test_rm_is_arity_1(self):
|
||||
assert build_session_pattern(["rm", "-rf", "dir"]) == "rm *"
|
||||
|
||||
def test_uv_run(self):
|
||||
assert build_session_pattern(["uv", "run", "pytest"]) == "uv run pytest *"
|
||||
|
||||
def test_pip_install(self):
|
||||
assert build_session_pattern(["pip", "install", "numpy"]) == "pip install *"
|
||||
|
||||
def test_git_remote_add(self):
|
||||
assert (
|
||||
build_session_pattern(["git", "remote", "add", "origin", "url"])
|
||||
== "git remote add *"
|
||||
)
|
||||
|
||||
def test_gh_pr_list(self):
|
||||
assert build_session_pattern(["gh", "pr", "list"]) == "gh pr list *"
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
|
||||
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -80,7 +81,10 @@ def test_find_not_in_default_allowlist():
|
||||
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||
# find -exec runs arbitrary commands; must not be allowlisted by default
|
||||
permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
|
||||
assert permission is not ToolPermission.ALWAYS
|
||||
assert (
|
||||
not isinstance(permission, PermissionContext)
|
||||
or permission.permission is not ToolPermission.ALWAYS
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_permission():
|
||||
@@ -92,9 +96,13 @@ def test_resolve_permission():
|
||||
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
|
||||
empty = bash_tool.resolve_permission(BashArgs(command=""))
|
||||
|
||||
assert allowlisted is ToolPermission.ALWAYS
|
||||
assert denylisted is ToolPermission.NEVER
|
||||
assert mixed is None
|
||||
assert isinstance(allowlisted, PermissionContext)
|
||||
assert allowlisted.permission is ToolPermission.ALWAYS
|
||||
assert isinstance(denylisted, PermissionContext)
|
||||
assert denylisted.permission is ToolPermission.NEVER
|
||||
assert isinstance(mixed, PermissionContext)
|
||||
assert mixed.permission is ToolPermission.ASK
|
||||
assert any(rp.label == "whoami *" for rp in mixed.required_permissions)
|
||||
assert empty is None
|
||||
|
||||
|
||||
@@ -108,80 +116,95 @@ class TestResolvePermissionWindowsSyntax:
|
||||
def test_dir_with_windows_flags_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s /b"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_type_command_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["type"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="type file.txt"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_findstr_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["findstr"])
|
||||
result = bash_tool.resolve_permission(
|
||||
BashArgs(command="findstr /s pattern *.txt")
|
||||
)
|
||||
assert result is ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_ver_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["ver"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="ver"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_where_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["where"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="where python"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_cmd_k_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist=["cmd /k"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="cmd /k something"))
|
||||
assert result is ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_powershell_noexit_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist=["powershell -NoExit"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="powershell -NoExit"))
|
||||
assert result is ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_notepad_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist=["notepad"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="notepad file.txt"))
|
||||
assert result is ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_cmd_standalone_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist_standalone=["cmd"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="cmd"))
|
||||
assert result is ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_powershell_standalone_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist_standalone=["powershell"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="powershell"))
|
||||
assert result is ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_powershell_cmdlet_asks(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="Get-ChildItem -Path ."))
|
||||
assert result is None
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission == ToolPermission.ASK
|
||||
|
||||
def test_mixed_allowed_and_unknown_asks(self):
|
||||
bash_tool = self._make_bash(allowlist=["git status"])
|
||||
result = bash_tool.resolve_permission(
|
||||
BashArgs(command="git status && npm install")
|
||||
)
|
||||
assert result is None
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission == ToolPermission.ASK
|
||||
|
||||
def test_chained_windows_commands_all_allowed(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s && echo done"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_chained_commands_one_denied(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s && rm -rf /"))
|
||||
assert result is ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_piped_windows_commands(self):
|
||||
bash_tool = self._make_bash(allowlist=["findstr", "type"])
|
||||
result = bash_tool.resolve_permission(
|
||||
BashArgs(command="type file.txt | findstr pattern")
|
||||
)
|
||||
assert result is ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
750
tests/tools/test_granular_permissions.py
Normal file
@@ -0,0 +1,750 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.tools.base import BaseToolState, ToolPermission
|
||||
from vibe.core.tools.builtins.bash import (
|
||||
Bash,
|
||||
BashArgs,
|
||||
BashToolConfig,
|
||||
_collect_outside_dirs,
|
||||
)
|
||||
from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepToolConfig
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFile,
|
||||
ReadFileArgs,
|
||||
ReadFileState,
|
||||
ReadFileToolConfig,
|
||||
)
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplace,
|
||||
SearchReplaceArgs,
|
||||
SearchReplaceConfig,
|
||||
)
|
||||
from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig
|
||||
from vibe.core.tools.builtins.write_file import (
|
||||
WriteFile,
|
||||
WriteFileArgs,
|
||||
WriteFileConfig,
|
||||
)
|
||||
from vibe.core.tools.permissions import (
|
||||
ApprovedRule,
|
||||
PermissionContext,
|
||||
PermissionScope,
|
||||
RequiredPermission,
|
||||
)
|
||||
from vibe.core.tools.utils import wildcard_match
|
||||
|
||||
|
||||
class TestBashGranularPermissions:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
self.workdir = tmp_path
|
||||
|
||||
def _bash(self, **kwargs):
|
||||
config = BashToolConfig(**kwargs)
|
||||
return Bash(config=config, state=BaseToolState())
|
||||
|
||||
def test_allowlisted_command_always(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="git status"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_denylisted_command_never(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="vim file.txt"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_standalone_denylisted_never(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="python"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_standalone_denylisted_with_args_not_denied(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="python script.py"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
|
||||
def test_unknown_command_returns_permission_context(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="npm install"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
assert len(result.required_permissions) == 1
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
assert rp.session_pattern == "npm install *"
|
||||
|
||||
def test_arity_based_prefix(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="docker compose up -d"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.session_pattern == "docker compose up *"
|
||||
|
||||
def test_multiple_commands_dedup(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(
|
||||
BashArgs(command="npm install foo && npm install bar")
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
command_labels = [
|
||||
rp.label
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
]
|
||||
assert command_labels == ["npm install *"]
|
||||
|
||||
def test_cd_excluded_from_command_patterns(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="cd /tmp"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert all(
|
||||
rp.scope is not PermissionScope.COMMAND_PATTERN
|
||||
for rp in result.required_permissions
|
||||
)
|
||||
|
||||
def test_outside_directory_detection(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/test"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
outside = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert len(outside) >= 1
|
||||
|
||||
def test_outside_directory_has_glob_pattern(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/test"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
outside = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert any("/tmp" in rp.session_pattern for rp in outside)
|
||||
|
||||
def test_in_workdir_no_outside_directory(self):
|
||||
bash = self._bash()
|
||||
(self.workdir / "subdir").mkdir()
|
||||
result = bash.resolve_permission(BashArgs(command="mkdir subdir/child"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
outside = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert len(outside) == 0
|
||||
|
||||
def test_rm_uses_arity_based_pattern(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
cmd_perms = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
]
|
||||
assert len(cmd_perms) == 1
|
||||
assert cmd_perms[0].session_pattern == "rm *"
|
||||
|
||||
def test_sensitive_sudo_exact_pattern(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="sudo apt install foo"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
cmd_perms = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
]
|
||||
assert cmd_perms[0].session_pattern == "sudo apt install foo"
|
||||
|
||||
def test_rmdir_uses_arity_based_pattern(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="rmdir foo"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
cmd_perms = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
]
|
||||
assert cmd_perms[0].session_pattern == "rmdir *"
|
||||
|
||||
def test_sensitive_bypasses_allowlist(self):
|
||||
bash = self._bash(allowlist=["sudo"])
|
||||
result = bash.resolve_permission(BashArgs(command="sudo ls"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
|
||||
def test_allowlisted_outside_dir_still_asks(self):
|
||||
bash = self._bash()
|
||||
# cat is allowlisted but /etc/passwd is outside workdir
|
||||
result = bash.resolve_permission(BashArgs(command="cat /etc/passwd"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
outside = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert len(outside) == 1
|
||||
|
||||
def test_allowlisted_relative_traversal_outside_dir_still_asks(self):
|
||||
bash = self._bash()
|
||||
(self.workdir / "src").mkdir()
|
||||
result = bash.resolve_permission(
|
||||
BashArgs(command="cat src/../../../etc/passwd")
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
outside = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert len(outside) >= 1
|
||||
|
||||
def test_allowlisted_in_workdir_subdir_auto_approves(self):
|
||||
bash = self._bash()
|
||||
(self.workdir / "foo").mkdir()
|
||||
(self.workdir / "foo" / "bar.txt").touch()
|
||||
result = bash.resolve_permission(BashArgs(command="cat foo/bar.txt"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_allowlisted_in_workdir_auto_approves(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="cat README.md"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_mixed_allowlisted_and_not(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(
|
||||
BashArgs(command="echo hello && npm install foo")
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
cmd_perms = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
]
|
||||
assert len(cmd_perms) == 1
|
||||
assert cmd_perms[0].session_pattern == "npm install *"
|
||||
|
||||
def test_empty_command_returns_none(self):
|
||||
bash = self._bash()
|
||||
assert bash.resolve_permission(BashArgs(command="")) is None
|
||||
|
||||
def test_chmod_plus_skipped_as_flag(self):
|
||||
bash = self._bash()
|
||||
result = bash.resolve_permission(BashArgs(command="chmod +x /tmp/script.sh"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
outside = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert len(outside) >= 1
|
||||
|
||||
|
||||
class TestReadFileGranularPermissions:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
self.workdir = tmp_path
|
||||
|
||||
def _read_file(self, **kwargs):
|
||||
config = ReadFileToolConfig(**kwargs)
|
||||
return ReadFile(config=config, state=ReadFileState())
|
||||
|
||||
def test_in_workdir_normal_file_returns_none(self):
|
||||
(self.workdir / "test.py").touch()
|
||||
tool = self._read_file()
|
||||
assert tool.resolve_permission(ReadFileArgs(path="test.py")) is None
|
||||
|
||||
def test_outside_workdir_returns_permission_context(self):
|
||||
tool = self._read_file()
|
||||
result = tool.resolve_permission(ReadFileArgs(path="/tmp/file.txt"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
outside = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert len(outside) == 1
|
||||
|
||||
def test_sensitive_env_file_returns_permission_context(self):
|
||||
(self.workdir / ".env").touch()
|
||||
tool = self._read_file()
|
||||
result = tool.resolve_permission(ReadFileArgs(path=".env"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
sensitive = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.FILE_PATTERN
|
||||
]
|
||||
assert len(sensitive) == 1
|
||||
assert sensitive[0].label.startswith("accessing sensitive files")
|
||||
|
||||
def test_sensitive_env_local_file(self):
|
||||
(self.workdir / ".env.local").touch()
|
||||
tool = self._read_file()
|
||||
result = tool.resolve_permission(ReadFileArgs(path=".env.local"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
sensitive = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.FILE_PATTERN
|
||||
]
|
||||
assert len(sensitive) == 1
|
||||
|
||||
def test_sensitive_outside_both_permissions(self):
|
||||
tool = self._read_file()
|
||||
result = tool.resolve_permission(ReadFileArgs(path="/tmp/.env"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
scopes = {rp.scope for rp in result.required_permissions}
|
||||
assert PermissionScope.FILE_PATTERN in scopes
|
||||
assert PermissionScope.OUTSIDE_DIRECTORY in scopes
|
||||
|
||||
def test_denylisted_returns_never(self):
|
||||
tool = self._read_file(denylist=["*/secret*"])
|
||||
result = tool.resolve_permission(ReadFileArgs(path="secret.key"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_allowlisted_returns_always(self):
|
||||
tool = self._read_file(allowlist=["*/README*"])
|
||||
result = tool.resolve_permission(
|
||||
ReadFileArgs(path=str(self.workdir / "README.md"))
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_custom_sensitive_patterns(self):
|
||||
(self.workdir / "credentials.json").touch()
|
||||
tool = self._read_file(sensitive_patterns=["*/credentials*"])
|
||||
result = tool.resolve_permission(ReadFileArgs(path="credentials.json"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
|
||||
|
||||
class TestWriteFileGranularPermissions:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
self.workdir = tmp_path
|
||||
|
||||
def _write_file(self):
|
||||
config = WriteFileConfig()
|
||||
return WriteFile(config=config, state=BaseToolState())
|
||||
|
||||
def test_in_workdir_returns_none(self):
|
||||
tool = self._write_file()
|
||||
assert (
|
||||
tool.resolve_permission(WriteFileArgs(path="test.py", content="x")) is None
|
||||
)
|
||||
|
||||
def test_outside_workdir_returns_permission_context(self):
|
||||
tool = self._write_file()
|
||||
result = tool.resolve_permission(
|
||||
WriteFileArgs(path="/tmp/file.txt", content="x")
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
|
||||
def test_sensitive_env_file_asks(self):
|
||||
(self.workdir / ".env").touch()
|
||||
tool = self._write_file()
|
||||
result = tool.resolve_permission(
|
||||
WriteFileArgs(path=".env", content="x", overwrite=True)
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
|
||||
|
||||
class TestSearchReplaceGranularPermissions:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
def test_outside_workdir_returns_permission_context(self):
|
||||
config = SearchReplaceConfig()
|
||||
tool = SearchReplace(config=config, state=BaseToolState())
|
||||
result = tool.resolve_permission(
|
||||
SearchReplaceArgs(file_path="/tmp/file.py", content="x")
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ASK
|
||||
|
||||
|
||||
class TestGrepGranularPermissions:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
self.workdir = tmp_path
|
||||
|
||||
def _grep(self):
|
||||
config = GrepToolConfig()
|
||||
return Grep(config=config, state=BaseToolState())
|
||||
|
||||
def test_in_workdir_normal_path_returns_none(self):
|
||||
tool = self._grep()
|
||||
assert tool.resolve_permission(GrepArgs(pattern="foo", path=".")) is None
|
||||
|
||||
def test_outside_workdir_returns_permission_context(self):
|
||||
tool = self._grep()
|
||||
result = tool.resolve_permission(GrepArgs(pattern="foo", path="/tmp"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
|
||||
def test_sensitive_env_directory(self):
|
||||
(self.workdir / ".env").touch()
|
||||
tool = self._grep()
|
||||
result = tool.resolve_permission(GrepArgs(pattern="foo", path=".env"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
sensitive = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.FILE_PATTERN
|
||||
]
|
||||
assert len(sensitive) == 1
|
||||
|
||||
|
||||
class TestApprovalFlowSimulation:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
def _is_covered(
|
||||
self, tool_name: str, rp: RequiredPermission, rules: list[ApprovedRule]
|
||||
) -> bool:
|
||||
return any(
|
||||
rule.tool_name == tool_name
|
||||
and rule.scope == rp.scope
|
||||
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
|
||||
for rule in rules
|
||||
)
|
||||
|
||||
def test_mkdir_approved_covers_subsequent_mkdir(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="bash",
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
session_pattern="mkdir *",
|
||||
)
|
||||
]
|
||||
bash = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||
result = bash.resolve_permission(BashArgs(command="mkdir another_dir"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
uncovered = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if not self._is_covered("bash", rp, rules)
|
||||
]
|
||||
assert not any(rp.scope is PermissionScope.COMMAND_PATTERN for rp in uncovered)
|
||||
|
||||
def test_mkdir_approved_does_not_cover_npm(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="bash",
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
session_pattern="mkdir *",
|
||||
)
|
||||
]
|
||||
bash = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||
result = bash.resolve_permission(BashArgs(command="npm install"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
uncovered = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if not self._is_covered("bash", rp, rules)
|
||||
]
|
||||
assert len(uncovered) == 1
|
||||
assert uncovered[0].session_pattern == "npm install *"
|
||||
|
||||
def test_outside_dir_approved_covers_subsequent(self):
|
||||
bash = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||
result = bash.resolve_permission(BashArgs(command="mkdir /tmp/newdir"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
outside_rps = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.OUTSIDE_DIRECTORY
|
||||
]
|
||||
assert len(outside_rps) == 1
|
||||
# Resolved pattern may differ per OS (e.g. /private/tmp/* on macOS)
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="bash",
|
||||
scope=PermissionScope.OUTSIDE_DIRECTORY,
|
||||
session_pattern=outside_rps[0].session_pattern,
|
||||
),
|
||||
ApprovedRule(
|
||||
tool_name="bash",
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
session_pattern="mkdir *",
|
||||
),
|
||||
]
|
||||
uncovered = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if not self._is_covered("bash", rp, rules)
|
||||
]
|
||||
assert len(uncovered) == 0
|
||||
|
||||
def test_rm_approved_covers_subsequent_rm(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="bash",
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
session_pattern="rm *",
|
||||
)
|
||||
]
|
||||
bash = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||
result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
cmd_perms = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
]
|
||||
assert cmd_perms[0].session_pattern == "rm *"
|
||||
uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)]
|
||||
assert len(uncovered) == 0
|
||||
|
||||
def test_sudo_exact_approval_doesnt_cover_different_invocation(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="bash",
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
session_pattern="sudo apt install foo",
|
||||
)
|
||||
]
|
||||
bash = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||
result = bash.resolve_permission(BashArgs(command="sudo apt install bar"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
cmd_perms = [
|
||||
rp
|
||||
for rp in result.required_permissions
|
||||
if rp.scope is PermissionScope.COMMAND_PATTERN
|
||||
]
|
||||
uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)]
|
||||
assert len(uncovered) == 1
|
||||
|
||||
def test_read_file_sensitive_approved_covers_subsequent(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="read_file",
|
||||
scope=PermissionScope.FILE_PATTERN,
|
||||
session_pattern="*",
|
||||
)
|
||||
]
|
||||
rp = RequiredPermission(
|
||||
scope=PermissionScope.FILE_PATTERN,
|
||||
invocation_pattern=".env.production",
|
||||
session_pattern="*",
|
||||
label="reading sensitive files (read_file)",
|
||||
)
|
||||
assert self._is_covered("read_file", rp, rules)
|
||||
|
||||
def test_different_tool_rule_doesnt_cover(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="bash",
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
session_pattern="mkdir *",
|
||||
)
|
||||
]
|
||||
rp = RequiredPermission(
|
||||
scope=PermissionScope.COMMAND_PATTERN,
|
||||
invocation_pattern="mkdir foo",
|
||||
session_pattern="mkdir *",
|
||||
label="mkdir *",
|
||||
)
|
||||
assert not self._is_covered("grep", rp, rules)
|
||||
|
||||
|
||||
class TestWebFetchPermissions:
|
||||
def _make_webfetch(self) -> WebFetch:
|
||||
return WebFetch(config=WebFetchConfig(), state=BaseToolState())
|
||||
|
||||
def test_returns_url_pattern_with_domain(self):
|
||||
wf = self._make_webfetch()
|
||||
result = wf.resolve_permission(
|
||||
WebFetchArgs(url="https://docs.python.org/3/library")
|
||||
)
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert len(result.required_permissions) == 1
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.scope is PermissionScope.URL_PATTERN
|
||||
assert rp.invocation_pattern == "docs.python.org"
|
||||
assert rp.session_pattern == "docs.python.org"
|
||||
assert "docs.python.org" in rp.label
|
||||
|
||||
def test_http_url(self):
|
||||
wf = self._make_webfetch()
|
||||
result = wf.resolve_permission(WebFetchArgs(url="http://example.com/page"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.invocation_pattern == "example.com"
|
||||
|
||||
def test_url_without_scheme(self):
|
||||
wf = self._make_webfetch()
|
||||
result = wf.resolve_permission(WebFetchArgs(url="github.com/anthropics"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.invocation_pattern == "github.com"
|
||||
|
||||
def test_url_with_port(self):
|
||||
wf = self._make_webfetch()
|
||||
result = wf.resolve_permission(WebFetchArgs(url="http://localhost:8080/api"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.invocation_pattern == "localhost:8080"
|
||||
|
||||
def test_url_without_scheme_with_port(self):
|
||||
wf = self._make_webfetch()
|
||||
result = wf.resolve_permission(WebFetchArgs(url="example.com:3000/path"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.invocation_pattern == "example.com:3000"
|
||||
|
||||
def test_different_domains_not_covered(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="web_fetch",
|
||||
scope=PermissionScope.URL_PATTERN,
|
||||
session_pattern="docs.python.org",
|
||||
)
|
||||
]
|
||||
rp = RequiredPermission(
|
||||
scope=PermissionScope.URL_PATTERN,
|
||||
invocation_pattern="evil.com",
|
||||
session_pattern="evil.com",
|
||||
label="fetching from evil.com",
|
||||
)
|
||||
covered = any(
|
||||
rule.tool_name == "web_fetch"
|
||||
and rule.scope == rp.scope
|
||||
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
|
||||
for rule in rules
|
||||
)
|
||||
assert not covered
|
||||
|
||||
def test_same_domain_covered(self):
|
||||
rules = [
|
||||
ApprovedRule(
|
||||
tool_name="web_fetch",
|
||||
scope=PermissionScope.URL_PATTERN,
|
||||
session_pattern="docs.python.org",
|
||||
)
|
||||
]
|
||||
rp = RequiredPermission(
|
||||
scope=PermissionScope.URL_PATTERN,
|
||||
invocation_pattern="docs.python.org",
|
||||
session_pattern="docs.python.org",
|
||||
label="fetching from docs.python.org",
|
||||
)
|
||||
covered = any(
|
||||
rule.tool_name == "web_fetch"
|
||||
and rule.scope == rp.scope
|
||||
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
|
||||
for rule in rules
|
||||
)
|
||||
assert covered
|
||||
|
||||
def test_double_slash_url(self):
|
||||
wf = self._make_webfetch()
|
||||
result = wf.resolve_permission(WebFetchArgs(url="//cdn.example.com/lib.js"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
rp = result.required_permissions[0]
|
||||
assert rp.invocation_pattern == "cdn.example.com"
|
||||
|
||||
def test_config_permission_always_honored(self):
|
||||
wf = WebFetch(
|
||||
config=WebFetchConfig(permission=ToolPermission.ALWAYS),
|
||||
state=BaseToolState(),
|
||||
)
|
||||
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_config_permission_never_honored(self):
|
||||
wf = WebFetch(
|
||||
config=WebFetchConfig(permission=ToolPermission.NEVER),
|
||||
state=BaseToolState(),
|
||||
)
|
||||
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_config_permission_ask_falls_through_to_domain(self):
|
||||
wf = WebFetch(
|
||||
config=WebFetchConfig(permission=ToolPermission.ASK), state=BaseToolState()
|
||||
)
|
||||
result = wf.resolve_permission(WebFetchArgs(url="https://example.com"))
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.required_permissions[0].invocation_pattern == "example.com"
|
||||
|
||||
|
||||
class TestCollectOutsideDirs:
|
||||
"""Tests for _collect_outside_dirs helper."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
self.workdir = tmp_path
|
||||
|
||||
def test_relative_path_resolving_outside_workdir(self):
|
||||
dirs = _collect_outside_dirs(["cat ../../etc/passwd"])
|
||||
# The relative path resolves outside workdir, should collect parent dir
|
||||
assert len(dirs) >= 1
|
||||
|
||||
def test_multiple_targets_in_one_command(self):
|
||||
dirs = _collect_outside_dirs(["cp /tmp/a /var/b"])
|
||||
assert len(dirs) == 2
|
||||
|
||||
def test_chmod_skips_plus_x_token(self):
|
||||
dirs = _collect_outside_dirs(["chmod +x /tmp/script.sh"])
|
||||
# +x should be skipped, only /tmp/script.sh should be considered
|
||||
assert len(dirs) >= 1
|
||||
# Verify no dir was created from the "+x" token
|
||||
for d in dirs:
|
||||
assert "+x" not in d
|
||||
|
||||
def test_empty_command_list(self):
|
||||
assert _collect_outside_dirs([]) == set()
|
||||
|
||||
def test_home_relative_path(self):
|
||||
home = os.path.expanduser("~")
|
||||
dirs = _collect_outside_dirs(["cat ~/some_file"])
|
||||
# ~/some_file resolves to home directory, which is likely outside workdir
|
||||
if home != str(self.workdir):
|
||||
assert len(dirs) >= 1
|
||||
|
||||
def test_in_workdir_path_not_collected(self):
|
||||
(self.workdir / "local_file").touch()
|
||||
dirs = _collect_outside_dirs(["cat ./local_file"])
|
||||
assert len(dirs) == 0
|
||||
|
||||
def test_traversal_path_without_dot_prefix(self):
|
||||
"""Paths like src/../../../etc/passwd don't start with . but contain /."""
|
||||
(self.workdir / "src").mkdir()
|
||||
dirs = _collect_outside_dirs(["cat src/../../../etc/passwd"])
|
||||
assert len(dirs) >= 1
|
||||
|
||||
def test_in_workdir_subdir_path_not_collected(self):
|
||||
"""foo/bar inside workdir should not be flagged."""
|
||||
(self.workdir / "foo").mkdir()
|
||||
(self.workdir / "foo" / "bar").touch()
|
||||
dirs = _collect_outside_dirs(["cat foo/bar"])
|
||||
assert len(dirs) == 0
|
||||
@@ -46,7 +46,10 @@ class TestInvokeContext:
|
||||
|
||||
def test_approval_callback_can_be_set(self) -> None:
|
||||
async def dummy_callback(
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
_tool_name: str,
|
||||
_args: BaseModel,
|
||||
_tool_call_id: str,
|
||||
_rp: list | None = None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return ApprovalResponse.YES, None
|
||||
|
||||
@@ -76,7 +79,10 @@ class TestToolInvokeWithContext:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
|
||||
async def dummy_callback(
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
_tool_name: str,
|
||||
_args: BaseModel,
|
||||
_tool_call_id: str,
|
||||
_rp: list | None = None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return ApprovalResponse.YES, None
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_merges_user_overrides_with_defaults():
|
||||
vibe_config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
tools={"bash": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
tools={"bash": {"permission": "always"}},
|
||||
)
|
||||
manager = ToolManager(lambda: vibe_config)
|
||||
|
||||
@@ -53,9 +53,9 @@ def test_preserves_tool_specific_fields_from_overrides():
|
||||
vibe_config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
tools={"bash": BaseToolConfig(permission=ToolPermission.ASK)},
|
||||
tools={"bash": {"permission": "ask"}},
|
||||
)
|
||||
vibe_config.tools["bash"].__pydantic_extra__ = {"default_timeout": 600}
|
||||
vibe_config.tools["bash"]["default_timeout"] = 600
|
||||
manager = ToolManager(lambda: vibe_config)
|
||||
|
||||
config = manager.get_tool_config("bash")
|
||||
@@ -71,6 +71,23 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager):
|
||||
assert config.permission == ToolPermission.ASK
|
||||
|
||||
|
||||
def test_partial_override_preserves_tool_defaults():
|
||||
vibe_config = build_test_vibe_config(
|
||||
system_prompt_id="tests",
|
||||
include_project_context=False,
|
||||
tools={"read_file": {"max_read_bytes": 32000}},
|
||||
)
|
||||
manager = ToolManager(lambda: vibe_config)
|
||||
|
||||
config = manager.get_tool_config("read_file")
|
||||
|
||||
assert (
|
||||
config.permission == ToolPermission.ALWAYS
|
||||
) # ReadFileToolConfig default, not BaseToolConfig.ASK
|
||||
assert config.sensitive_patterns == ["**/.env", "**/.env.*"] # type: ignore[attr-defined]
|
||||
assert config.max_read_bytes == 32000 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestToolManagerFiltering:
|
||||
def test_enabled_tools_filters_to_only_enabled(self):
|
||||
vibe_config = build_test_vibe_config(
|
||||
|
||||
217
tests/tools/test_skill.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.skills.models import SkillInfo
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.builtins.skill import (
|
||||
Skill,
|
||||
SkillArgs,
|
||||
SkillResult,
|
||||
SkillToolConfig,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionScope
|
||||
|
||||
|
||||
def _make_skill_dir(
|
||||
tmp_path: Path,
|
||||
name: str = "my-skill",
|
||||
description: str = "A test skill",
|
||||
body: str = "## Instructions\n\nDo the thing.",
|
||||
extra_files: list[str] | None = None,
|
||||
) -> SkillInfo:
|
||||
skill_dir = tmp_path / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
content = f"---\nname: {name}\ndescription: {description}\n---\n\n{body}"
|
||||
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
||||
|
||||
for f in extra_files or []:
|
||||
file_path = skill_dir / f
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(f"content of {f}", encoding="utf-8")
|
||||
|
||||
return SkillInfo(
|
||||
name=name, description=description, skill_path=skill_dir / "SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
def _make_skill_manager(skills: dict[str, SkillInfo]) -> SkillManager:
|
||||
manager = MagicMock(spec=SkillManager)
|
||||
manager.available_skills = skills
|
||||
manager.get_skill.side_effect = lambda n: skills.get(n)
|
||||
return manager
|
||||
|
||||
|
||||
def _make_ctx(skill_manager: SkillManager | None = None) -> InvokeContext:
|
||||
return InvokeContext(tool_call_id="test-call", skill_manager=skill_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skill_tool() -> Skill:
|
||||
return Skill(config=SkillToolConfig(), state=BaseToolState())
|
||||
|
||||
|
||||
class TestSkillRun:
|
||||
@pytest.mark.asyncio
|
||||
async def test_loads_skill_content(self, tmp_path: Path, skill_tool: Skill) -> None:
|
||||
info = _make_skill_dir(tmp_path, body="Follow these steps:\n1. Do A\n2. Do B")
|
||||
manager = _make_skill_manager({"my-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
|
||||
assert isinstance(result, SkillResult)
|
||||
assert result.name == "my-skill"
|
||||
assert "Follow these steps:" in result.content
|
||||
assert "1. Do A" in result.content
|
||||
assert '<skill_content name="my-skill">' in result.content
|
||||
assert "# Skill: my-skill" in result.content
|
||||
assert "</skill_content>" in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_bundled_files(self, tmp_path: Path, skill_tool: Skill) -> None:
|
||||
info = _make_skill_dir(
|
||||
tmp_path, extra_files=["scripts/run.sh", "references/guide.md"]
|
||||
)
|
||||
manager = _make_skill_manager({"my-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
|
||||
assert "<skill_files>" in result.content
|
||||
assert "<file>scripts/run.sh</file>" in result.content
|
||||
assert "<file>references/guide.md</file>" in result.content
|
||||
assert f"<file>{info.skill_dir / 'scripts/run.sh'}</file>" not in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_excludes_skill_md_from_file_list(
|
||||
self, tmp_path: Path, skill_tool: Skill
|
||||
) -> None:
|
||||
info = _make_skill_dir(tmp_path, extra_files=["helper.py"])
|
||||
manager = _make_skill_manager({"my-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
|
||||
assert "SKILL.md" not in result.content.split("<skill_files>")[1]
|
||||
assert "helper.py" in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caps_file_list_at_ten(
|
||||
self, tmp_path: Path, skill_tool: Skill
|
||||
) -> None:
|
||||
files = [f"file{i:02d}.txt" for i in range(15)]
|
||||
info = _make_skill_dir(tmp_path, extra_files=files)
|
||||
manager = _make_skill_manager({"my-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
|
||||
file_section = result.content.split("<skill_files>")[1].split("</skill_files>")[
|
||||
0
|
||||
]
|
||||
assert file_section.count("<file>") == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_skill_directory(
|
||||
self, tmp_path: Path, skill_tool: Skill
|
||||
) -> None:
|
||||
info = _make_skill_dir(tmp_path)
|
||||
manager = _make_skill_manager({"my-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
|
||||
assert "<skill_files>\n\n</skill_files>" in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_skill_dir(self, tmp_path: Path, skill_tool: Skill) -> None:
|
||||
info = _make_skill_dir(tmp_path)
|
||||
manager = _make_skill_manager({"my-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
|
||||
assert result.skill_dir == str(info.skill_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_base_directory(
|
||||
self, tmp_path: Path, skill_tool: Skill
|
||||
) -> None:
|
||||
info = _make_skill_dir(tmp_path)
|
||||
manager = _make_skill_manager({"my-skill": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx))
|
||||
|
||||
assert f"Base directory for this skill: {info.skill_dir}" in result.content
|
||||
|
||||
|
||||
class TestSkillErrors:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_context(self, skill_tool: Skill) -> None:
|
||||
with pytest.raises(ToolError, match="Skill manager not available"):
|
||||
await collect_result(skill_tool.run(SkillArgs(name="test"), ctx=None))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_skill_manager(self, skill_tool: Skill) -> None:
|
||||
ctx = _make_ctx(skill_manager=None)
|
||||
with pytest.raises(ToolError, match="Skill manager not available"):
|
||||
await collect_result(skill_tool.run(SkillArgs(name="test"), ctx=ctx))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_not_found(self, skill_tool: Skill) -> None:
|
||||
manager = _make_skill_manager({"alpha": MagicMock(), "beta": MagicMock()})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
with pytest.raises(ToolError, match='Skill "missing" not found'):
|
||||
await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_not_found_lists_available(self, skill_tool: Skill) -> None:
|
||||
manager = _make_skill_manager({"alpha": MagicMock(), "beta": MagicMock()})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
with pytest.raises(ToolError, match="alpha, beta"):
|
||||
await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreadable_skill_file(
|
||||
self, tmp_path: Path, skill_tool: Skill
|
||||
) -> None:
|
||||
info = SkillInfo(
|
||||
name="broken",
|
||||
description="Broken skill",
|
||||
skill_path=tmp_path / "nonexistent" / "SKILL.md",
|
||||
)
|
||||
manager = _make_skill_manager({"broken": info})
|
||||
ctx = _make_ctx(manager)
|
||||
|
||||
with pytest.raises(ToolError, match="Cannot load skill file"):
|
||||
await collect_result(skill_tool.run(SkillArgs(name="broken"), ctx=ctx))
|
||||
|
||||
|
||||
class TestSkillPermission:
|
||||
def test_resolve_permission_returns_file_pattern(self, skill_tool: Skill) -> None:
|
||||
perm = skill_tool.resolve_permission(SkillArgs(name="my-skill"))
|
||||
|
||||
assert perm is not None
|
||||
assert len(perm.required_permissions) == 1
|
||||
assert perm.required_permissions[0].scope == PermissionScope.FILE_PATTERN
|
||||
assert perm.required_permissions[0].invocation_pattern == "my-skill"
|
||||
assert perm.required_permissions[0].session_pattern == "my-skill"
|
||||
|
||||
|
||||
class TestSkillMeta:
|
||||
def test_tool_name(self) -> None:
|
||||
assert Skill.get_name() == "skill"
|
||||
|
||||
def test_description_is_set(self) -> None:
|
||||
assert "skill" in Skill.description.lower()
|
||||
assert len(Skill.description) > 20
|
||||
@@ -10,6 +10,7 @@ from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
|
||||
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.types import AssistantEvent, LLMMessage, Role
|
||||
|
||||
|
||||
@@ -80,7 +81,8 @@ class TestTaskToolResolvePermission:
|
||||
def test_explore_allowed_by_default(self, task_tool: Task) -> None:
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = task_tool.resolve_permission(args)
|
||||
assert result == ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_unknown_agent_returns_none(self, task_tool: Task) -> None:
|
||||
args = TaskArgs(task="do something", agent="custom_agent")
|
||||
@@ -92,21 +94,24 @@ class TestTaskToolResolvePermission:
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_glob_pattern_in_allowlist(self) -> None:
|
||||
config = TaskToolConfig(allowlist=["exp*"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="explore")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.ALWAYS
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.ALWAYS
|
||||
|
||||
def test_glob_pattern_in_denylist(self) -> None:
|
||||
config = TaskToolConfig(denylist=["danger*"])
|
||||
tool = Task(config=config, state=BaseToolState())
|
||||
args = TaskArgs(task="do something", agent="dangerous_agent")
|
||||
result = tool.resolve_permission(args)
|
||||
assert result == ToolPermission.NEVER
|
||||
assert isinstance(result, PermissionContext)
|
||||
assert result.permission is ToolPermission.NEVER
|
||||
|
||||
def test_empty_lists_returns_none(self) -> None:
|
||||
config = TaskToolConfig(allowlist=[], denylist=[])
|
||||
|
||||
@@ -14,9 +14,10 @@ from mistralai.client.models import (
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.config import Backend, ProviderConfig
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def _make_response(
|
||||
|
||||
66
tests/tools/test_wildcard_match.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.core.tools.utils import wildcard_match
|
||||
|
||||
|
||||
class TestWildcardMatch:
|
||||
def test_exact_match(self):
|
||||
assert wildcard_match("hello", "hello")
|
||||
|
||||
def test_exact_no_match(self):
|
||||
assert not wildcard_match("hello", "world")
|
||||
|
||||
def test_star_matches_any(self):
|
||||
assert wildcard_match("hello world", "hello *")
|
||||
|
||||
def test_star_matches_empty_trailing(self):
|
||||
assert wildcard_match("mkdir", "mkdir *")
|
||||
|
||||
def test_star_matches_with_args(self):
|
||||
assert wildcard_match("mkdir hello", "mkdir *")
|
||||
|
||||
def test_star_matches_long_trailing(self):
|
||||
assert wildcard_match("git commit -m hello world", "git commit *")
|
||||
|
||||
def test_star_in_middle(self):
|
||||
assert wildcard_match("fooXbar", "foo*bar")
|
||||
|
||||
def test_question_mark_single_char(self):
|
||||
assert wildcard_match("cat", "c?t")
|
||||
|
||||
def test_question_mark_no_match(self):
|
||||
assert not wildcard_match("ct", "c?t")
|
||||
|
||||
def test_glob_path_pattern(self):
|
||||
assert wildcard_match("/tmp/dir/file.txt", "/tmp/dir/*")
|
||||
|
||||
def test_glob_nested_path(self):
|
||||
assert wildcard_match("/tmp/dir/sub/file.txt", "/tmp/dir/*")
|
||||
|
||||
def test_glob_no_match(self):
|
||||
assert not wildcard_match("/home/user/file.txt", "/tmp/*")
|
||||
|
||||
def test_special_regex_chars_in_text(self):
|
||||
assert wildcard_match("echo (hello)", "echo *")
|
||||
|
||||
def test_special_regex_chars_in_pattern(self):
|
||||
assert wildcard_match(".env", ".env")
|
||||
assert not wildcard_match("xenv", ".env")
|
||||
|
||||
def test_fnmatch_character_class(self):
|
||||
assert wildcard_match("vache", "[bcghlmstv]ache")
|
||||
|
||||
def test_empty_pattern_empty_text(self):
|
||||
assert wildcard_match("", "")
|
||||
|
||||
def test_star_only(self):
|
||||
assert wildcard_match("anything goes here", "*")
|
||||
|
||||
def test_trailing_space_star_is_optional(self):
|
||||
assert wildcard_match("ls", "ls *")
|
||||
assert wildcard_match("ls -la", "ls *")
|
||||
assert wildcard_match("ls -la /tmp", "ls *")
|
||||
|
||||
def test_non_trailing_star_is_greedy(self):
|
||||
assert wildcard_match("abc123def", "abc*def")
|
||||
assert not wildcard_match("abc123de", "abc*def")
|
||||
@@ -39,10 +39,17 @@ async def _empty_audio_stream() -> AsyncIterator[bytes]:
|
||||
yield
|
||||
|
||||
|
||||
def _make_sdk_session_created() -> MagicMock:
|
||||
from mistralai.client.models import RealtimeTranscriptionSessionCreated
|
||||
def _make_sdk_session_created(request_id: str = "test-request-id") -> MagicMock:
|
||||
from mistralai.client.models import (
|
||||
RealtimeTranscriptionSession,
|
||||
RealtimeTranscriptionSessionCreated,
|
||||
)
|
||||
|
||||
return MagicMock(spec=RealtimeTranscriptionSessionCreated)
|
||||
session = MagicMock(spec=RealtimeTranscriptionSession)
|
||||
session.request_id = request_id
|
||||
mock = MagicMock(spec=RealtimeTranscriptionSessionCreated)
|
||||
mock.session = session
|
||||
return mock
|
||||
|
||||
|
||||
def _make_sdk_text_delta(text: str) -> MagicMock:
|
||||
@@ -104,6 +111,7 @@ class TestEventMapping:
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], TranscribeSessionCreated)
|
||||
assert events[0].request_id == "test-request-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_delta(self) -> None:
|
||||
|
||||
100
tests/tts/test_tts_client.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import TTSModelConfig, TTSProviderConfig
|
||||
from vibe.core.tts import MistralTTSClient, TTSResult
|
||||
|
||||
|
||||
def _make_provider() -> TTSProviderConfig:
|
||||
return TTSProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _make_model() -> TTSModelConfig:
|
||||
return TTSModelConfig(
|
||||
name="voxtral-mini-tts-latest",
|
||||
alias="voxtral-tts",
|
||||
provider="mistral",
|
||||
voice="gb_jane_neutral",
|
||||
)
|
||||
|
||||
|
||||
class TestMistralTTSClientInit:
|
||||
def test_client_configured_with_base_url_and_auth(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
assert str(client._client.base_url) == "https://api.mistral.ai/v1/"
|
||||
assert client._client.headers["authorization"] == "Bearer test-key"
|
||||
|
||||
|
||||
class TestMistralTTSClient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_returns_decoded_audio(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
|
||||
raw_audio = b"fake-audio-data-for-testing"
|
||||
encoded_audio = base64.b64encode(raw_audio).decode()
|
||||
|
||||
async def mock_post(self_client, url, **kwargs):
|
||||
assert url == "/audio/speech"
|
||||
body = kwargs["json"]
|
||||
assert body["model"] == "voxtral-mini-tts-latest"
|
||||
assert body["input"] == "Hello"
|
||||
assert body["voice_id"] == "gb_jane_neutral"
|
||||
assert body["stream"] is False
|
||||
assert body["response_format"] == "wav"
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
json={"audio_data": encoded_audio},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
result = await client.speak("Hello")
|
||||
|
||||
assert isinstance(result, TTSResult)
|
||||
assert result.audio_data == raw_audio
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_raises_on_http_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
|
||||
async def mock_post(self_client, url, **kwargs):
|
||||
return httpx.Response(
|
||||
status_code=500,
|
||||
json={"error": "Internal Server Error"},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await client.speak("Hello")
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_closes_underlying_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
await client.close()
|
||||
assert client._client.is_closed
|
||||
44
tests/voice_manager/test_telemetry.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
|
||||
|
||||
|
||||
class TestTranscriptionTrackingState:
|
||||
def test_reset_clears_accumulated_state(self) -> None:
|
||||
state = TranscriptionTrackingState()
|
||||
state.set_recording_id("req-1")
|
||||
state.record_text("hello")
|
||||
state.set_recording_duration(5.0)
|
||||
|
||||
state.reset()
|
||||
assert state.recording_id == ""
|
||||
assert state.accumulated_transcript_length == 0
|
||||
assert state.last_recording_duration_ms is None
|
||||
|
||||
def test_set_recording_id(self) -> None:
|
||||
state = TranscriptionTrackingState()
|
||||
state.set_recording_id("req-abc")
|
||||
assert state.recording_id == "req-abc"
|
||||
|
||||
def test_record_text_accumulates_length(self) -> None:
|
||||
state = TranscriptionTrackingState()
|
||||
state.reset()
|
||||
state.record_text("hello ")
|
||||
state.record_text("world")
|
||||
assert state.accumulated_transcript_length == 11
|
||||
|
||||
def test_elapsed_ms_returns_positive_value(self) -> None:
|
||||
state = TranscriptionTrackingState()
|
||||
state.reset()
|
||||
assert state.elapsed_ms() >= 0
|
||||
|
||||
def test_set_recording_duration_converts_seconds_to_ms(self) -> None:
|
||||
state = TranscriptionTrackingState()
|
||||
state.set_recording_duration(2.5)
|
||||
assert state.last_recording_duration_ms == 2500.0
|
||||
|
||||
def test_default_state(self) -> None:
|
||||
state = TranscriptionTrackingState()
|
||||
assert state.recording_id == ""
|
||||
assert state.accumulated_transcript_length == 0
|
||||
assert state.last_recording_duration_ms is None
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -47,12 +47,16 @@ def _make_manager(
|
||||
*,
|
||||
voice_mode_enabled: bool = True,
|
||||
transcribe_client: FakeTranscribeClient | None = None,
|
||||
telemetry_client: MagicMock | None = None,
|
||||
) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]:
|
||||
recorder = FakeAudioRecorder()
|
||||
client = transcribe_client or FakeTranscribeClient()
|
||||
config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled)
|
||||
manager = VoiceManager(
|
||||
config_getter=lambda: config, audio_recorder=recorder, transcribe_client=client
|
||||
config_getter=lambda: config,
|
||||
audio_recorder=recorder,
|
||||
transcribe_client=client,
|
||||
telemetry_client=telemetry_client,
|
||||
)
|
||||
return manager, recorder, client
|
||||
|
||||
@@ -175,7 +179,7 @@ class TestStopRecording:
|
||||
async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[
|
||||
TranscribeSessionCreated(),
|
||||
TranscribeSessionCreated(request_id="test-req-id"),
|
||||
TranscribeError(
|
||||
message="Cannot flush audio before sending any audio bytes"
|
||||
),
|
||||
@@ -304,7 +308,7 @@ class TestTranscription:
|
||||
async def test_text_deltas_notify_listeners(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[
|
||||
TranscribeSessionCreated(),
|
||||
TranscribeSessionCreated(request_id="test-req-id"),
|
||||
TranscribeTextDelta(text="hello "),
|
||||
TranscribeTextDelta(text="world"),
|
||||
TranscribeDone(),
|
||||
@@ -323,7 +327,7 @@ class TestTranscription:
|
||||
async def test_transcription_error_does_not_crash(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[
|
||||
TranscribeSessionCreated(),
|
||||
TranscribeSessionCreated(request_id="test-req-id"),
|
||||
TranscribeTextDelta(text="partial"),
|
||||
TranscribeError(message="something broke"),
|
||||
TranscribeDone(),
|
||||
@@ -341,7 +345,10 @@ class TestTranscription:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_transcription(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")]
|
||||
events=[
|
||||
TranscribeSessionCreated(request_id="test-req-id"),
|
||||
TranscribeTextDelta(text="hello"),
|
||||
]
|
||||
)
|
||||
manager, _, _ = _make_manager(transcribe_client=client)
|
||||
listener = StateListener()
|
||||
@@ -355,7 +362,10 @@ class TestTranscription:
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_created_is_silent(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[TranscribeSessionCreated(), TranscribeDone()]
|
||||
events=[
|
||||
TranscribeSessionCreated(request_id="test-req-id"),
|
||||
TranscribeDone(),
|
||||
]
|
||||
)
|
||||
manager, _, _ = _make_manager(transcribe_client=client)
|
||||
listener = StateListener()
|
||||
@@ -392,3 +402,178 @@ class TestTranscription:
|
||||
|
||||
assert manager.transcribe_state == TranscribeState.IDLE
|
||||
assert not recorder.is_recording
|
||||
|
||||
|
||||
def _find_telemetry_calls(
|
||||
mock: MagicMock, event_name: str
|
||||
) -> list[dict[str, str | int | float | None]]:
|
||||
"""Return the properties dicts for all calls matching a given event name."""
|
||||
results: list[dict[str, str | int | float | None]] = []
|
||||
for call in mock.send_telemetry_event.call_args_list:
|
||||
if call[0][0] == event_name:
|
||||
results.append(call[0][1])
|
||||
return results
|
||||
|
||||
|
||||
class TestTelemetryTracking:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sends_transcription_start_event(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[TranscribeSessionCreated(request_id="req-123"), TranscribeDone()]
|
||||
)
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _, _ = _make_manager(
|
||||
transcribe_client=client, telemetry_client=mock_telemetry
|
||||
)
|
||||
manager.start_recording()
|
||||
await manager.stop_recording()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["recording_id"] == "req-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sends_cancel_event(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
manager.start_recording()
|
||||
manager.cancel_recording()
|
||||
|
||||
calls = _find_telemetry_calls(
|
||||
mock_telemetry, "vibe.audio.transcription.cancel_recording"
|
||||
)
|
||||
assert len(calls) == 1
|
||||
recording_duration_ms = calls[0]["recording_duration_ms"]
|
||||
assert isinstance(recording_duration_ms, (int, float))
|
||||
assert recording_duration_ms >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_done_sends_done_event(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[
|
||||
TranscribeSessionCreated(request_id="test-req-id"),
|
||||
TranscribeTextDelta(text="hello "),
|
||||
TranscribeTextDelta(text="world"),
|
||||
TranscribeDone(),
|
||||
]
|
||||
)
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _, _ = _make_manager(
|
||||
transcribe_client=client, telemetry_client=mock_telemetry
|
||||
)
|
||||
|
||||
manager.start_recording()
|
||||
await manager.stop_recording()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.done")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["recording_id"] == "test-req-id"
|
||||
assert calls[0]["transcript_length"] == len("hello ") + len("world")
|
||||
transcription_duration_ms = calls[0]["transcription_duration_ms"]
|
||||
assert isinstance(transcription_duration_ms, (int, float))
|
||||
assert transcription_duration_ms >= 0
|
||||
recording_duration_ms = calls[0]["recording_duration_ms"]
|
||||
assert isinstance(recording_duration_ms, (int, float))
|
||||
assert recording_duration_ms >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_sends_error_event(self) -> None:
|
||||
import asyncio
|
||||
|
||||
class CrashingTranscribeClient:
|
||||
def __init__(self, provider=None, model=None) -> None:
|
||||
pass
|
||||
|
||||
async def transcribe(self, audio_stream):
|
||||
raise RuntimeError("network error")
|
||||
yield
|
||||
|
||||
recorder = FakeAudioRecorder()
|
||||
config = build_test_vibe_config(voice_mode_enabled=True)
|
||||
mock_telemetry = MagicMock()
|
||||
manager = VoiceManager(
|
||||
config_getter=lambda: config,
|
||||
audio_recorder=recorder,
|
||||
transcribe_client=CrashingTranscribeClient(),
|
||||
telemetry_client=mock_telemetry,
|
||||
)
|
||||
|
||||
manager.start_recording()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error")
|
||||
assert len(calls) == 1
|
||||
error_message = calls[0]["error_message"]
|
||||
assert isinstance(error_message, str)
|
||||
assert "network error" in error_message
|
||||
transcription_duration_ms = calls[0]["transcription_duration_ms"]
|
||||
assert isinstance(transcription_duration_ms, (int, float))
|
||||
assert transcription_duration_ms >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_telemetry_when_client_is_none(self) -> None:
|
||||
manager, _, _ = _make_manager() # no telemetry_client
|
||||
manager.start_recording()
|
||||
manager.cancel_recording()
|
||||
# No error raised — tracking is silently skipped
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_recording_uses_session_request_id(self) -> None:
|
||||
client = FakeTranscribeClient(
|
||||
events=[TranscribeSessionCreated(request_id="req-first"), TranscribeDone()]
|
||||
)
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _, _ = _make_manager(
|
||||
transcribe_client=client, telemetry_client=mock_telemetry
|
||||
)
|
||||
|
||||
manager.start_recording()
|
||||
await manager.stop_recording()
|
||||
|
||||
client.set_events([
|
||||
TranscribeSessionCreated(request_id="req-second"),
|
||||
TranscribeDone(),
|
||||
])
|
||||
|
||||
manager.start_recording()
|
||||
await manager.stop_recording()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start")
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["recording_id"] == "req-first"
|
||||
assert calls[1]["recording_id"] == "req-second"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_sends_error_event(self) -> None:
|
||||
import asyncio
|
||||
|
||||
class HangingTranscribeClient:
|
||||
def __init__(self, provider=None, model=None) -> None:
|
||||
pass
|
||||
|
||||
async def transcribe(self, audio_stream):
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
yield
|
||||
|
||||
recorder = FakeAudioRecorder()
|
||||
config = build_test_vibe_config(voice_mode_enabled=True)
|
||||
mock_telemetry = MagicMock()
|
||||
manager = VoiceManager(
|
||||
config_getter=lambda: config,
|
||||
audio_recorder=recorder,
|
||||
transcribe_client=HangingTranscribeClient(),
|
||||
telemetry_client=mock_telemetry,
|
||||
)
|
||||
manager.start_recording()
|
||||
|
||||
with patch(
|
||||
"vibe.cli.voice_manager.voice_manager.TRANSCRIPTION_DRAIN_TIMEOUT", 0.01
|
||||
):
|
||||
await manager.stop_recording()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error")
|
||||
assert len(calls) == 1
|
||||
error_message = calls[0]["error_message"]
|
||||
assert isinstance(error_message, str)
|
||||
assert "timed out" in error_message.lower()
|
||||
|
||||
116
uv.lock
generated
@@ -4,14 +4,14 @@ requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0a1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7b/7cdac86db388809d9e3bc58cac88cc7dfa49b7615b98fab304a828cd7f8a/agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd", size = 68866, upload-time = "2026-02-13T15:34:54.567Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/dc/1ec56897b461fbdb844c9bff3abbbe225bfe8cda020dc2449101a6d76592/agent_client_protocol-0.9.0a1.tar.gz", hash = "sha256:9e6fc8b72df465279470920d679c871e0c658f69212e345563cc69b17906b606", size = 70423, upload-time = "2026-03-19T18:44:47.117Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f3/219eeca0ad4a20843d4b9eaac5532f87018b9d25730a62a16f54f6c52d1a/agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e", size = 54529, upload-time = "2026-02-13T15:34:53.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/59/b794d5247aac2693a562ddd6eb2092581331496815967a6bca6bbf086b4f/agent_client_protocol-0.9.0a1-py3-none-any.whl", hash = "sha256:3e0962df15c3c7dd2957daea2f47db5e644fd897e77180e492f0a27d9fdb7bf4", size = 55945, upload-time = "2026-03-19T18:44:45.924Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -398,6 +398,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.73.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -758,7 +770,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mistral-vibe"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
@@ -773,6 +785,10 @@ dependencies = [
|
||||
{ name = "markdownify" },
|
||||
{ name = "mcp" },
|
||||
{ name = "mistralai" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pexpect" },
|
||||
{ name = "pydantic" },
|
||||
@@ -796,6 +812,7 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
build = [
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "debugpy" },
|
||||
@@ -815,7 +832,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-client-protocol", specifier = "==0.8.1" },
|
||||
{ name = "agent-client-protocol", specifier = "==0.9.0a1" },
|
||||
{ name = "anyio", specifier = ">=4.12.0" },
|
||||
{ name = "cachetools", specifier = ">=5.5.0" },
|
||||
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
|
||||
@@ -827,6 +844,10 @@ requires-dist = [
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "mcp", specifier = ">=1.14.0" },
|
||||
{ name = "mistralai", specifier = "==2.0.0" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.39.1" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.1" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.1" },
|
||||
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.60b1" },
|
||||
{ name = "packaging", specifier = ">=24.1" },
|
||||
{ name = "pexpect", specifier = ">=4.9.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.4" },
|
||||
@@ -848,7 +869,10 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
build = [{ name = "pyinstaller", specifier = ">=6.17.0" }]
|
||||
build = [
|
||||
{ name = "pyinstaller", specifier = ">=6.17.0" },
|
||||
{ name = "truststore", specifier = ">=0.10.4" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "debugpy", specifier = ">=1.8.19" },
|
||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
||||
@@ -948,6 +972,62 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-common"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-proto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-http"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-common" },
|
||||
{ name = "opentelemetry-proto" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-sdk"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.60b1"
|
||||
@@ -1025,6 +1105,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "6.33.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
@@ -1865,6 +1960,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/75/4ca1a9fabd8fb5aea78cea70f7837ce4dbf2afae115f62051e5fa99cba1c/tree_sitter_bash-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:59115057ec2bae319e8082ff29559861045002964c3431ccb0fc92aa4bc9bccb", size = 191196, upload-time = "2025-12-02T17:01:07.486Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twine"
|
||||
version = "6.2.0"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# Onedir build for vibe-acp — no per-launch extraction overhead.
|
||||
# Build: uv run --group build pyinstaller vibe-acp.spec
|
||||
# Output: dist/vibe-acp-dir/vibe-acp (+ dist/vibe-acp-dir/_internal/)
|
||||
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
@@ -7,7 +10,7 @@ core_builtins_deps = collect_all('vibe.core.tools.builtins')
|
||||
acp_builtins_deps = collect_all('vibe.acp.tools.builtins')
|
||||
|
||||
# Extract hidden imports and binaries, filtering to ensure only strings are in hiddenimports
|
||||
hidden_imports = []
|
||||
hidden_imports = ["truststore"]
|
||||
for item in core_builtins_deps[2] + acp_builtins_deps[2]:
|
||||
if isinstance(item, str):
|
||||
hidden_imports.append(item)
|
||||
@@ -31,7 +34,7 @@ a = Analysis(
|
||||
hiddenimports=hidden_imports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
runtime_hooks=["pyinstaller/runtime_hook_truststore.py"],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
@@ -41,8 +44,6 @@ pyz = PYZ(a.pure)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='vibe-acp',
|
||||
debug=False,
|
||||
@@ -50,7 +51,6 @@ exe = EXE(
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
@@ -58,3 +58,13 @@ exe = EXE(
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='vibe-acp-dir',
|
||||
)
|
||||
|
||||
@@ -3,4 +3,4 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.5.0"
|
||||
__version__ = "2.6.0"
|
||||
|
||||
@@ -26,11 +26,14 @@ from acp.schema import (
|
||||
AgentThoughtChunk,
|
||||
AllowedOutcome,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
AuthMethodAgent,
|
||||
AvailableCommand,
|
||||
AvailableCommandInput,
|
||||
ClientCapabilities,
|
||||
CloseSessionResponse,
|
||||
ContentToolCallContent,
|
||||
Cost,
|
||||
EnvVarAuthMethod,
|
||||
ForkSessionResponse,
|
||||
HttpMcpServer,
|
||||
Implementation,
|
||||
@@ -43,11 +46,14 @@ from acp.schema import (
|
||||
SessionListCapabilities,
|
||||
SetSessionConfigOptionResponse,
|
||||
SseMcpServer,
|
||||
TerminalAuthMethod,
|
||||
TextContentBlock,
|
||||
TextResourceContents,
|
||||
ToolCallProgress,
|
||||
ToolCallUpdate,
|
||||
UnstructuredCommandInput,
|
||||
Usage,
|
||||
UsageUpdate,
|
||||
UserMessageChunk,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -71,8 +77,8 @@ from vibe.acp.tools.session_update import (
|
||||
tool_result_session_update,
|
||||
)
|
||||
from vibe.acp.utils import (
|
||||
TOOL_OPTIONS,
|
||||
ToolOption,
|
||||
build_permission_options,
|
||||
create_assistant_message_replay,
|
||||
create_compact_end_session_update,
|
||||
create_compact_start_session_update,
|
||||
@@ -101,8 +107,9 @@ from vibe.core.proxy_setup import (
|
||||
unset_proxy_var,
|
||||
)
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.types import (
|
||||
AgentProfileChangedEvent,
|
||||
ApprovalCallback,
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
@@ -173,9 +180,10 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
and self.client_capabilities.field_meta.get("terminal-auth") is True
|
||||
)
|
||||
|
||||
auth_methods = (
|
||||
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = (
|
||||
[
|
||||
AuthMethod(
|
||||
TerminalAuthMethod(
|
||||
type="terminal",
|
||||
id="vibe-setup",
|
||||
name="Register your API Key",
|
||||
description="Register your API Key inside Mistral Vibe",
|
||||
@@ -228,9 +236,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"]
|
||||
)
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
return config
|
||||
except MissingAPIKeyError as e:
|
||||
@@ -263,18 +269,22 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
|
||||
config = self._load_config()
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
|
||||
# We should just use agent_loop.session_id everywhere, but it can still change during
|
||||
# session lifetime (e.g. agent_loop.compact is called).
|
||||
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
try:
|
||||
agent_loop = AgentLoop(
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
|
||||
# We should just use agent_loop.session_id everywhere, but it can still change during
|
||||
# session lifetime (e.g. agent_loop.compact is called).
|
||||
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
except Exception as e:
|
||||
raise ConfigurationError(str(e)) from e
|
||||
|
||||
agent_loop.emit_new_session_telemetry()
|
||||
|
||||
modes_state, modes_config = make_mode_response(
|
||||
@@ -314,17 +324,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
session = self._get_session(session_id)
|
||||
|
||||
def _handle_permission_selection(
|
||||
option_id: str, tool_name: str
|
||||
option_id: str,
|
||||
tool_name: str,
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
match option_id:
|
||||
case ToolOption.ALLOW_ONCE:
|
||||
return (ApprovalResponse.YES, None)
|
||||
case ToolOption.ALLOW_ALWAYS:
|
||||
if tool_name not in session.agent_loop.config.tools:
|
||||
session.agent_loop.config.tools[tool_name] = BaseToolConfig()
|
||||
session.agent_loop.config.tools[
|
||||
tool_name
|
||||
].permission = ToolPermission.ALWAYS
|
||||
session.agent_loop.approve_always(tool_name, required_permissions)
|
||||
return (ApprovalResponse.YES, None)
|
||||
case ToolOption.REJECT_ONCE:
|
||||
return (
|
||||
@@ -335,19 +343,33 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
||||
|
||||
async def approval_callback(
|
||||
tool_name: str, args: BaseModel, tool_call_id: str
|
||||
tool_name: str,
|
||||
args: BaseModel,
|
||||
tool_call_id: str,
|
||||
required_permissions: list | None = None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
# Create the tool call update
|
||||
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
|
||||
|
||||
response = await self.client.request_permission(
|
||||
session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS
|
||||
typed_permissions: list[RequiredPermission] | None = (
|
||||
[
|
||||
rp
|
||||
for rp in required_permissions
|
||||
if isinstance(rp, RequiredPermission)
|
||||
]
|
||||
if required_permissions
|
||||
else None
|
||||
)
|
||||
|
||||
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
|
||||
options = build_permission_options(typed_permissions)
|
||||
|
||||
response = await self.client.request_permission(
|
||||
session_id=session_id, tool_call=tool_call, options=options
|
||||
)
|
||||
|
||||
# Parse the response using isinstance for proper type narrowing
|
||||
if response.outcome.outcome == "selected":
|
||||
outcome = cast(AllowedOutcome, response.outcome)
|
||||
return _handle_permission_selection(outcome.option_id, tool_name)
|
||||
return _handle_permission_selection(
|
||||
outcome.option_id, tool_name, typed_permissions
|
||||
)
|
||||
else:
|
||||
return (
|
||||
ApprovalResponse.NO,
|
||||
@@ -365,6 +387,39 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
raise SessionNotFoundError(session_id)
|
||||
return self.sessions[session_id]
|
||||
|
||||
def _build_usage(self, session: AcpSessionLoop) -> Usage:
|
||||
stats = session.agent_loop.stats
|
||||
return Usage(
|
||||
input_tokens=stats.session_prompt_tokens,
|
||||
output_tokens=stats.session_completion_tokens,
|
||||
total_tokens=stats.session_total_llm_tokens,
|
||||
)
|
||||
|
||||
def _build_usage_update(self, session: AcpSessionLoop) -> UsageUpdate:
|
||||
stats = session.agent_loop.stats
|
||||
active_model = session.agent_loop.config.get_active_model()
|
||||
cost = (
|
||||
Cost(amount=stats.session_cost, currency="USD")
|
||||
if stats.input_price_per_million > 0 or stats.output_price_per_million > 0
|
||||
else None
|
||||
)
|
||||
return UsageUpdate(
|
||||
session_update="usage_update",
|
||||
used=stats.context_tokens,
|
||||
size=active_model.auto_compact_threshold,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
def _send_usage_update(self, session: AcpSessionLoop) -> None:
|
||||
async def _send() -> None:
|
||||
try:
|
||||
update = self._build_usage_update(session)
|
||||
await self.client.session_update(session_id=session.id, update=update)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(_send())
|
||||
|
||||
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
|
||||
if not msg.tool_calls:
|
||||
return
|
||||
@@ -463,7 +518,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
disabled_tools=["ask_user_question"],
|
||||
)
|
||||
await session.agent_loop.reload_with_initial_messages(
|
||||
base_config=new_config
|
||||
@@ -492,7 +547,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
})
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
disabled_tools=["ask_user_question"],
|
||||
)
|
||||
await session.agent_loop.reload_with_initial_messages(
|
||||
base_config=new_config
|
||||
@@ -549,6 +604,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
session = await self._create_acp_session(session_id, agent_loop)
|
||||
|
||||
await self._replay_conversation_history(session_id, non_system_messages)
|
||||
self._send_usage_update(session)
|
||||
|
||||
modes_state, modes_config = make_mode_response(
|
||||
list(agent_loop.agent_manager.available_agents.values()),
|
||||
@@ -589,7 +645,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
disabled_tools=["ask_user_question"],
|
||||
)
|
||||
|
||||
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||
@@ -675,7 +731,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
|
||||
@override
|
||||
async def prompt(
|
||||
self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
|
||||
self,
|
||||
prompt: list[ContentBlock],
|
||||
session_id: str,
|
||||
message_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> PromptResponse:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
@@ -708,7 +768,10 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
await session.task
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return PromptResponse(stop_reason="cancelled")
|
||||
self._send_usage_update(session)
|
||||
return PromptResponse(
|
||||
stop_reason="cancelled", usage=self._build_usage(session)
|
||||
)
|
||||
|
||||
except CoreRateLimitError as e:
|
||||
raise RateLimitError.from_core(e) from e
|
||||
@@ -722,7 +785,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
finally:
|
||||
session.task = None
|
||||
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
self._send_usage_update(session)
|
||||
return PromptResponse(stop_reason="end_turn", usage=self._build_usage(session))
|
||||
|
||||
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
|
||||
text_prompt = ""
|
||||
@@ -841,6 +905,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||
elif isinstance(event, CompactEndEvent):
|
||||
yield create_compact_end_session_update(event)
|
||||
|
||||
elif isinstance(event, AgentProfileChangedEvent):
|
||||
pass
|
||||
|
||||
@override
|
||||
async def close_session(
|
||||
self, session_id: str, **kwargs: Any
|
||||
) -> CloseSessionResponse | None:
|
||||
raise NotImplementedMethodError("close_session")
|
||||
|
||||
@override
|
||||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
@@ -8,7 +8,7 @@ import sys
|
||||
import tomli_w
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig
|
||||
from vibe.core.config.harness_files import (
|
||||
get_harness_files_manager,
|
||||
init_harness_files_manager,
|
||||
@@ -78,13 +78,23 @@ def main() -> None:
|
||||
init_harness_files_manager("user", "project")
|
||||
|
||||
from vibe.acp.acp_agent_loop import run_acp_server
|
||||
from vibe.core.config import VibeConfig, load_dotenv_values
|
||||
from vibe.core.tracing import setup_tracing
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
load_dotenv_values()
|
||||
bootstrap_config_files()
|
||||
args = parse_arguments()
|
||||
if args.setup:
|
||||
run_onboarding()
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
config = VibeConfig.load()
|
||||
setup_tracing(config)
|
||||
except MissingAPIKeyError:
|
||||
pass # tracing disabled, but server can still handle the error properly in new_session
|
||||
|
||||
run_acp_server()
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
ModelInfo,
|
||||
PermissionOption,
|
||||
SessionConfigOption,
|
||||
SessionConfigOptionSelect,
|
||||
SessionConfigSelectOption,
|
||||
SessionMode,
|
||||
@@ -23,6 +22,7 @@ from acp.schema import (
|
||||
|
||||
from vibe.core.agents.models import AgentProfile, AgentType
|
||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
||||
from vibe.core.utils import compact_reduction_display
|
||||
|
||||
@@ -45,7 +45,7 @@ TOOL_OPTIONS = [
|
||||
),
|
||||
PermissionOption(
|
||||
option_id=ToolOption.ALLOW_ALWAYS,
|
||||
name="Allow always",
|
||||
name="Allow for this session",
|
||||
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
|
||||
),
|
||||
PermissionOption(
|
||||
@@ -56,6 +56,44 @@ TOOL_OPTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def build_permission_options(
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
) -> list[PermissionOption]:
|
||||
"""Build ACP permission options, including granular labels when available."""
|
||||
if not required_permissions:
|
||||
return TOOL_OPTIONS
|
||||
|
||||
labels = ", ".join(rp.label for rp in required_permissions)
|
||||
permissions_meta = [
|
||||
{
|
||||
"scope": rp.scope,
|
||||
"invocation_pattern": rp.invocation_pattern,
|
||||
"session_pattern": rp.session_pattern,
|
||||
"label": rp.label,
|
||||
}
|
||||
for rp in required_permissions
|
||||
]
|
||||
|
||||
return [
|
||||
PermissionOption(
|
||||
option_id=ToolOption.ALLOW_ONCE,
|
||||
name="Allow once",
|
||||
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
|
||||
),
|
||||
PermissionOption(
|
||||
option_id=ToolOption.ALLOW_ALWAYS,
|
||||
name=f"Allow for this session: {labels}",
|
||||
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
|
||||
field_meta={"required_permissions": permissions_meta},
|
||||
),
|
||||
PermissionOption(
|
||||
option_id=ToolOption.REJECT_ONCE,
|
||||
name="Reject once",
|
||||
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
||||
return any(
|
||||
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
|
||||
@@ -64,7 +102,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
||||
|
||||
def make_mode_response(
|
||||
profiles: list[AgentProfile], current_mode_id: str
|
||||
) -> tuple[SessionModeState, SessionConfigOption]:
|
||||
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
|
||||
session_modes: list[SessionMode] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
@@ -89,22 +127,20 @@ def make_mode_response(
|
||||
state = SessionModeState(
|
||||
current_mode_id=current_mode_id, available_modes=session_modes
|
||||
)
|
||||
config = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="mode",
|
||||
name="Session Mode",
|
||||
current_value=current_mode_id,
|
||||
category="mode",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
config = SessionConfigOptionSelect(
|
||||
id="mode",
|
||||
name="Session Mode",
|
||||
current_value=current_mode_id,
|
||||
category="mode",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
return state, config
|
||||
|
||||
|
||||
def make_model_response(
|
||||
models: list[ModelConfig], current_model_id: str
|
||||
) -> tuple[SessionModelState, SessionConfigOption]:
|
||||
) -> tuple[SessionModelState, SessionConfigOptionSelect]:
|
||||
model_infos: list[ModelInfo] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
@@ -119,15 +155,13 @@ def make_model_response(
|
||||
state = SessionModelState(
|
||||
current_model_id=current_model_id, available_models=model_infos
|
||||
)
|
||||
config_option = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="model",
|
||||
name="Model",
|
||||
current_value=current_model_id,
|
||||
category="model",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
config_option = SessionConfigOptionSelect(
|
||||
id="model",
|
||||
name="Model",
|
||||
current_value=current_model_id,
|
||||
category="model",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
return state, config_option
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from rich import print as rprint
|
||||
import tomli_w
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import (
|
||||
@@ -22,6 +22,7 @@ from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tracing import setup_tracing
|
||||
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
@@ -104,6 +105,8 @@ def load_session(
|
||||
f"{config.session_logging.save_dir}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
elif args.resume is True:
|
||||
return None
|
||||
else:
|
||||
session_to_load = SessionLoader.find_session_by_id(
|
||||
args.resume, config.session_logging
|
||||
@@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||
try:
|
||||
initial_agent_name = get_initial_agent_name(args)
|
||||
config = load_config_or_exit()
|
||||
setup_tracing(config)
|
||||
|
||||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
@@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
startup=StartupOptions(
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
show_resume_picker=args.resume is True,
|
||||
),
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
|
||||
@@ -22,10 +22,15 @@ class CommandRegistry:
|
||||
handler="_show_help",
|
||||
),
|
||||
"config": Command(
|
||||
aliases=frozenset(["/config", "/model"]),
|
||||
aliases=frozenset(["/config"]),
|
||||
description="Edit config settings",
|
||||
handler="_show_config",
|
||||
),
|
||||
"model": Command(
|
||||
aliases=frozenset(["/model"]),
|
||||
description="Select active model",
|
||||
handler="_show_model",
|
||||
),
|
||||
"reload": Command(
|
||||
aliases=frozenset(["/reload"]),
|
||||
description="Reload configuration from disk",
|
||||
@@ -79,8 +84,8 @@ class CommandRegistry:
|
||||
),
|
||||
"voice": Command(
|
||||
aliases=frozenset(["/voice"]),
|
||||
description="Toggle voice mode on/off",
|
||||
handler="_toggle_voice_mode",
|
||||
description="Configure voice settings",
|
||||
handler="_show_voice_settings",
|
||||
),
|
||||
"leanstall": Command(
|
||||
aliases=frozenset(["/leanstall"]),
|
||||
|
||||
@@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace:
|
||||
)
|
||||
continuation_group.add_argument(
|
||||
"--resume",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_ID",
|
||||
help="Resume a specific session by its ID (supports partial matching)",
|
||||
help="Resume a session. Without SESSION_ID, shows an interactive picker.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
||||
WhoAmIPlanType,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import platform
|
||||
import subprocess
|
||||
from typing import Any, Literal
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class Terminal(Enum):
|
||||
VSCODE = "vscode"
|
||||
@@ -189,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
|
||||
|
||||
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
|
||||
if keybindings_path.exists():
|
||||
content = keybindings_path.read_text()
|
||||
content = read_safe(keybindings_path)
|
||||
return _parse_keybindings(content)
|
||||
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
import os
|
||||
@@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import (
|
||||
NotificationPort,
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.session_exit import print_session_resume_message
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
|
||||
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
|
||||
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
@@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import (
|
||||
WarningMessage,
|
||||
WhatsNewMessage,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
|
||||
from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
||||
@@ -65,6 +72,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
HISTORY_RESUME_TAIL_MESSAGES,
|
||||
LOAD_MORE_BATCH_SIZE,
|
||||
@@ -76,6 +84,13 @@ from vibe.cli.textual_ui.windowing import (
|
||||
should_resume_history,
|
||||
sync_backfill_state,
|
||||
)
|
||||
from vibe.cli.turn_summary import (
|
||||
NoopTurnSummary,
|
||||
TurnSummaryPort,
|
||||
TurnSummaryResult,
|
||||
TurnSummaryTracker,
|
||||
create_narrator_backend,
|
||||
)
|
||||
from vibe.cli.update_notifier import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
PyPIUpdateGateway,
|
||||
@@ -92,9 +107,11 @@ from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
|
||||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.audio_player.audio_player import AudioPlayer
|
||||
from vibe.core.audio_player.audio_player_port import AudioFormat
|
||||
from vibe.core.audio_recorder import AudioRecorder
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
@@ -109,17 +126,20 @@ from vibe.core.teleport.types import (
|
||||
TeleportSendingGithubTokenEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
)
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
AskUserQuestionResult,
|
||||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.transcribe import make_transcribe_client
|
||||
from vibe.core.tts.factory import make_tts_client
|
||||
from vibe.core.tts.tts_client_port import TTSClientPort
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
ApprovalResponse,
|
||||
Backend,
|
||||
LLMMessage,
|
||||
RateLimitError,
|
||||
Role,
|
||||
@@ -129,6 +149,7 @@ from vibe.core.utils import (
|
||||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
)
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class BottomApp(StrEnum):
|
||||
@@ -142,9 +163,11 @@ class BottomApp(StrEnum):
|
||||
Approval = auto()
|
||||
Config = auto()
|
||||
Input = auto()
|
||||
ModelPicker = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
SessionPicker = auto()
|
||||
Voice = auto()
|
||||
|
||||
|
||||
class ChatScroll(VerticalScroll):
|
||||
@@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll):
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
||||
|
||||
def _check_anchor(self) -> None:
|
||||
if self._anchored and self._anchor_released and self.is_at_bottom:
|
||||
self._anchor_released = False
|
||||
_reanchor_pending: bool = False
|
||||
_scrolling_down: bool = False
|
||||
|
||||
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
|
||||
super().watch_scroll_y(old_value, new_value)
|
||||
self._scrolling_down = new_value >= old_value
|
||||
|
||||
def release_anchor(self) -> None:
|
||||
super().release_anchor()
|
||||
# Textual's MRO dispatch calls Widget._on_mouse_scroll_down AFTER
|
||||
# our override, so any re-anchor we do gets immediately undone.
|
||||
# Defer the re-check until all handlers for this event have finished.
|
||||
if not self._reanchor_pending:
|
||||
self._reanchor_pending = True
|
||||
self.call_later(self._maybe_reanchor)
|
||||
|
||||
def _maybe_reanchor(self) -> None:
|
||||
self._reanchor_pending = False
|
||||
if (
|
||||
self._anchored
|
||||
and self._anchor_released
|
||||
and self.is_at_bottom
|
||||
and self._scrolling_down
|
||||
):
|
||||
self.anchor()
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
@@ -202,6 +247,13 @@ async def prune_oldest_children(
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StartupOptions:
|
||||
initial_prompt: str | None = None
|
||||
teleport_on_start: bool = False
|
||||
show_resume_picker: bool = False
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
CSS_PATH = "app.tcss"
|
||||
@@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||
def __init__(
|
||||
self,
|
||||
agent_loop: AgentLoop,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
startup: StartupOptions | None = None,
|
||||
update_notifier: UpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
@@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._update_cache_repository = update_cache_repository
|
||||
self._current_version = current_version
|
||||
self._plan_offer_gateway = plan_offer_gateway
|
||||
self._initial_prompt = initial_prompt
|
||||
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
|
||||
opts = startup or StartupOptions()
|
||||
self._initial_prompt = opts.initial_prompt
|
||||
self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled
|
||||
self._show_resume_picker = opts.show_resume_picker
|
||||
self._last_escape_time: float | None = None
|
||||
self._banner: Banner | None = None
|
||||
self._whats_new_message: WhatsNewMessage | None = None
|
||||
@@ -287,6 +340,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._switch_agent_generation = 0
|
||||
self._plan_info: PlanInfo | None = None
|
||||
self._turn_summary: TurnSummaryPort = self._make_turn_summary()
|
||||
self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set()
|
||||
self._tts_client: TTSClientPort | None = self._make_tts_client()
|
||||
self._audio_player = AudioPlayer()
|
||||
self._speak_task: asyncio.Task[None] | None = None
|
||||
self._cancel_summary: Callable[[], bool] | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
@@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904
|
||||
yield VerticalGroup(id="messages")
|
||||
|
||||
with Horizontal(id="loading-area"):
|
||||
yield NarratorStatus()
|
||||
yield Static(id="loading-area-content")
|
||||
yield FeedbackBar()
|
||||
|
||||
with Static(id="bottom-app-container"):
|
||||
yield ChatInputContainer(
|
||||
@@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._cached_messages_area = self.query_one("#messages")
|
||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||
self._cached_loading_area = self.query_one("#loading-area-content")
|
||||
self._feedback_bar = self.query_one(FeedbackBar)
|
||||
|
||||
self.event_handler = EventHandler(
|
||||
mount_callback=self._mount_and_scroll,
|
||||
get_tools_collapsed=lambda: self._tools_collapsed,
|
||||
on_profile_changed=self._on_profile_changed,
|
||||
)
|
||||
|
||||
self._chat_input_container = self.query_one(ChatInputContainer)
|
||||
@@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
if self._initial_prompt or self._teleport_on_start:
|
||||
if self._show_resume_picker:
|
||||
self.run_worker(self._show_session_picker(), exclusive=False)
|
||||
elif self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
||||
gc.collect()
|
||||
@@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||
async def on_approval_app_approval_granted_always_tool(
|
||||
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
|
||||
) -> None:
|
||||
self._set_tool_permission_always(
|
||||
message.tool_name, save_permanently=message.save_permanently
|
||||
)
|
||||
self.agent_loop.approve_always(message.tool_name, message.required_permissions)
|
||||
|
||||
if self._pending_approval and not self._pending_approval.done():
|
||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||
@@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904
|
||||
result = AskUserQuestionResult(answers=[], cancelled=True)
|
||||
self._pending_question.set_result(result)
|
||||
|
||||
def on_chat_text_area_feedback_key_pressed(
|
||||
self, message: ChatTextArea.FeedbackKeyPressed
|
||||
) -> None:
|
||||
self._feedback_bar.handle_feedback_key(message.rating)
|
||||
|
||||
def on_chat_text_area_non_feedback_key_pressed(
|
||||
self, message: ChatTextArea.NonFeedbackKeyPressed
|
||||
) -> None:
|
||||
self._feedback_bar.hide()
|
||||
|
||||
def on_feedback_bar_feedback_given(
|
||||
self, message: FeedbackBar.FeedbackGiven
|
||||
) -> None:
|
||||
self.agent_loop.telemetry_client.send_user_rating_feedback(
|
||||
rating=message.rating, model=self.config.active_model
|
||||
)
|
||||
|
||||
async def _remove_loading_widget(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
|
||||
async def on_config_app_open_model_picker(
|
||||
self, _message: ConfigApp.OpenModelPicker
|
||||
) -> None:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
changes = config_app._convert_changes_for_save()
|
||||
if changes:
|
||||
VibeConfig.save_updates(changes)
|
||||
await self._reload_config()
|
||||
await self._switch_to_input_app()
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
async def on_config_app_config_closed(
|
||||
self, message: ConfigApp.ConfigClosed
|
||||
) -> None:
|
||||
if message.changes:
|
||||
VibeConfig.save_updates(message.changes)
|
||||
await self._handle_config_settings_closed(message.changes)
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_voice_app_config_closed(self, message: VoiceApp.ConfigClosed) -> None:
|
||||
await self._handle_voice_settings_closed(message.changes)
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_config_settings_closed(
|
||||
self, changes: dict[str, str | bool]
|
||||
) -> None:
|
||||
if changes:
|
||||
VibeConfig.save_updates(changes)
|
||||
await self._reload_config()
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Configuration closed (no changes saved).")
|
||||
)
|
||||
|
||||
async def _handle_voice_settings_closed(
|
||||
self, changes: dict[str, str | bool]
|
||||
) -> None:
|
||||
if not changes:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Voice settings closed (no changes saved).")
|
||||
)
|
||||
return
|
||||
|
||||
if "voice_mode_enabled" in changes:
|
||||
current = self._voice_manager.is_enabled
|
||||
desired = changes["voice_mode_enabled"]
|
||||
if current != desired:
|
||||
self._voice_manager.toggle_voice_mode()
|
||||
self.agent_loop.telemetry_client.send_telemetry_event(
|
||||
"vibe.voice_mode_toggled", {"enabled": desired}
|
||||
)
|
||||
self.agent_loop.refresh_config()
|
||||
if desired:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Voice mode enabled. Press ctrl+r to start recording."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Voice mode disabled.")
|
||||
)
|
||||
|
||||
non_voice_changes = {
|
||||
k: v for k, v in changes.items() if k != "voice_mode_enabled"
|
||||
}
|
||||
if non_voice_changes:
|
||||
VibeConfig.save_updates(non_voice_changes)
|
||||
self.agent_loop.refresh_config()
|
||||
self._sync_turn_summary()
|
||||
|
||||
async def on_model_picker_app_model_selected(
|
||||
self, message: ModelPickerApp.ModelSelected
|
||||
) -> None:
|
||||
VibeConfig.save_updates({"active_model": message.alias})
|
||||
await self._reload_config()
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_model_picker_app_cancelled(
|
||||
self, _event: ModelPickerApp.Cancelled
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
@@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904
|
||||
for widget in children[:compact_index]:
|
||||
await widget.remove()
|
||||
|
||||
def _set_tool_permission_always(
|
||||
self, tool_name: str, save_permanently: bool = False
|
||||
) -> None:
|
||||
self.agent_loop.set_tool_permission(
|
||||
tool_name, ToolPermission.ALWAYS, save_permanently
|
||||
)
|
||||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
if cmd_name := self.commands.get_command_name(user_input):
|
||||
@@ -557,7 +698,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
|
||||
|
||||
try:
|
||||
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
|
||||
skill_content = read_safe(skill_info.skill_path)
|
||||
except OSError as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
@@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||
user_message = UserMessage(message)
|
||||
|
||||
await self._mount_and_scroll(user_message)
|
||||
if self.agent_loop.telemetry_client.is_active():
|
||||
self._feedback_bar.maybe_show()
|
||||
|
||||
if not self._agent_running:
|
||||
self._agent_task = asyncio.create_task(
|
||||
@@ -682,7 +825,11 @@ class VibeApp(App): # noqa: PLR0904
|
||||
return tool in self.agent_loop.tool_manager.available_tools
|
||||
|
||||
async def _approval_callback(
|
||||
self, tool: str, args: BaseModel, tool_call_id: str
|
||||
self,
|
||||
tool: str,
|
||||
args: BaseModel,
|
||||
tool_call_id: str,
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
|
||||
# This ensures subagents respect the main agent's tool restrictions
|
||||
@@ -695,7 +842,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
await self._switch_to_approval_app(tool, args, required_permissions)
|
||||
result = await self._pending_approval
|
||||
return result
|
||||
finally:
|
||||
@@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._pending_question = None
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_turn_error(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
|
||||
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||
self._agent_running = True
|
||||
|
||||
@@ -730,7 +883,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
try:
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
self._cancel_speak()
|
||||
self._turn_summary.start_turn(rendered_prompt)
|
||||
async for event in self.agent_loop.act(rendered_prompt):
|
||||
self._turn_summary.track(event)
|
||||
if self.event_handler:
|
||||
await self.event_handler.handle_event(
|
||||
event,
|
||||
@@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
await self._handle_turn_error()
|
||||
self._turn_summary.cancel_turn()
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
await self._handle_turn_error()
|
||||
|
||||
message = str(e)
|
||||
if isinstance(e, RateLimitError):
|
||||
message = self._rate_limit_message()
|
||||
self._turn_summary.set_error(message)
|
||||
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(message, collapsed=self._tools_collapsed)
|
||||
)
|
||||
finally:
|
||||
cancel_summary = self._turn_summary.end_turn()
|
||||
if (
|
||||
cancel_summary is not None
|
||||
and self.config.narrator_enabled
|
||||
and self._tts_client is not None
|
||||
):
|
||||
self._cancel_summary = cancel_summary
|
||||
self.query_one(NarratorStatus).state = NarratorState.SUMMARIZING
|
||||
self._agent_running = False
|
||||
self._interrupt_requested = False
|
||||
self._agent_task = None
|
||||
@@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||
return
|
||||
await self._switch_to_config_app()
|
||||
|
||||
async def _show_model(self) -> None:
|
||||
"""Switch to the model picker in the bottom panel."""
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
return
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
async def _show_proxy_setup(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
@@ -1045,6 +1211,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
|
||||
await self._resolve_plan()
|
||||
self._sync_turn_summary()
|
||||
|
||||
if self._banner:
|
||||
self._banner.set_state(
|
||||
@@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904
|
||||
lambda: self.config,
|
||||
audio_recorder=AudioRecorder(),
|
||||
transcribe_client=transcribe_client,
|
||||
telemetry_client=self.agent_loop.telemetry_client,
|
||||
)
|
||||
|
||||
async def _toggle_voice_mode(self) -> None:
|
||||
result = self._voice_manager.toggle_voice_mode()
|
||||
self.agent_loop.refresh_config()
|
||||
if result.enabled:
|
||||
msg = "Voice mode enabled. Press ctrl+r to start recording."
|
||||
else:
|
||||
msg = "Voice mode disabled."
|
||||
await self._mount_and_scroll(UserCommandMessage(msg))
|
||||
async def _show_voice_settings(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
await self._switch_to_voice_app()
|
||||
|
||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
@@ -1249,6 +1413,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._chat_input_container.display = False
|
||||
self._chat_input_container.disabled = True
|
||||
|
||||
self._feedback_bar.hide()
|
||||
|
||||
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
|
||||
await bottom_container.mount(widget)
|
||||
|
||||
@@ -1263,6 +1429,23 @@ class VibeApp(App): # noqa: PLR0904
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
||||
await self._switch_from_input(ConfigApp(self.config))
|
||||
|
||||
async def _switch_to_voice_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Voice settings opened..."))
|
||||
await self._switch_from_input(VoiceApp(self.config))
|
||||
|
||||
async def _switch_to_model_picker_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
return
|
||||
|
||||
model_aliases = [m.alias for m in self.config.models]
|
||||
current_model = str(self.config.active_model)
|
||||
await self._switch_from_input(
|
||||
ModelPickerApp(model_aliases=model_aliases, current_model=current_model)
|
||||
)
|
||||
|
||||
async def _switch_to_proxy_setup_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
@@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904
|
||||
await self._switch_from_input(ProxySetupApp())
|
||||
|
||||
async def _switch_to_approval_app(
|
||||
self, tool_name: str, tool_args: BaseModel
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
required_permissions: list[RequiredPermission] | None = None,
|
||||
) -> None:
|
||||
approval_app = ApprovalApp(
|
||||
tool_name=tool_name, tool_args=tool_args, config=self.config
|
||||
tool_name=tool_name,
|
||||
tool_args=tool_args,
|
||||
config=self.config,
|
||||
required_permissions=required_permissions,
|
||||
)
|
||||
await self._switch_from_input(approval_app, scroll=True)
|
||||
|
||||
@@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self.query_one(ChatInputContainer).focus_input()
|
||||
case BottomApp.Config:
|
||||
self.query_one(ConfigApp).focus()
|
||||
case BottomApp.ModelPicker:
|
||||
self.query_one(ModelPickerApp).focus()
|
||||
case BottomApp.ProxySetup:
|
||||
self.query_one(ProxySetupApp).focus()
|
||||
case BottomApp.Approval:
|
||||
@@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case BottomApp.Voice:
|
||||
self.query_one(VoiceApp).focus()
|
||||
case app:
|
||||
assert_never(app)
|
||||
except Exception:
|
||||
@@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_voice_app_escape(self) -> None:
|
||||
try:
|
||||
voice_app = self.query_one(VoiceApp)
|
||||
voice_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_approval_app_escape(self) -> None:
|
||||
try:
|
||||
approval_app = self.query_one(ApprovalApp)
|
||||
@@ -1345,6 +1546,14 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_model_picker_app_escape(self) -> None:
|
||||
try:
|
||||
model_picker = self.query_one(ModelPickerApp)
|
||||
model_picker.post_message(ModelPickerApp.Cancelled())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_session_picker_app_escape(self) -> None:
|
||||
try:
|
||||
session_picker = self.query_one(SessionPickerApp)
|
||||
@@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._handle_config_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
self._handle_voice_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
try:
|
||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||
@@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
self._handle_model_picker_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.SessionPicker:
|
||||
self._handle_session_picker_app_escape()
|
||||
return
|
||||
@@ -1405,6 +1622,11 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._handle_input_app_escape()
|
||||
return
|
||||
|
||||
narrator_status = self.query_one(NarratorStatus)
|
||||
if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE:
|
||||
self._cancel_speak()
|
||||
return
|
||||
|
||||
if self._agent_running:
|
||||
self._handle_agent_running_escape()
|
||||
|
||||
@@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||
def _refresh_profile_widgets(self) -> None:
|
||||
self._update_profile_widgets(self.agent_loop.agent_profile)
|
||||
|
||||
def _on_profile_changed(self) -> None:
|
||||
self._refresh_profile_widgets()
|
||||
self._refresh_banner()
|
||||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
self._banner.set_state(
|
||||
@@ -1730,31 +1956,99 @@ class VibeApp(App): # noqa: PLR0904
|
||||
# force a full layout refresh so the UI isn't garbled.
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _make_turn_summary(self) -> TurnSummaryPort:
|
||||
if not self.config.narrator_enabled:
|
||||
return NoopTurnSummary()
|
||||
result = create_narrator_backend(self.config)
|
||||
if result is None:
|
||||
return NoopTurnSummary()
|
||||
backend, model = result
|
||||
return TurnSummaryTracker(
|
||||
backend=backend, model=model, on_summary=self._on_turn_summary
|
||||
)
|
||||
|
||||
def _print_session_resume_message(session_id: str | None) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
def _on_turn_summary(self, result: TurnSummaryResult) -> None:
|
||||
self._cancel_summary = None
|
||||
if result.generation != self._turn_summary.generation:
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
return
|
||||
if result.summary is None:
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
return
|
||||
if self._tts_client is not None:
|
||||
self._speak_task = asyncio.create_task(self._speak_summary(result.summary))
|
||||
else:
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
|
||||
print()
|
||||
print("To continue this session, run: vibe --continue")
|
||||
print(f"Or: vibe --resume {session_id}")
|
||||
async def _speak_summary(self, text: str) -> None:
|
||||
if self._tts_client is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
tts_result = await self._tts_client.speak(text)
|
||||
self._set_narrator_state(NarratorState.SPEAKING)
|
||||
self._audio_player.play(
|
||||
tts_result.audio_data,
|
||||
AudioFormat.WAV,
|
||||
on_finished=lambda: loop.call_soon_threadsafe(
|
||||
self._set_narrator_state, NarratorState.IDLE
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("TTS speak failed", exc_info=True)
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
|
||||
def _cancel_speak(self) -> None:
|
||||
if self._cancel_summary is not None:
|
||||
self._cancel_summary()
|
||||
self._cancel_summary = None
|
||||
if self._speak_task is not None and not self._speak_task.done():
|
||||
self._speak_task.cancel()
|
||||
self._speak_task = None
|
||||
self._audio_player.stop()
|
||||
self._set_narrator_state(NarratorState.IDLE)
|
||||
|
||||
def _set_narrator_state(self, state: NarratorState) -> None:
|
||||
self.query_one(NarratorStatus).state = state
|
||||
|
||||
def _make_tts_client(self) -> TTSClientPort | None:
|
||||
if not self.config.narrator_enabled:
|
||||
return None
|
||||
try:
|
||||
model = self.config.get_active_tts_model()
|
||||
provider = self.config.get_tts_provider_for_model(model)
|
||||
return make_tts_client(provider, model)
|
||||
except (ValueError, KeyError) as exc:
|
||||
logger.error("Failed to initialize TTS client", exc_info=exc)
|
||||
return None
|
||||
|
||||
def _sync_turn_summary(self) -> None:
|
||||
self._cancel_speak()
|
||||
task = asyncio.create_task(self._turn_summary.close())
|
||||
self._turn_summary_close_tasks.add(task)
|
||||
task.add_done_callback(self._turn_summary_close_tasks.discard)
|
||||
self._turn_summary = self._make_turn_summary()
|
||||
|
||||
old_tts = self._tts_client
|
||||
self._tts_client = self._make_tts_client()
|
||||
if old_tts is not None:
|
||||
close_task = asyncio.create_task(old_tts.close())
|
||||
self._turn_summary_close_tasks.add(close_task)
|
||||
close_task.add_done_callback(self._turn_summary_close_tasks.discard)
|
||||
|
||||
|
||||
def run_textual_ui(
|
||||
agent_loop: AgentLoop,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
agent_loop: AgentLoop, startup: StartupOptions | None = None
|
||||
) -> None:
|
||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
plan_offer_gateway = HttpWhoAmIGateway()
|
||||
app = VibeApp(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=initial_prompt,
|
||||
teleport_on_start=teleport_on_start,
|
||||
startup=startup,
|
||||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
plan_offer_gateway=plan_offer_gateway,
|
||||
)
|
||||
session_id = app.run()
|
||||
_print_session_resume_message(session_id)
|
||||
print_session_resume_message(session_id, agent_loop.stats)
|
||||
|
||||
@@ -160,7 +160,7 @@ Markdown {
|
||||
color: ansi_default;
|
||||
|
||||
.code_inline {
|
||||
color: ansi_yellow;
|
||||
color: ansi_green;
|
||||
background: transparent;
|
||||
text-style: bold;
|
||||
}
|
||||
@@ -625,7 +625,8 @@ StatusMessage {
|
||||
.loading-hint {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
color: ansi_bright_black;
|
||||
|
||||
}
|
||||
|
||||
.history-load-more-message {
|
||||
@@ -655,7 +656,7 @@ StatusMessage {
|
||||
|
||||
}
|
||||
|
||||
#config-app {
|
||||
#config-app, #voice-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
@@ -664,7 +665,7 @@ StatusMessage {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#config-content {
|
||||
#config-content, #voice-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
@@ -675,41 +676,14 @@ StatusMessage {
|
||||
color: ansi_blue;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
#config-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.settings-cursor-selected {
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-label-selected {
|
||||
color: ansi_default;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-toggle-on-selected {
|
||||
color: ansi_green;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-toggle-on-unselected {
|
||||
color: ansi_green;
|
||||
}
|
||||
|
||||
.settings-value-toggle-off {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.settings-value-cycle-selected {
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-cycle-unselected {
|
||||
color: ansi_blue;
|
||||
#config-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.settings-help {
|
||||
@@ -953,6 +927,14 @@ ContextProgress {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
NarratorStatus {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0 0 0 1;
|
||||
}
|
||||
|
||||
#banner-container {
|
||||
align: left middle;
|
||||
padding: 0 1 0 0;
|
||||
@@ -1083,3 +1065,54 @@ ContextProgress {
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#modelpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#modelpicker-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.modelpicker-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
}
|
||||
|
||||
#modelpicker-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#modelpicker-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modelpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
FeedbackBar {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-left: 1;
|
||||
}
|
||||
|
||||
#feedback-text {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
}
|
||||
|
||||
11
vibe/cli/textual_ui/constants.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class MistralColors(StrEnum):
|
||||
RED = "#E10500"
|
||||
ORANGE_DARK = "#FA500F"
|
||||
ORANGE = "#FF8205"
|
||||
ORANGE_LIGHT = "#FFAF00"
|
||||
YELLOW = "#FFD800"
|
||||
@@ -6,6 +6,8 @@ import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class ExternalEditor:
|
||||
"""Handles opening an external editor to edit prompt content."""
|
||||
@@ -24,7 +26,7 @@ class ExternalEditor:
|
||||
parts = shlex.split(editor)
|
||||
subprocess.run([*parts, filepath], check=True)
|
||||
|
||||
content = Path(filepath).read_text().rstrip()
|
||||
content = read_safe(Path(filepath)).rstrip()
|
||||
return content if content != initial_content else None
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||
from vibe.core.types import (
|
||||
AgentProfileChangedEvent,
|
||||
AssistantEvent,
|
||||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
@@ -27,10 +28,14 @@ if TYPE_CHECKING:
|
||||
|
||||
class EventHandler:
|
||||
def __init__(
|
||||
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
|
||||
self,
|
||||
mount_callback: Callable,
|
||||
get_tools_collapsed: Callable[[], bool],
|
||||
on_profile_changed: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.on_profile_changed = on_profile_changed
|
||||
self.tool_calls: dict[str, ToolCallMessage] = {}
|
||||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
@@ -62,6 +67,9 @@ class EventHandler:
|
||||
case CompactEndEvent():
|
||||
await self.finalize_streaming()
|
||||
await self._handle_compact_end(event)
|
||||
case AgentProfileChangedEvent():
|
||||
if self.on_profile_changed:
|
||||
self.on_profile_changed()
|
||||
case UserMessageEvent():
|
||||
await self.finalize_streaming()
|
||||
case _:
|
||||
|
||||
25
vibe/cli/textual_ui/session_exit.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe.core.types import AgentStats
|
||||
|
||||
|
||||
def format_session_usage(stats: AgentStats) -> str:
|
||||
return (
|
||||
"Total tokens used this session: "
|
||||
f"input={stats.session_prompt_tokens:,} "
|
||||
f"output={stats.session_completion_tokens:,} "
|
||||
f"(total={stats.session_total_llm_tokens:,})"
|
||||
)
|
||||
|
||||
|
||||
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
|
||||
print()
|
||||
print(format_session_usage(stats))
|
||||
print()
|
||||
rprint("To continue this session, run: [bold dark_orange]vibe --continue[/]")
|
||||
rprint(f"Or: [bold dark_orange]vibe --resume {session_id}[/]")
|
||||
@@ -13,6 +13,7 @@ from textual.widgets import Static
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
|
||||
|
||||
class ApprovalApp(Container):
|
||||
@@ -38,12 +39,15 @@ class ApprovalApp(Container):
|
||||
|
||||
class ApprovalGrantedAlwaysTool(Message):
|
||||
def __init__(
|
||||
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
required_permissions: list[RequiredPermission],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tool_name = tool_name
|
||||
self.tool_args = tool_args
|
||||
self.save_permanently = save_permanently
|
||||
self.required_permissions = required_permissions
|
||||
|
||||
class ApprovalRejected(Message):
|
||||
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
|
||||
@@ -52,12 +56,17 @@ class ApprovalApp(Container):
|
||||
self.tool_args = tool_args
|
||||
|
||||
def __init__(
|
||||
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
config: VibeConfig,
|
||||
required_permissions: list[RequiredPermission] | None = None,
|
||||
) -> None:
|
||||
super().__init__(id="approval-app")
|
||||
self.tool_name = tool_name
|
||||
self.tool_args = tool_args
|
||||
self.config = config
|
||||
self.required_permissions = required_permissions or []
|
||||
self.selected_option = 0
|
||||
self.content_container: Vertical | None = None
|
||||
self.title_widget: Static | None = None
|
||||
@@ -104,9 +113,15 @@ class ApprovalApp(Container):
|
||||
await self.tool_info_container.mount(approval_widget)
|
||||
|
||||
def _update_options(self) -> None:
|
||||
if self.required_permissions:
|
||||
labels = ", ".join(rp.label for rp in self.required_permissions)
|
||||
always_text = f"Yes and always allow for this session: {labels}"
|
||||
else:
|
||||
always_text = f"Yes and always allow {self.tool_name} for this session"
|
||||
|
||||
options = [
|
||||
("Yes", "yes"),
|
||||
(f"Yes and always allow {self.tool_name} for this session", "yes"),
|
||||
(always_text, "yes"),
|
||||
("No and tell the agent what to do instead", "no"),
|
||||
]
|
||||
|
||||
@@ -178,7 +193,7 @@ class ApprovalApp(Container):
|
||||
self.ApprovalGrantedAlwaysTool(
|
||||
tool_name=self.tool_name,
|
||||
tool_args=self.tool_args,
|
||||
save_permanently=False,
|
||||
required_permissions=self.required_permissions,
|
||||
)
|
||||
)
|
||||
case 2:
|
||||
|
||||
@@ -161,6 +161,16 @@ class ChatTextArea(TextArea):
|
||||
self.post_message(self.HistoryNext())
|
||||
return True
|
||||
|
||||
class FeedbackKeyPressed(Message):
|
||||
def __init__(self, rating: int) -> None:
|
||||
self.rating = rating
|
||||
super().__init__()
|
||||
|
||||
class NonFeedbackKeyPressed(Message):
|
||||
pass
|
||||
|
||||
feedback_active: bool = False
|
||||
|
||||
async def _handle_voice_key(self, event: events.Key) -> bool:
|
||||
if not self._voice_manager:
|
||||
return False
|
||||
@@ -193,6 +203,15 @@ class ChatTextArea(TextArea):
|
||||
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
if self.feedback_active:
|
||||
if event.character in {"1", "2", "3"}:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.post_message(self.FeedbackKeyPressed(int(event.character)))
|
||||
return
|
||||
if event.character is not None:
|
||||
self.post_message(self.NonFeedbackKeyPressed())
|
||||
|
||||
manager = self._completion_manager
|
||||
if manager:
|
||||
match manager.on_key(
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar, TypedDict
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual import events
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.events import DescendantBlur
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
@@ -15,22 +17,13 @@ if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
class SettingDefinition(TypedDict):
|
||||
key: str
|
||||
label: str
|
||||
type: str
|
||||
options: list[str]
|
||||
|
||||
|
||||
class ConfigApp(Container):
|
||||
can_focus = True
|
||||
can_focus_children = False
|
||||
"""Settings panel with navigatable option picker."""
|
||||
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "move_up", "Up", show=False),
|
||||
Binding("down", "move_down", "Down", show=False),
|
||||
Binding("space", "toggle_setting", "Toggle", show=False),
|
||||
Binding("enter", "cycle", "Next", show=False),
|
||||
Binding("escape", "close", "Close", show=False)
|
||||
]
|
||||
|
||||
class SettingChanged(Message):
|
||||
@@ -44,122 +37,92 @@ class ConfigApp(Container):
|
||||
super().__init__()
|
||||
self.changes = changes
|
||||
|
||||
class OpenModelPicker(Message):
|
||||
pass
|
||||
|
||||
def __init__(self, config: VibeConfig) -> None:
|
||||
super().__init__(id="config-app")
|
||||
self.config = config
|
||||
self.selected_index = 0
|
||||
self.changes: dict[str, str] = {}
|
||||
|
||||
self.settings: list[SettingDefinition] = [
|
||||
{
|
||||
"key": "active_model",
|
||||
"label": "Model",
|
||||
"type": "cycle",
|
||||
"options": [m.alias for m in self.config.models],
|
||||
},
|
||||
{
|
||||
"key": "autocopy_to_clipboard",
|
||||
"label": "Auto-copy",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
{
|
||||
"key": "file_watcher_for_autocomplete",
|
||||
"label": "Autocomplete watcher (may delay first autocompletion)",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
self._toggle_settings: list[tuple[str, str]] = [
|
||||
("autocopy_to_clipboard", "Auto-copy"),
|
||||
(
|
||||
"file_watcher_for_autocomplete",
|
||||
"Autocomplete watcher (may delay first autocompletion)",
|
||||
),
|
||||
]
|
||||
|
||||
self.title_widget: Static | None = None
|
||||
self.setting_widgets: list[Static] = []
|
||||
self.help_widget: Static | None = None
|
||||
def _get_current_model(self) -> str:
|
||||
return str(getattr(self.config, "active_model", ""))
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="config-content"):
|
||||
self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
|
||||
yield self.title_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in self.settings:
|
||||
widget = NoMarkupStatic("", classes="settings-option")
|
||||
self.setting_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_display()
|
||||
self.focus()
|
||||
|
||||
def _get_display_value(self, setting: SettingDefinition) -> str:
|
||||
key = setting["key"]
|
||||
def _get_toggle_value(self, key: str) -> str:
|
||||
if key in self.changes:
|
||||
return self.changes[key]
|
||||
raw_value = getattr(self.config, key, "")
|
||||
if isinstance(raw_value, bool):
|
||||
return "On" if raw_value else "Off"
|
||||
return str(raw_value)
|
||||
raw = getattr(self.config, key, False)
|
||||
if isinstance(raw, bool):
|
||||
return "On" if raw else "Off"
|
||||
return str(raw)
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, (setting, widget) in enumerate(
|
||||
zip(self.settings, self.setting_widgets, strict=True)
|
||||
):
|
||||
is_selected = i == self.selected_index
|
||||
cursor = "› " if is_selected else " "
|
||||
def _model_prompt(self) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
text.append("Model: ")
|
||||
text.append(self._get_current_model(), style="bold")
|
||||
return text
|
||||
|
||||
label: str = setting["label"]
|
||||
value: str = self._get_display_value(setting)
|
||||
def _toggle_prompt(self, key: str, label: str) -> Text:
|
||||
value = self._get_toggle_value(key)
|
||||
text = Text(no_wrap=True)
|
||||
text.append(f"{label}: ")
|
||||
if value == "On":
|
||||
text.append("On", style="green bold")
|
||||
else:
|
||||
text.append("Off", style="dim")
|
||||
return text
|
||||
|
||||
text = f"{cursor}{label}: {value}"
|
||||
def compose(self) -> ComposeResult:
|
||||
options: list[Option] = [Option(self._model_prompt(), id="action:active_model")]
|
||||
for key, label in self._toggle_settings:
|
||||
options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}"))
|
||||
|
||||
widget.update(text)
|
||||
with Vertical(id="config-content"):
|
||||
yield NoMarkupStatic("Settings", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
yield OptionList(*options, id="config-options")
|
||||
yield NoMarkupStatic("")
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help"
|
||||
)
|
||||
|
||||
widget.remove_class("settings-cursor-selected")
|
||||
widget.remove_class("settings-value-cycle-selected")
|
||||
widget.remove_class("settings-value-cycle-unselected")
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
if is_selected:
|
||||
widget.add_class("settings-value-cycle-selected")
|
||||
else:
|
||||
widget.add_class("settings-value-cycle-unselected")
|
||||
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def action_move_up(self) -> None:
|
||||
self.selected_index = (self.selected_index - 1) % len(self.settings)
|
||||
self._update_display()
|
||||
def _refresh_options(self) -> None:
|
||||
option_list = self.query_one(OptionList)
|
||||
option_list.replace_option_prompt("action:active_model", self._model_prompt())
|
||||
for key, label in self._toggle_settings:
|
||||
option_list.replace_option_prompt(
|
||||
f"toggle:{key}", self._toggle_prompt(key, label)
|
||||
)
|
||||
|
||||
def action_move_down(self) -> None:
|
||||
self.selected_index = (self.selected_index + 1) % len(self.settings)
|
||||
self._update_display()
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
option_id = event.option.id
|
||||
if not option_id:
|
||||
return
|
||||
|
||||
def action_toggle_setting(self) -> None:
|
||||
setting = self.settings[self.selected_index]
|
||||
key: str = setting["key"]
|
||||
current: str = self._get_display_value(setting)
|
||||
if option_id == "action:active_model":
|
||||
self.post_message(self.OpenModelPicker())
|
||||
return
|
||||
|
||||
options: list[str] = setting["options"]
|
||||
new_value = ""
|
||||
try:
|
||||
current_idx = options.index(current)
|
||||
next_idx = (current_idx + 1) % len(options)
|
||||
new_value = options[next_idx]
|
||||
except (ValueError, IndexError):
|
||||
new_value = options[0] if options else current
|
||||
|
||||
self.changes[key] = new_value
|
||||
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
|
||||
self._update_display()
|
||||
|
||||
def action_cycle(self) -> None:
|
||||
self.action_toggle_setting()
|
||||
if option_id.startswith("toggle:"):
|
||||
key = option_id.removeprefix("toggle:")
|
||||
current = self._get_toggle_value(key)
|
||||
new_value = "Off" if current == "On" else "On"
|
||||
self.changes[key] = new_value
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
self._refresh_options()
|
||||
|
||||
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
||||
result: dict[str, str | bool] = {}
|
||||
@@ -172,6 +135,3 @@ class ConfigApp(Container):
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
||||
69
vibe/cli/textual_ui/widgets/feedback_bar.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
|
||||
FEEDBACK_PROBABILITY = 0.02
|
||||
THANK_YOU_DURATION = 2.0
|
||||
|
||||
|
||||
class FeedbackBar(Widget):
|
||||
class FeedbackGiven(Message):
|
||||
def __init__(self, rating: int) -> None:
|
||||
super().__init__()
|
||||
self.rating = rating
|
||||
|
||||
@staticmethod
|
||||
def _prompt_text() -> Text:
|
||||
text = Text()
|
||||
text.append("How is Vibe doing so far? ")
|
||||
text.append("1", style="blue")
|
||||
text.append(": good ")
|
||||
text.append("2", style="blue")
|
||||
text.append(": fine ")
|
||||
text.append("3", style="blue")
|
||||
text.append(": bad")
|
||||
return text
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._prompt_text(), id="feedback-text")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.display = False
|
||||
|
||||
def maybe_show(self) -> None:
|
||||
if self.display:
|
||||
return
|
||||
if random.random() <= FEEDBACK_PROBABILITY:
|
||||
self._set_active(True)
|
||||
|
||||
def hide(self) -> None:
|
||||
if self.display:
|
||||
self._set_active(False)
|
||||
|
||||
def handle_feedback_key(self, rating: int) -> None:
|
||||
try:
|
||||
self.app.query_one(ChatTextArea).feedback_active = False
|
||||
except Exception:
|
||||
pass
|
||||
self.query_one("#feedback-text", Static).update(
|
||||
Text("Thank you for your feedback!")
|
||||
)
|
||||
self.post_message(self.FeedbackGiven(rating))
|
||||
self.set_timer(THANK_YOU_DURATION, lambda: self._set_active(False))
|
||||
|
||||
def _set_active(self, active: bool) -> None:
|
||||
if active:
|
||||
self.query_one("#feedback-text", Static).update(self._prompt_text())
|
||||
self.display = active
|
||||
try:
|
||||
self.app.query_one(ChatTextArea).feedback_active = active
|
||||
except Exception:
|
||||
pass
|
||||