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
|
enable-cache: true
|
||||||
cache-dependency-glob: "uv.lock"
|
cache-dependency-glob: "uv.lock"
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
run: uv python install 3.12
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Sync dependencies
|
- name: Sync dependencies
|
||||||
run: uv sync --no-dev --group build
|
run: uv sync --no-dev --group build
|
||||||
|
|
@ -86,19 +84,20 @@ jobs:
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: python -c "import subprocess; version = subprocess.check_output(['uv', 'version']).decode().split()[1]; print(f'version={version}')" >> $env:GITHUB_OUTPUT
|
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)
|
- name: Smoke test bundled binary (Unix)
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
||||||
if: ${{ matrix.os != 'windows' }}
|
if: ${{ matrix.os != 'windows' }}
|
||||||
with:
|
run: ./dist/vibe-acp-dir/vibe-acp --version
|
||||||
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_unix.outputs.version }}
|
|
||||||
path: dist/vibe-acp
|
|
||||||
|
|
||||||
- name: Upload binary as artifact (Windows)
|
- name: Smoke test bundled binary (Windows)
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
||||||
if: ${{ matrix.os == '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:
|
with:
|
||||||
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_windows.outputs.version }}
|
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.exe
|
path: dist/vibe-acp-dir/
|
||||||
|
|
||||||
attach-to-release:
|
attach-to-release:
|
||||||
needs: build-and-upload
|
needs: build-and-upload
|
||||||
|
|
@ -117,11 +116,9 @@ jobs:
|
||||||
mkdir release-assets
|
mkdir release-assets
|
||||||
for dir in artifacts/*; do
|
for dir in artifacts/*; do
|
||||||
name=$(basename "$dir")
|
name=$(basename "$dir")
|
||||||
if [ -f "$dir/vibe-acp" ]; then
|
if [ -f "$dir/vibe-acp" ] || [ -f "$dir/vibe-acp.exe" ]; then
|
||||||
chmod +x "$dir/vibe-acp"
|
chmod -f +x "$dir/vibe-acp" 2>/dev/null || true
|
||||||
zip -j "release-assets/${name}.zip" "$dir/vibe-acp"
|
(cd "$dir" && zip -r "../../release-assets/${name}.zip" .)
|
||||||
elif [ -f "$dir/vibe-acp.exe" ]; then
|
|
||||||
zip -j "release-assets/${name}.zip" "$dir/vibe-acp.exe"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
2
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "ACP Server",
|
"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/),
|
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).
|
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
|
## [2.5.0] - 2026-03-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
[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"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[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"
|
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]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
@ -27,7 +27,7 @@ classifiers = [
|
||||||
"Topic :: Utilities",
|
"Topic :: Utilities",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol==0.8.1",
|
"agent-client-protocol==0.9.0a1",
|
||||||
"anyio>=4.12.0",
|
"anyio>=4.12.0",
|
||||||
"cachetools>=5.5.0",
|
"cachetools>=5.5.0",
|
||||||
"cryptography>=44.0.0,<=46.0.3",
|
"cryptography>=44.0.0,<=46.0.3",
|
||||||
|
|
@ -39,6 +39,10 @@ dependencies = [
|
||||||
"markdownify>=1.2.2",
|
"markdownify>=1.2.2",
|
||||||
"mcp>=1.14.0",
|
"mcp>=1.14.0",
|
||||||
"mistralai==2.0.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",
|
"packaging>=24.1",
|
||||||
"pexpect>=4.9.0",
|
"pexpect>=4.9.0",
|
||||||
"pydantic>=2.12.4",
|
"pydantic>=2.12.4",
|
||||||
|
|
@ -103,18 +107,21 @@ dev = [
|
||||||
"vulture>=2.14",
|
"vulture>=2.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
build = ["pyinstaller>=6.17.0"]
|
build = ["pyinstaller>=6.17.0", "truststore>=0.10.4"]
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
reportMissingTypeStubs = false
|
reportMissingTypeStubs = false
|
||||||
reportPrivateImportUsage = false
|
reportPrivateImportUsage = false
|
||||||
|
|
||||||
include = ["vibe/**/*.py", "tests/**/*.py"]
|
include = ["vibe/**/*.py", "tests/**/*.py"]
|
||||||
|
exclude = ["pyinstaller/"]
|
||||||
venvPath = "."
|
venvPath = "."
|
||||||
venv = ".venv"
|
venv = ".venv"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
include = ["vibe/**/*.py", "tests/**/*.py"]
|
include = ["vibe/**/*.py", "tests/**/*.py"]
|
||||||
|
exclude = ["pyinstaller/"]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
preview = true
|
preview = true
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,9 @@ def create_release_branch(version: str) -> None:
|
||||||
print(f"Created and switched to branch {branch_name}")
|
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"
|
previous_tag = f"v{previous_version}-private"
|
||||||
current_tag = f"v{current_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}")
|
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
|
||||||
print("Successfully cherry-picked all commits")
|
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:
|
def get_commits_summary(previous_version: str, current_version: str) -> str:
|
||||||
previous_tag = f"v{previous_version}-private"
|
previous_tag = f"v{previous_version}-private"
|
||||||
|
|
@ -182,6 +231,7 @@ def print_summary(
|
||||||
previous_version: str,
|
previous_version: str,
|
||||||
commits_summary: str,
|
commits_summary: str,
|
||||||
changelog_entry: str,
|
changelog_entry: str,
|
||||||
|
squash: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
print("\n" + "=" * 80)
|
print("\n" + "=" * 80)
|
||||||
print("RELEASE PREPARATION SUMMARY")
|
print("RELEASE PREPARATION SUMMARY")
|
||||||
|
|
@ -204,14 +254,15 @@ def print_summary(
|
||||||
print(changelog_entry)
|
print(changelog_entry)
|
||||||
|
|
||||||
print("\n" + "-" * 80)
|
print("\n" + "-" * 80)
|
||||||
print("NEXT STEPS")
|
if not squash:
|
||||||
print("-" * 80)
|
print("NEXT STEPS")
|
||||||
print(
|
print("-" * 80)
|
||||||
f"To review/edit commits before publishing, use interactive rebase:\n"
|
print(
|
||||||
f" git rebase -i v{previous_version}"
|
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("REMINDERS")
|
||||||
print("-" * 80)
|
print("-" * 80)
|
||||||
print("Before publishing the release:")
|
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("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()
|
args = parser.parse_args()
|
||||||
current_version = args.version
|
current_version = args.version
|
||||||
|
squash = args.squash
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Ensure public remote exists
|
# Step 1: Ensure public remote exists
|
||||||
|
|
@ -264,7 +323,7 @@ def main() -> None:
|
||||||
create_release_branch(current_version)
|
create_release_branch(current_version)
|
||||||
|
|
||||||
# Step 7: Cherry-pick commits
|
# 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
|
# Step 8: Get summary information
|
||||||
commits_summary = get_commits_summary(previous_version, current_version)
|
commits_summary = get_commits_summary(previous_version, current_version)
|
||||||
|
|
@ -272,7 +331,7 @@ def main() -> None:
|
||||||
|
|
||||||
# Step 9: Print summary
|
# Step 9: Print summary
|
||||||
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:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -759,6 +759,42 @@ class TestToolCallStructure:
|
||||||
)
|
)
|
||||||
assert rejected_tool_call is not None
|
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.skip(reason="Long running tool call updates are not implemented yet")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_in_progress_update_structure(
|
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()),
|
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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 == []
|
assert response.auth_methods == []
|
||||||
|
|
@ -52,7 +52,7 @@ class TestACPInitialize:
|
||||||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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
|
assert response.auth_methods is not None
|
||||||
|
|
|
||||||
|
|
@ -100,13 +100,11 @@ class TestLoadSession:
|
||||||
|
|
||||||
assert response.config_options is not None
|
assert response.config_options is not None
|
||||||
assert len(response.config_options) == 2
|
assert len(response.config_options) == 2
|
||||||
assert response.config_options[0].root.id == "mode"
|
assert response.config_options[0].id == "mode"
|
||||||
assert response.config_options[0].root.category == "mode"
|
assert response.config_options[0].category == "mode"
|
||||||
assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT
|
assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT
|
||||||
assert len(response.config_options[0].root.options) == 5
|
assert len(response.config_options[0].options) == 5
|
||||||
mode_option_values = {
|
mode_option_values = {opt.value for opt in response.config_options[0].options}
|
||||||
opt.value for opt in response.config_options[0].root.options
|
|
||||||
}
|
|
||||||
assert mode_option_values == {
|
assert mode_option_values == {
|
||||||
BuiltinAgentName.DEFAULT,
|
BuiltinAgentName.DEFAULT,
|
||||||
BuiltinAgentName.CHAT,
|
BuiltinAgentName.CHAT,
|
||||||
|
|
@ -114,13 +112,11 @@ class TestLoadSession:
|
||||||
BuiltinAgentName.PLAN,
|
BuiltinAgentName.PLAN,
|
||||||
BuiltinAgentName.ACCEPT_EDITS,
|
BuiltinAgentName.ACCEPT_EDITS,
|
||||||
}
|
}
|
||||||
assert response.config_options[1].root.id == "model"
|
assert response.config_options[1].id == "model"
|
||||||
assert response.config_options[1].root.category == "model"
|
assert response.config_options[1].category == "model"
|
||||||
assert response.config_options[1].root.current_value == "devstral-latest"
|
assert response.config_options[1].current_value == "devstral-latest"
|
||||||
assert len(response.config_options[1].root.options) == 2
|
assert len(response.config_options[1].options) == 2
|
||||||
model_option_values = {
|
model_option_values = {opt.value for opt in response.config_options[1].options}
|
||||||
opt.value for opt in response.config_options[1].root.options
|
|
||||||
}
|
|
||||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,11 @@ class TestACPNewSession:
|
||||||
|
|
||||||
# Mode config option
|
# Mode config option
|
||||||
mode_config = session_response.config_options[0]
|
mode_config = session_response.config_options[0]
|
||||||
assert mode_config.root.id == "mode"
|
assert mode_config.id == "mode"
|
||||||
assert mode_config.root.category == "mode"
|
assert mode_config.category == "mode"
|
||||||
assert mode_config.root.current_value == BuiltinAgentName.DEFAULT
|
assert mode_config.current_value == BuiltinAgentName.DEFAULT
|
||||||
assert len(mode_config.root.options) == 5
|
assert len(mode_config.options) == 5
|
||||||
mode_option_values = {opt.value for opt in mode_config.root.options}
|
mode_option_values = {opt.value for opt in mode_config.options}
|
||||||
assert mode_option_values == {
|
assert mode_option_values == {
|
||||||
BuiltinAgentName.DEFAULT,
|
BuiltinAgentName.DEFAULT,
|
||||||
BuiltinAgentName.CHAT,
|
BuiltinAgentName.CHAT,
|
||||||
|
|
@ -118,11 +118,11 @@ class TestACPNewSession:
|
||||||
|
|
||||||
# Model config option
|
# Model config option
|
||||||
model_config = session_response.config_options[1]
|
model_config = session_response.config_options[1]
|
||||||
assert model_config.root.id == "model"
|
assert model_config.id == "model"
|
||||||
assert model_config.root.category == "model"
|
assert model_config.category == "model"
|
||||||
assert model_config.root.current_value == "devstral-latest"
|
assert model_config.current_value == "devstral-latest"
|
||||||
assert len(model_config.root.options) == 2
|
assert len(model_config.options) == 2
|
||||||
model_option_values = {opt.value for opt in model_config.root.options}
|
model_option_values = {opt.value for opt in model_config.options}
|
||||||
assert model_option_values == {"devstral-latest", "devstral-small"}
|
assert model_option_values == {"devstral-latest", "devstral-small"}
|
||||||
|
|
||||||
@pytest.mark.skip(reason="TODO: Fix this test")
|
@pytest.mark.skip(reason="TODO: Fix this test")
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ class TestACPSetConfigOptionMode:
|
||||||
|
|
||||||
# Verify config_options reflect the new state
|
# Verify config_options reflect the new state
|
||||||
mode_config = response.config_options[0]
|
mode_config = response.config_options[0]
|
||||||
assert mode_config.root.id == "mode"
|
assert mode_config.id == "mode"
|
||||||
assert mode_config.root.current_value == BuiltinAgentName.AUTO_APPROVE
|
assert mode_config.current_value == BuiltinAgentName.AUTO_APPROVE
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_config_option_mode_to_plan(
|
async def test_set_config_option_mode_to_plan(
|
||||||
|
|
@ -133,8 +133,8 @@ class TestACPSetConfigOptionMode:
|
||||||
) # Chat mode auto-approves read-only tools
|
) # Chat mode auto-approves read-only tools
|
||||||
|
|
||||||
mode_config = response.config_options[0]
|
mode_config = response.config_options[0]
|
||||||
assert mode_config.root.id == "mode"
|
assert mode_config.id == "mode"
|
||||||
assert mode_config.root.current_value == BuiltinAgentName.CHAT
|
assert mode_config.current_value == BuiltinAgentName.CHAT
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_config_option_mode_invalid_returns_none(
|
async def test_set_config_option_mode_invalid_returns_none(
|
||||||
|
|
@ -205,8 +205,8 @@ class TestACPSetConfigOptionModel:
|
||||||
|
|
||||||
# Verify config_options reflect the new state
|
# Verify config_options reflect the new state
|
||||||
model_config = response.config_options[1]
|
model_config = response.config_options[1]
|
||||||
assert model_config.root.id == "model"
|
assert model_config.id == "model"
|
||||||
assert model_config.root.current_value == "devstral-small"
|
assert model_config.current_value == "devstral-small"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_config_option_model_invalid_returns_none(
|
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 __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.paths import GLOBAL_ENV_FILE
|
||||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
|
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS
|
||||||
|
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
|
||||||
|
|
||||||
|
|
||||||
def _write_env_file(content: str) -> None:
|
def _write_env_file(content: str) -> None:
|
||||||
|
|
@ -53,3 +59,65 @@ class TestGetProxyHelpText:
|
||||||
|
|
||||||
assert "HTTP_PROXY=http://proxy:8080" in result
|
assert "HTTP_PROXY=http://proxy:8080" in result
|
||||||
assert "HTTPS_PROXY=" not 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
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from mistralai.client.models import AssistantMessage
|
||||||
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
|
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
|
@ -33,13 +34,13 @@ from tests.backend.data.mistral import (
|
||||||
STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS,
|
STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS,
|
||||||
TOOL_CONVERSATION_PARAMS as MISTRAL_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.factory import BACKEND_FACTORY
|
||||||
from vibe.core.llm.backend.generic import GenericBackend
|
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.exceptions import BackendError
|
||||||
from vibe.core.llm.types import BackendLike
|
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
|
from vibe.core.utils import get_user_agent
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -435,3 +436,93 @@ class TestMistralRetry:
|
||||||
timeout_ms=720000,
|
timeout_ms=720000,
|
||||||
retry_config=backend._retry_config,
|
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,
|
resolve_api_key_for_plan,
|
||||||
)
|
)
|
||||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
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
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class TestCommandRegistry:
|
||||||
registry = CommandRegistry()
|
registry = CommandRegistry()
|
||||||
assert registry.get_command_name("/help") == "help"
|
assert registry.get_command_name("/help") == "help"
|
||||||
assert registry.get_command_name("/config") == "config"
|
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("/clear") == "clear"
|
||||||
assert registry.get_command_name("/exit") == "exit"
|
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 tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
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.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import (
|
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"])
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||||
events: list[dict[str, Any]] = []
|
events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def record_telemetry(
|
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:
|
) -> 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(
|
monkeypatch.setattr(
|
||||||
"vibe.core.telemetry.send.TelemetryClient.send_telemetry_event",
|
"vibe.core.telemetry.send.TelemetryClient.send_telemetry_event",
|
||||||
|
|
@ -236,11 +250,11 @@ def build_test_vibe_app(
|
||||||
|
|
||||||
return VibeApp(
|
return VibeApp(
|
||||||
agent_loop=resolved_agent_loop,
|
agent_loop=resolved_agent_loop,
|
||||||
|
startup=StartupOptions(initial_prompt=kwargs.pop("initial_prompt", None)),
|
||||||
current_version=resolved_current_version,
|
current_version=resolved_current_version,
|
||||||
update_notifier=resolved_update_notifier,
|
update_notifier=resolved_update_notifier,
|
||||||
update_cache_repository=resolved_update_cache_repository,
|
update_cache_repository=resolved_update_cache_repository,
|
||||||
plan_offer_gateway=resolved_plan_offer_gateway,
|
plan_offer_gateway=resolved_plan_offer_gateway,
|
||||||
initial_prompt=kwargs.pop("initial_prompt", None),
|
|
||||||
voice_manager=voice_manager,
|
voice_manager=voice_manager,
|
||||||
**kwargs,
|
**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 __future__ import annotations
|
||||||
|
|
||||||
from vibe.core.slug import _ADJECTIVES, _NOUNS, create_slug
|
from vibe.core.utils.slug import _ADJECTIVES, _NOUNS, create_slug
|
||||||
|
|
||||||
|
|
||||||
class TestCreateSlug:
|
class TestCreateSlug:
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import pytest
|
||||||
from tests.conftest import build_test_vibe_config
|
from tests.conftest import build_test_vibe_config
|
||||||
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
||||||
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
|
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
|
||||||
from vibe.core.config import Backend
|
|
||||||
from vibe.core.llm.format import ResolvedToolCall
|
from vibe.core.llm.format import ResolvedToolCall
|
||||||
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
|
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
|
||||||
from vibe.core.tools.base import BaseTool, ToolPermission
|
from vibe.core.tools.base import BaseTool, ToolPermission
|
||||||
|
from vibe.core.types import Backend
|
||||||
from vibe.core.utils import get_user_agent
|
from vibe.core.utils import get_user_agent
|
||||||
|
|
||||||
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
|
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
|
||||||
|
|
@ -258,6 +258,8 @@ class TestTelemetryClient:
|
||||||
nb_mcp_servers=1,
|
nb_mcp_servers=1,
|
||||||
nb_models=3,
|
nb_models=3,
|
||||||
entrypoint="cli",
|
entrypoint="cli",
|
||||||
|
client_name="vscode",
|
||||||
|
client_version="1.96.0",
|
||||||
terminal_emulator="vscode",
|
terminal_emulator="vscode",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -270,6 +272,8 @@ class TestTelemetryClient:
|
||||||
assert properties["nb_mcp_servers"] == 1
|
assert properties["nb_mcp_servers"] == 1
|
||||||
assert properties["nb_models"] == 3
|
assert properties["nb_models"] == 3
|
||||||
assert properties["entrypoint"] == "cli"
|
assert properties["entrypoint"] == "cli"
|
||||||
|
assert properties["client_name"] == "vscode"
|
||||||
|
assert properties["client_version"] == "1.96.0"
|
||||||
assert properties["terminal_emulator"] == "vscode"
|
assert properties["terminal_emulator"] == "vscode"
|
||||||
assert "version" in properties
|
assert "version" in properties
|
||||||
|
|
||||||
|
|
@ -372,3 +376,41 @@ class TestTelemetryClient:
|
||||||
assert (
|
assert (
|
||||||
calls[1].kwargs["json"]["properties"]["session_id"] == "second-session-id"
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.utils import get_server_url_from_api_base
|
from vibe.core.utils import get_server_url_from_api_base
|
||||||
|
from vibe.core.utils.io import read_safe
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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):
|
def test_get_server_url_from_api_base(api_base, expected):
|
||||||
assert 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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Sequence
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -13,7 +13,7 @@ import pexpect
|
||||||
|
|
||||||
class SpawnedVibeProcessFixture(Protocol):
|
class SpawnedVibeProcessFixture(Protocol):
|
||||||
def __call__(
|
def __call__(
|
||||||
self, workdir: Path
|
self, workdir: Path, extra_args: Sequence[str] | None = None
|
||||||
) -> AbstractContextManager[tuple[pexpect.spawn, io.StringIO]]: ...
|
) -> AbstractContextManager[tuple[pexpect.spawn, io.StringIO]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
from contextlib import AbstractContextManager, contextmanager
|
from contextlib import AbstractContextManager, contextmanager
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
|
@ -52,17 +52,21 @@ type SpawnedVibeContext = Iterator[tuple[pexpect.spawn, io.StringIO]]
|
||||||
type SpawnedVibeContextManager = AbstractContextManager[
|
type SpawnedVibeContextManager = AbstractContextManager[
|
||||||
tuple[pexpect.spawn, io.StringIO]
|
tuple[pexpect.spawn, io.StringIO]
|
||||||
]
|
]
|
||||||
type SpawnedVibeFactory = Callable[[Path], SpawnedVibeContextManager]
|
type SpawnedVibeFactory = Callable[
|
||||||
|
[Path, Sequence[str] | None], SpawnedVibeContextManager
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def spawned_vibe_process() -> SpawnedVibeFactory:
|
def spawned_vibe_process() -> SpawnedVibeFactory:
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def spawn(workdir: Path) -> SpawnedVibeContext:
|
def spawn(
|
||||||
|
workdir: Path, extra_args: Sequence[str] | None = None
|
||||||
|
) -> SpawnedVibeContext:
|
||||||
captured = io.StringIO()
|
captured = io.StringIO()
|
||||||
child = pexpect.spawn(
|
child = pexpect.spawn(
|
||||||
"uv",
|
"uv",
|
||||||
["run", "vibe", "--workdir", str(workdir)],
|
["run", "vibe", "--workdir", str(workdir), *(extra_args or [])],
|
||||||
cwd=str(TESTS_ROOT.parent),
|
cwd=str(TESTS_ROOT.parent),
|
||||||
env=os.environ,
|
env=os.environ,
|
||||||
encoding="utf-8",
|
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 contextlib import contextmanager
|
||||||
|
|
||||||
from vibe.core.config import Backend
|
|
||||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||||
|
from vibe.core.types import Backend
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
|
||||||
|
|
@ -1008,12 +1008,13 @@ class TestSessionLoaderUTF8Encoding:
|
||||||
session_folder = session_dir / "test_20230101_120000_latin100"
|
session_folder = session_dir / "test_20230101_120000_latin100"
|
||||||
session_folder.mkdir()
|
session_folder.mkdir()
|
||||||
|
|
||||||
|
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
|
||||||
metadata_content = {
|
metadata_content = {
|
||||||
"session_id": "latin1-test",
|
"session_id": "latin1-test",
|
||||||
"start_time": "2023-01-01T12:00:00Z",
|
"start_time": "2023-01-01T12:00:00Z",
|
||||||
"end_time": "2023-01-01T12:05:00Z",
|
"end_time": "2023-01-01T12:05:00Z",
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
"environment": {"working_directory": "/home/user/café_project"},
|
"environment": {"working_directory": "/home/user/caf\x81_project"},
|
||||||
"git_commit": None,
|
"git_commit": None,
|
||||||
"git_branch": None,
|
"git_branch": None,
|
||||||
}
|
}
|
||||||
|
|
@ -1027,7 +1028,7 @@ class TestSessionLoaderUTF8Encoding:
|
||||||
|
|
||||||
metadata = SessionLoader.load_metadata(session_folder)
|
metadata = SessionLoader.load_metadata(session_folder)
|
||||||
assert metadata.session_id == "latin1-test"
|
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(
|
def test_load_session_with_utf8_metadata_and_messages(
|
||||||
self, session_config: SessionLoggingConfig
|
self, session_config: SessionLoggingConfig
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
.terminal-r6 { fill: #d2d2d2 }
|
.terminal-r6 { fill: #d2d2d2 }
|
||||||
.terminal-r7 { fill: #292929 }
|
.terminal-r7 { fill: #292929 }
|
||||||
.terminal-r8 { fill: #4b4e55 }
|
.terminal-r8 { fill: #4b4e55 }
|
||||||
.terminal-r9 { fill: #d0b344;font-weight: bold }
|
.terminal-r9 { fill: #98a84b;font-weight: bold }
|
||||||
.terminal-r10 { fill: #9a9b99 }
|
.terminal-r10 { fill: #9a9b99 }
|
||||||
</style>
|
</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="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="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="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="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-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="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="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="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="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="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="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="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-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-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-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="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-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="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="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="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="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-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="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="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="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="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="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="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="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-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-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-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="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-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 textual.pilot import Pilot
|
||||||
|
|
||||||
|
from tests.conftest import build_test_vibe_config
|
||||||
from tests.mock.utils import mock_llm_chunk
|
from tests.mock.utils import mock_llm_chunk
|
||||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||||
from tests.snapshots.snap_compare import SnapCompare
|
from tests.snapshots.snap_compare import SnapCompare
|
||||||
|
|
@ -17,7 +18,14 @@ class VoiceEnableApp(BaseSnapshotTestApp):
|
||||||
|
|
||||||
class VoiceDisableApp(BaseSnapshotTestApp):
|
class VoiceDisableApp(BaseSnapshotTestApp):
|
||||||
def __init__(self) -> None:
|
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):
|
class RecordingActiveApp(BaseSnapshotTestApp):
|
||||||
|
|
@ -49,6 +57,10 @@ def test_snapshot_voice_enable(snap_compare: SnapCompare) -> None:
|
||||||
await pilot.press(*"/voice")
|
await pilot.press(*"/voice")
|
||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
await pilot.pause(0.4)
|
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(
|
assert snap_compare(
|
||||||
"test_ui_snapshot_voice_mode.py:VoiceEnableApp",
|
"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(*"/voice")
|
||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
await pilot.pause(0.4)
|
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(
|
assert snap_compare(
|
||||||
"test_ui_snapshot_voice_mode.py:VoiceDisableApp",
|
"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,
|
Agent as AcpAgent,
|
||||||
Client,
|
Client,
|
||||||
CreateTerminalResponse,
|
CreateTerminalResponse,
|
||||||
KillTerminalCommandResponse,
|
KillTerminalResponse,
|
||||||
ReadTextFileResponse,
|
ReadTextFileResponse,
|
||||||
ReleaseTerminalResponse,
|
ReleaseTerminalResponse,
|
||||||
RequestPermissionResponse,
|
RequestPermissionResponse,
|
||||||
|
|
@ -112,7 +112,7 @@ class FakeClient(Client):
|
||||||
|
|
||||||
async def kill_terminal(
|
async def kill_terminal(
|
||||||
self, session_id: str, terminal_id: str, **kwargs: Any
|
self, session_id: str, terminal_id: str, **kwargs: Any
|
||||||
) -> KillTerminalCommandResponse | None:
|
) -> KillTerminalResponse | None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
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
|
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.mock.utils import mock_llm_chunk
|
||||||
from tests.stubs.fake_backend import FakeBackend
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.types import EntrypointMetadata
|
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:
|
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")]
|
[_ async for _ in agent.act("Hello")]
|
||||||
|
|
||||||
assert len(backend.requests_metadata) > 0
|
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
|
@pytest.mark.asyncio
|
||||||
|
|
@ -141,13 +152,16 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
|
||||||
[_ async for _ in agent.act("Hello")]
|
[_ async for _ in agent.act("Hello")]
|
||||||
|
|
||||||
assert len(backend.requests_metadata) > 0
|
assert len(backend.requests_metadata) > 0
|
||||||
assert backend.requests_metadata[0] == {
|
meta = backend.requests_metadata[0]
|
||||||
"agent_entrypoint": "acp",
|
assert meta is not None
|
||||||
"agent_version": "2.0.0",
|
assert meta["agent_entrypoint"] == "acp"
|
||||||
"client_name": "vibe_ide",
|
assert meta["agent_version"] == "2.0.0"
|
||||||
"client_version": "0.5.0",
|
assert meta["client_name"] == "vibe_ide"
|
||||||
"session_id": agent.session_id,
|
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
|
@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)
|
result2 = await handler(context, params)
|
||||||
assert isinstance(result2, CreateMessageResult)
|
assert isinstance(result2, CreateMessageResult)
|
||||||
assert result2.model == "devstral-small-latest"
|
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,
|
MiddlewareResult,
|
||||||
ResetReason,
|
ResetReason,
|
||||||
)
|
)
|
||||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
|
||||||
from vibe.core.tools.builtins.todo import TodoArgs
|
from vibe.core.tools.builtins.todo import TodoArgs
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
ApprovalResponse,
|
ApprovalResponse,
|
||||||
|
|
@ -54,9 +53,7 @@ class InjectBeforeMiddleware:
|
||||||
|
|
||||||
|
|
||||||
def make_config(
|
def make_config(
|
||||||
*,
|
*, enabled_tools: list[str] | None = None, tools: dict[str, dict] | None = None
|
||||||
enabled_tools: list[str] | None = None,
|
|
||||||
tools: dict[str, BaseToolConfig] | None = None,
|
|
||||||
) -> VibeConfig:
|
) -> VibeConfig:
|
||||||
return build_test_vibe_config(
|
return build_test_vibe_config(
|
||||||
system_prompt_id="tests",
|
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(
|
agent = build_test_agent_loop(
|
||||||
config=make_config(
|
config=make_config(
|
||||||
enabled_tools=["todo"],
|
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
|
||||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
|
||||||
),
|
),
|
||||||
backend=backend,
|
backend=backend,
|
||||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
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(
|
agent = build_test_agent_loop(
|
||||||
config=make_config(
|
config=make_config(
|
||||||
enabled_tools=["todo"],
|
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
|
||||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
|
||||||
),
|
),
|
||||||
backend=backend,
|
backend=backend,
|
||||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||||
|
|
@ -324,8 +319,7 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
||||||
])
|
])
|
||||||
agent = build_test_agent_loop(
|
agent = build_test_agent_loop(
|
||||||
config=make_config(
|
config=make_config(
|
||||||
enabled_tools=["todo"],
|
enabled_tools=["todo"], tools={"todo": {"permission": "always"}}
|
||||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
|
||||||
),
|
),
|
||||||
backend=backend,
|
backend=backend,
|
||||||
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
agent_name=BuiltinAgentName.AUTO_APPROVE,
|
||||||
|
|
@ -387,8 +381,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||||
])
|
])
|
||||||
agent = build_test_agent_loop(
|
agent = build_test_agent_loop(
|
||||||
config=make_config(
|
config=make_config(
|
||||||
enabled_tools=["todo"],
|
enabled_tools=["todo"], tools={"todo": {"permission": "ask"}}
|
||||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
|
|
||||||
),
|
),
|
||||||
backend=backend,
|
backend=backend,
|
||||||
agent_name=BuiltinAgentName.DEFAULT,
|
agent_name=BuiltinAgentName.DEFAULT,
|
||||||
|
|
@ -398,7 +391,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||||
agent.middleware_pipeline.add(middleware)
|
agent.middleware_pipeline.add(middleware)
|
||||||
|
|
||||||
async def _reject_callback(
|
async def _reject_callback(
|
||||||
_name: str, _args: BaseModel, _id: str
|
_name: str, _args: BaseModel, _id: str, _rp: list | None = None
|
||||||
) -> tuple[ApprovalResponse, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return (
|
return (
|
||||||
ApprovalResponse.NO,
|
ApprovalResponse.NO,
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,16 @@ from tests.mock.utils import mock_llm_chunk
|
||||||
from tests.stubs.fake_backend import FakeBackend
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import (
|
from vibe.core.config import (
|
||||||
Backend,
|
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
SessionLoggingConfig,
|
SessionLoggingConfig,
|
||||||
VibeConfig,
|
VibeConfig,
|
||||||
)
|
)
|
||||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
from vibe.core.tools.base import ToolPermission
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
AgentStats,
|
AgentStats,
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
|
Backend,
|
||||||
CompactEndEvent,
|
CompactEndEvent,
|
||||||
CompactStartEvent,
|
CompactStartEvent,
|
||||||
FunctionCall,
|
FunctionCall,
|
||||||
|
|
@ -95,7 +95,7 @@ def make_config(
|
||||||
models=models,
|
models=models,
|
||||||
providers=providers,
|
providers=providers,
|
||||||
enabled_tools=enabled_tools or [],
|
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.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import VibeConfig
|
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.tools.builtins.todo import TodoItem
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
ApprovalCallback,
|
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:
|
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
|
||||||
return build_test_vibe_config(
|
return build_test_vibe_config(
|
||||||
enabled_tools=["todo"],
|
enabled_tools=["todo"],
|
||||||
tools={"todo": BaseToolConfig(permission=todo_permission)},
|
tools={"todo": {"permission": todo_permission.value}},
|
||||||
system_prompt_id="tests",
|
system_prompt_id="tests",
|
||||||
include_project_context=False,
|
include_project_context=False,
|
||||||
include_prompt_detail=False,
|
include_prompt_detail=False,
|
||||||
|
|
@ -166,7 +166,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved(
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None:
|
async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None:
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return (ApprovalResponse.YES, 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"
|
custom_feedback = "User declined tool execution"
|
||||||
|
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return (ApprovalResponse.NO, custom_feedback)
|
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
|
agent_ref: AgentLoop | None = None
|
||||||
|
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
callback_invocations.append(tool_name)
|
callback_invocations.append(tool_name)
|
||||||
# Set permission to ALWAYS for this tool (simulating the new behavior)
|
# Set permission to ALWAYS for this tool (simulating the new behavior)
|
||||||
assert agent_ref is not None
|
assert agent_ref is not None
|
||||||
if tool_name not in agent_ref.config.tools:
|
if tool_name not in agent_ref.config.tools:
|
||||||
agent_ref.config.tools[tool_name] = BaseToolConfig()
|
agent_ref.config.tools[tool_name] = {}
|
||||||
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
|
agent_ref.config.tools[tool_name]["permission"] = "always"
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
|
|
||||||
agent_loop = make_agent_loop(
|
agent_loop = make_agent_loop(
|
||||||
|
|
@ -596,7 +596,7 @@ async def test_parallel_tool_calls_with_approval_callback(
|
||||||
approval_calls: list[str] = []
|
approval_calls: list[str] = []
|
||||||
|
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
approval_calls.append(tool_call_id)
|
approval_calls.append(tool_call_id)
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
|
|
@ -640,7 +640,7 @@ async def test_parallel_approvals_can_run_concurrently() -> None:
|
||||||
max_concurrency = 0
|
max_concurrency = 0
|
||||||
|
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
nonlocal concurrency, max_concurrency
|
nonlocal concurrency, max_concurrency
|
||||||
concurrency += 1
|
concurrency += 1
|
||||||
|
|
@ -674,7 +674,7 @@ async def test_parallel_mixed_approval_and_rejection(
|
||||||
"""One tool approved, one rejected — both should produce correct events."""
|
"""One tool approved, one rejected — both should produce correct events."""
|
||||||
|
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
if tool_call_id == "call_yes":
|
if tool_call_id == "call_yes":
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
|
|
@ -813,7 +813,7 @@ async def test_parallel_all_permission_never() -> None:
|
||||||
approval_calls: list[str] = []
|
approval_calls: list[str] = []
|
||||||
|
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
approval_calls.append(tool_call_id)
|
approval_calls.append(tool_call_id)
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,32 @@ class TestAgentProfile:
|
||||||
|
|
||||||
|
|
||||||
class TestAgentApplyToConfig:
|
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(
|
def test_custom_prompt_found_in_global_when_missing_from_project(
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -204,8 +230,9 @@ class TestAgentApplyToConfig:
|
||||||
|
|
||||||
|
|
||||||
class TestAgentProfileOverrides:
|
class TestAgentProfileOverrides:
|
||||||
def test_default_agent_has_no_overrides(self) -> None:
|
def test_default_agent_disables_exit_plan_mode(self) -> None:
|
||||||
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
|
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:
|
def test_auto_approve_agent_sets_auto_approve(self) -> None:
|
||||||
overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides
|
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.mock.utils import mock_llm_chunk
|
||||||
from tests.stubs.fake_backend import FakeBackend
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
from vibe.core import run_programmatic
|
from vibe.core import run_programmatic
|
||||||
from vibe.core.config import Backend
|
from vibe.core.types import Backend, LLMMessage, OutputFormat, Role
|
||||||
from vibe.core.types import LLMMessage, OutputFormat, Role
|
|
||||||
|
|
||||||
|
|
||||||
class SpyStreamingFormatter:
|
class SpyStreamingFormatter:
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ class TestMistralMapperPrepareMessage:
|
||||||
|
|
||||||
assert isinstance(result, AssistantMessage)
|
assert isinstance(result, AssistantMessage)
|
||||||
assert isinstance(result.content, list)
|
assert isinstance(result.content, list)
|
||||||
assert len(result.content) == 2
|
assert len(result.content) == 1
|
||||||
|
|
||||||
think_chunk = result.content[0]
|
think_chunk = result.content[0]
|
||||||
assert isinstance(think_chunk, ThinkChunk)
|
assert isinstance(think_chunk, ThinkChunk)
|
||||||
|
|
@ -168,10 +168,6 @@ class TestMistralMapperPrepareMessage:
|
||||||
assert isinstance(inner_chunk, TextChunk)
|
assert isinstance(inner_chunk, TextChunk)
|
||||||
assert inner_chunk.text == "Just thinking..."
|
assert inner_chunk.text == "Just thinking..."
|
||||||
|
|
||||||
text_chunk = result.content[1]
|
|
||||||
assert isinstance(text_chunk, TextChunk)
|
|
||||||
assert text_chunk.text == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenericBackendReasoningContent:
|
class TestGenericBackendReasoningContent:
|
||||||
@pytest.mark.asyncio
|
@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 tests.mock.utils import collect_result
|
||||||
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
|
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
|
||||||
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
|
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
|
||||||
|
from vibe.core.tools.permissions import PermissionContext
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -80,7 +81,10 @@ def test_find_not_in_default_allowlist():
|
||||||
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
|
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||||
# find -exec runs arbitrary commands; must not be allowlisted by default
|
# find -exec runs arbitrary commands; must not be allowlisted by default
|
||||||
permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
|
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():
|
def test_resolve_permission():
|
||||||
|
|
@ -92,9 +96,13 @@ def test_resolve_permission():
|
||||||
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
|
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
|
||||||
empty = bash_tool.resolve_permission(BashArgs(command=""))
|
empty = bash_tool.resolve_permission(BashArgs(command=""))
|
||||||
|
|
||||||
assert allowlisted is ToolPermission.ALWAYS
|
assert isinstance(allowlisted, PermissionContext)
|
||||||
assert denylisted is ToolPermission.NEVER
|
assert allowlisted.permission is ToolPermission.ALWAYS
|
||||||
assert mixed is None
|
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
|
assert empty is None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,80 +116,95 @@ class TestResolvePermissionWindowsSyntax:
|
||||||
def test_dir_with_windows_flags_allowlisted(self):
|
def test_dir_with_windows_flags_allowlisted(self):
|
||||||
bash_tool = self._make_bash(allowlist=["dir"])
|
bash_tool = self._make_bash(allowlist=["dir"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s /b"))
|
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):
|
def test_type_command_allowlisted(self):
|
||||||
bash_tool = self._make_bash(allowlist=["type"])
|
bash_tool = self._make_bash(allowlist=["type"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="type file.txt"))
|
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):
|
def test_findstr_allowlisted(self):
|
||||||
bash_tool = self._make_bash(allowlist=["findstr"])
|
bash_tool = self._make_bash(allowlist=["findstr"])
|
||||||
result = bash_tool.resolve_permission(
|
result = bash_tool.resolve_permission(
|
||||||
BashArgs(command="findstr /s pattern *.txt")
|
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):
|
def test_ver_allowlisted(self):
|
||||||
bash_tool = self._make_bash(allowlist=["ver"])
|
bash_tool = self._make_bash(allowlist=["ver"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="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):
|
def test_where_allowlisted(self):
|
||||||
bash_tool = self._make_bash(allowlist=["where"])
|
bash_tool = self._make_bash(allowlist=["where"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="where python"))
|
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):
|
def test_cmd_k_denylisted(self):
|
||||||
bash_tool = self._make_bash(denylist=["cmd /k"])
|
bash_tool = self._make_bash(denylist=["cmd /k"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="cmd /k something"))
|
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):
|
def test_powershell_noexit_denylisted(self):
|
||||||
bash_tool = self._make_bash(denylist=["powershell -NoExit"])
|
bash_tool = self._make_bash(denylist=["powershell -NoExit"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="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):
|
def test_notepad_denylisted(self):
|
||||||
bash_tool = self._make_bash(denylist=["notepad"])
|
bash_tool = self._make_bash(denylist=["notepad"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="notepad file.txt"))
|
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):
|
def test_cmd_standalone_denylisted(self):
|
||||||
bash_tool = self._make_bash(denylist_standalone=["cmd"])
|
bash_tool = self._make_bash(denylist_standalone=["cmd"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="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):
|
def test_powershell_standalone_denylisted(self):
|
||||||
bash_tool = self._make_bash(denylist_standalone=["powershell"])
|
bash_tool = self._make_bash(denylist_standalone=["powershell"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="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):
|
def test_powershell_cmdlet_asks(self):
|
||||||
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="Get-ChildItem -Path ."))
|
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):
|
def test_mixed_allowed_and_unknown_asks(self):
|
||||||
bash_tool = self._make_bash(allowlist=["git status"])
|
bash_tool = self._make_bash(allowlist=["git status"])
|
||||||
result = bash_tool.resolve_permission(
|
result = bash_tool.resolve_permission(
|
||||||
BashArgs(command="git status && npm install")
|
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):
|
def test_chained_windows_commands_all_allowed(self):
|
||||||
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s && echo done"))
|
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):
|
def test_chained_commands_one_denied(self):
|
||||||
bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"])
|
bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"])
|
||||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s && rm -rf /"))
|
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):
|
def test_piped_windows_commands(self):
|
||||||
bash_tool = self._make_bash(allowlist=["findstr", "type"])
|
bash_tool = self._make_bash(allowlist=["findstr", "type"])
|
||||||
result = bash_tool.resolve_permission(
|
result = bash_tool.resolve_permission(
|
||||||
BashArgs(command="type file.txt | findstr pattern")
|
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:
|
def test_approval_callback_can_be_set(self) -> None:
|
||||||
async def dummy_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return ApprovalResponse.YES, None
|
return ApprovalResponse.YES, None
|
||||||
|
|
||||||
|
|
@ -76,7 +79,10 @@ class TestToolInvokeWithContext:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
|
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
|
||||||
async def dummy_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return ApprovalResponse.YES, None
|
return ApprovalResponse.YES, None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ def test_merges_user_overrides_with_defaults():
|
||||||
vibe_config = build_test_vibe_config(
|
vibe_config = build_test_vibe_config(
|
||||||
system_prompt_id="tests",
|
system_prompt_id="tests",
|
||||||
include_project_context=False,
|
include_project_context=False,
|
||||||
tools={"bash": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
tools={"bash": {"permission": "always"}},
|
||||||
)
|
)
|
||||||
manager = ToolManager(lambda: vibe_config)
|
manager = ToolManager(lambda: vibe_config)
|
||||||
|
|
||||||
|
|
@ -53,9 +53,9 @@ def test_preserves_tool_specific_fields_from_overrides():
|
||||||
vibe_config = build_test_vibe_config(
|
vibe_config = build_test_vibe_config(
|
||||||
system_prompt_id="tests",
|
system_prompt_id="tests",
|
||||||
include_project_context=False,
|
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)
|
manager = ToolManager(lambda: vibe_config)
|
||||||
|
|
||||||
config = manager.get_tool_config("bash")
|
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
|
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:
|
class TestToolManagerFiltering:
|
||||||
def test_enabled_tools_filters_to_only_enabled(self):
|
def test_enabled_tools_filters_to_only_enabled(self):
|
||||||
vibe_config = build_test_vibe_config(
|
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.agents.models import BUILTIN_AGENTS, AgentType
|
||||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
|
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.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
|
||||||
|
from vibe.core.tools.permissions import PermissionContext
|
||||||
from vibe.core.types import AssistantEvent, LLMMessage, Role
|
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:
|
def test_explore_allowed_by_default(self, task_tool: Task) -> None:
|
||||||
args = TaskArgs(task="do something", agent="explore")
|
args = TaskArgs(task="do something", agent="explore")
|
||||||
result = task_tool.resolve_permission(args)
|
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:
|
def test_unknown_agent_returns_none(self, task_tool: Task) -> None:
|
||||||
args = TaskArgs(task="do something", agent="custom_agent")
|
args = TaskArgs(task="do something", agent="custom_agent")
|
||||||
|
|
@ -92,21 +94,24 @@ class TestTaskToolResolvePermission:
|
||||||
tool = Task(config=config, state=BaseToolState())
|
tool = Task(config=config, state=BaseToolState())
|
||||||
args = TaskArgs(task="do something", agent="explore")
|
args = TaskArgs(task="do something", agent="explore")
|
||||||
result = tool.resolve_permission(args)
|
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:
|
def test_glob_pattern_in_allowlist(self) -> None:
|
||||||
config = TaskToolConfig(allowlist=["exp*"])
|
config = TaskToolConfig(allowlist=["exp*"])
|
||||||
tool = Task(config=config, state=BaseToolState())
|
tool = Task(config=config, state=BaseToolState())
|
||||||
args = TaskArgs(task="do something", agent="explore")
|
args = TaskArgs(task="do something", agent="explore")
|
||||||
result = tool.resolve_permission(args)
|
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:
|
def test_glob_pattern_in_denylist(self) -> None:
|
||||||
config = TaskToolConfig(denylist=["danger*"])
|
config = TaskToolConfig(denylist=["danger*"])
|
||||||
tool = Task(config=config, state=BaseToolState())
|
tool = Task(config=config, state=BaseToolState())
|
||||||
args = TaskArgs(task="do something", agent="dangerous_agent")
|
args = TaskArgs(task="do something", agent="dangerous_agent")
|
||||||
result = tool.resolve_permission(args)
|
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:
|
def test_empty_lists_returns_none(self) -> None:
|
||||||
config = TaskToolConfig(allowlist=[], denylist=[])
|
config = TaskToolConfig(allowlist=[], denylist=[])
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,10 @@ from mistralai.client.models import (
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.mock.utils import collect_result
|
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.base import BaseToolState, InvokeContext, ToolError
|
||||||
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
|
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
|
||||||
|
from vibe.core.types import Backend
|
||||||
|
|
||||||
|
|
||||||
def _make_response(
|
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
|
yield
|
||||||
|
|
||||||
|
|
||||||
def _make_sdk_session_created() -> MagicMock:
|
def _make_sdk_session_created(request_id: str = "test-request-id") -> MagicMock:
|
||||||
from mistralai.client.models import RealtimeTranscriptionSessionCreated
|
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:
|
def _make_sdk_text_delta(text: str) -> MagicMock:
|
||||||
|
|
@ -104,6 +111,7 @@ class TestEventMapping:
|
||||||
|
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert isinstance(events[0], TranscribeSessionCreated)
|
assert isinstance(events[0], TranscribeSessionCreated)
|
||||||
|
assert events[0].request_id == "test-request-id"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_text_delta(self) -> None:
|
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 __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -47,12 +47,16 @@ def _make_manager(
|
||||||
*,
|
*,
|
||||||
voice_mode_enabled: bool = True,
|
voice_mode_enabled: bool = True,
|
||||||
transcribe_client: FakeTranscribeClient | None = None,
|
transcribe_client: FakeTranscribeClient | None = None,
|
||||||
|
telemetry_client: MagicMock | None = None,
|
||||||
) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]:
|
) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]:
|
||||||
recorder = FakeAudioRecorder()
|
recorder = FakeAudioRecorder()
|
||||||
client = transcribe_client or FakeTranscribeClient()
|
client = transcribe_client or FakeTranscribeClient()
|
||||||
config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled)
|
config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled)
|
||||||
manager = VoiceManager(
|
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
|
return manager, recorder, client
|
||||||
|
|
||||||
|
|
@ -175,7 +179,7 @@ class TestStopRecording:
|
||||||
async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
|
async def test_stop_recovers_when_no_audio_was_sent(self) -> None:
|
||||||
client = FakeTranscribeClient(
|
client = FakeTranscribeClient(
|
||||||
events=[
|
events=[
|
||||||
TranscribeSessionCreated(),
|
TranscribeSessionCreated(request_id="test-req-id"),
|
||||||
TranscribeError(
|
TranscribeError(
|
||||||
message="Cannot flush audio before sending any audio bytes"
|
message="Cannot flush audio before sending any audio bytes"
|
||||||
),
|
),
|
||||||
|
|
@ -304,7 +308,7 @@ class TestTranscription:
|
||||||
async def test_text_deltas_notify_listeners(self) -> None:
|
async def test_text_deltas_notify_listeners(self) -> None:
|
||||||
client = FakeTranscribeClient(
|
client = FakeTranscribeClient(
|
||||||
events=[
|
events=[
|
||||||
TranscribeSessionCreated(),
|
TranscribeSessionCreated(request_id="test-req-id"),
|
||||||
TranscribeTextDelta(text="hello "),
|
TranscribeTextDelta(text="hello "),
|
||||||
TranscribeTextDelta(text="world"),
|
TranscribeTextDelta(text="world"),
|
||||||
TranscribeDone(),
|
TranscribeDone(),
|
||||||
|
|
@ -323,7 +327,7 @@ class TestTranscription:
|
||||||
async def test_transcription_error_does_not_crash(self) -> None:
|
async def test_transcription_error_does_not_crash(self) -> None:
|
||||||
client = FakeTranscribeClient(
|
client = FakeTranscribeClient(
|
||||||
events=[
|
events=[
|
||||||
TranscribeSessionCreated(),
|
TranscribeSessionCreated(request_id="test-req-id"),
|
||||||
TranscribeTextDelta(text="partial"),
|
TranscribeTextDelta(text="partial"),
|
||||||
TranscribeError(message="something broke"),
|
TranscribeError(message="something broke"),
|
||||||
TranscribeDone(),
|
TranscribeDone(),
|
||||||
|
|
@ -341,7 +345,10 @@ class TestTranscription:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_during_transcription(self) -> None:
|
async def test_cancel_during_transcription(self) -> None:
|
||||||
client = FakeTranscribeClient(
|
client = FakeTranscribeClient(
|
||||||
events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")]
|
events=[
|
||||||
|
TranscribeSessionCreated(request_id="test-req-id"),
|
||||||
|
TranscribeTextDelta(text="hello"),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
manager, _, _ = _make_manager(transcribe_client=client)
|
manager, _, _ = _make_manager(transcribe_client=client)
|
||||||
listener = StateListener()
|
listener = StateListener()
|
||||||
|
|
@ -355,7 +362,10 @@ class TestTranscription:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_created_is_silent(self) -> None:
|
async def test_session_created_is_silent(self) -> None:
|
||||||
client = FakeTranscribeClient(
|
client = FakeTranscribeClient(
|
||||||
events=[TranscribeSessionCreated(), TranscribeDone()]
|
events=[
|
||||||
|
TranscribeSessionCreated(request_id="test-req-id"),
|
||||||
|
TranscribeDone(),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
manager, _, _ = _make_manager(transcribe_client=client)
|
manager, _, _ = _make_manager(transcribe_client=client)
|
||||||
listener = StateListener()
|
listener = StateListener()
|
||||||
|
|
@ -392,3 +402,178 @@ class TestTranscription:
|
||||||
|
|
||||||
assert manager.transcribe_state == TranscribeState.IDLE
|
assert manager.transcribe_state == TranscribeState.IDLE
|
||||||
assert not recorder.is_recording
|
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]]
|
[[package]]
|
||||||
name = "agent-client-protocol"
|
name = "agent-client-protocol"
|
||||||
version = "0.8.1"
|
version = "0.9.0a1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ 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 = [
|
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]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|
@ -758,7 +770,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
@ -773,6 +785,10 @@ dependencies = [
|
||||||
{ name = "markdownify" },
|
{ name = "markdownify" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "mistralai" },
|
{ name = "mistralai" },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||||
|
{ name = "opentelemetry-sdk" },
|
||||||
|
{ name = "opentelemetry-semantic-conventions" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
{ name = "pexpect" },
|
{ name = "pexpect" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
|
@ -796,6 +812,7 @@ dependencies = [
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
build = [
|
build = [
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
|
{ name = "truststore" },
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "debugpy" },
|
{ name = "debugpy" },
|
||||||
|
|
@ -815,7 +832,7 @@ dev = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
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 = "anyio", specifier = ">=4.12.0" },
|
||||||
{ name = "cachetools", specifier = ">=5.5.0" },
|
{ name = "cachetools", specifier = ">=5.5.0" },
|
||||||
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
|
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
|
||||||
|
|
@ -827,6 +844,10 @@ requires-dist = [
|
||||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||||
{ name = "mcp", specifier = ">=1.14.0" },
|
{ name = "mcp", specifier = ">=1.14.0" },
|
||||||
{ name = "mistralai", specifier = "==2.0.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 = "packaging", specifier = ">=24.1" },
|
||||||
{ name = "pexpect", specifier = ">=4.9.0" },
|
{ name = "pexpect", specifier = ">=4.9.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.4" },
|
{ name = "pydantic", specifier = ">=2.12.4" },
|
||||||
|
|
@ -848,7 +869,10 @@ requires-dist = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[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 = [
|
dev = [
|
||||||
{ name = "debugpy", specifier = ">=1.8.19" },
|
{ name = "debugpy", specifier = ">=1.8.19" },
|
||||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "opentelemetry-semantic-conventions"
|
name = "opentelemetry-semantic-conventions"
|
||||||
version = "0.60b1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ptyprocess"
|
name = "ptyprocess"
|
||||||
version = "0.7.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "twine"
|
name = "twine"
|
||||||
version = "6.2.0"
|
version = "6.2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- 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
|
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')
|
acp_builtins_deps = collect_all('vibe.acp.tools.builtins')
|
||||||
|
|
||||||
# Extract hidden imports and binaries, filtering to ensure only strings are in hiddenimports
|
# 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]:
|
for item in core_builtins_deps[2] + acp_builtins_deps[2]:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
hidden_imports.append(item)
|
hidden_imports.append(item)
|
||||||
|
|
@ -31,7 +34,7 @@ a = Analysis(
|
||||||
hiddenimports=hidden_imports,
|
hiddenimports=hidden_imports,
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=["pyinstaller/runtime_hook_truststore.py"],
|
||||||
excludes=[],
|
excludes=[],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
|
|
@ -41,8 +44,6 @@ pyz = PYZ(a.pure)
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
[],
|
[],
|
||||||
name='vibe-acp',
|
name='vibe-acp',
|
||||||
debug=False,
|
debug=False,
|
||||||
|
|
@ -50,7 +51,6 @@ exe = EXE(
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=True,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
console=True,
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
|
|
@ -58,3 +58,13 @@ exe = EXE(
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=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
|
from pathlib import Path
|
||||||
|
|
||||||
VIBE_ROOT = Path(__file__).parent
|
VIBE_ROOT = Path(__file__).parent
|
||||||
__version__ = "2.5.0"
|
__version__ = "2.6.0"
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,14 @@ from acp.schema import (
|
||||||
AgentThoughtChunk,
|
AgentThoughtChunk,
|
||||||
AllowedOutcome,
|
AllowedOutcome,
|
||||||
AuthenticateResponse,
|
AuthenticateResponse,
|
||||||
AuthMethod,
|
AuthMethodAgent,
|
||||||
AvailableCommand,
|
AvailableCommand,
|
||||||
AvailableCommandInput,
|
AvailableCommandInput,
|
||||||
ClientCapabilities,
|
ClientCapabilities,
|
||||||
|
CloseSessionResponse,
|
||||||
ContentToolCallContent,
|
ContentToolCallContent,
|
||||||
|
Cost,
|
||||||
|
EnvVarAuthMethod,
|
||||||
ForkSessionResponse,
|
ForkSessionResponse,
|
||||||
HttpMcpServer,
|
HttpMcpServer,
|
||||||
Implementation,
|
Implementation,
|
||||||
|
|
@ -43,11 +46,14 @@ from acp.schema import (
|
||||||
SessionListCapabilities,
|
SessionListCapabilities,
|
||||||
SetSessionConfigOptionResponse,
|
SetSessionConfigOptionResponse,
|
||||||
SseMcpServer,
|
SseMcpServer,
|
||||||
|
TerminalAuthMethod,
|
||||||
TextContentBlock,
|
TextContentBlock,
|
||||||
TextResourceContents,
|
TextResourceContents,
|
||||||
ToolCallProgress,
|
ToolCallProgress,
|
||||||
ToolCallUpdate,
|
ToolCallUpdate,
|
||||||
UnstructuredCommandInput,
|
UnstructuredCommandInput,
|
||||||
|
Usage,
|
||||||
|
UsageUpdate,
|
||||||
UserMessageChunk,
|
UserMessageChunk,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
@ -71,8 +77,8 @@ from vibe.acp.tools.session_update import (
|
||||||
tool_result_session_update,
|
tool_result_session_update,
|
||||||
)
|
)
|
||||||
from vibe.acp.utils import (
|
from vibe.acp.utils import (
|
||||||
TOOL_OPTIONS,
|
|
||||||
ToolOption,
|
ToolOption,
|
||||||
|
build_permission_options,
|
||||||
create_assistant_message_replay,
|
create_assistant_message_replay,
|
||||||
create_compact_end_session_update,
|
create_compact_end_session_update,
|
||||||
create_compact_start_session_update,
|
create_compact_start_session_update,
|
||||||
|
|
@ -101,8 +107,9 @@ from vibe.core.proxy_setup import (
|
||||||
unset_proxy_var,
|
unset_proxy_var,
|
||||||
)
|
)
|
||||||
from vibe.core.session.session_loader import SessionLoader
|
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 (
|
from vibe.core.types import (
|
||||||
|
AgentProfileChangedEvent,
|
||||||
ApprovalCallback,
|
ApprovalCallback,
|
||||||
ApprovalResponse,
|
ApprovalResponse,
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
|
|
@ -173,9 +180,10 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
and self.client_capabilities.field_meta.get("terminal-auth") is True
|
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",
|
id="vibe-setup",
|
||||||
name="Register your API Key",
|
name="Register your API Key",
|
||||||
description="Register your API Key inside Mistral Vibe",
|
description="Register your API Key inside Mistral Vibe",
|
||||||
|
|
@ -228,9 +236,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
|
|
||||||
def _load_config(self) -> VibeConfig:
|
def _load_config(self) -> VibeConfig:
|
||||||
try:
|
try:
|
||||||
config = VibeConfig.load(
|
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||||
disabled_tools=["ask_user_question", "exit_plan_mode"]
|
|
||||||
)
|
|
||||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||||
return config
|
return config
|
||||||
except MissingAPIKeyError as e:
|
except MissingAPIKeyError as e:
|
||||||
|
|
@ -263,18 +269,22 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
|
|
||||||
config = self._load_config()
|
config = self._load_config()
|
||||||
|
|
||||||
agent_loop = AgentLoop(
|
try:
|
||||||
config=config,
|
agent_loop = AgentLoop(
|
||||||
agent_name=BuiltinAgentName.DEFAULT,
|
config=config,
|
||||||
enable_streaming=True,
|
agent_name=BuiltinAgentName.DEFAULT,
|
||||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
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.
|
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||||
# We should just use agent_loop.session_id everywhere, but it can still change during
|
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
|
||||||
# session lifetime (e.g. agent_loop.compact is called).
|
# We should just use agent_loop.session_id everywhere, but it can still change during
|
||||||
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
# session lifetime (e.g. agent_loop.compact is called).
|
||||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
# 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()
|
agent_loop.emit_new_session_telemetry()
|
||||||
|
|
||||||
modes_state, modes_config = make_mode_response(
|
modes_state, modes_config = make_mode_response(
|
||||||
|
|
@ -314,17 +324,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
session = self._get_session(session_id)
|
session = self._get_session(session_id)
|
||||||
|
|
||||||
def _handle_permission_selection(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
match option_id:
|
match option_id:
|
||||||
case ToolOption.ALLOW_ONCE:
|
case ToolOption.ALLOW_ONCE:
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
case ToolOption.ALLOW_ALWAYS:
|
case ToolOption.ALLOW_ALWAYS:
|
||||||
if tool_name not in session.agent_loop.config.tools:
|
session.agent_loop.approve_always(tool_name, required_permissions)
|
||||||
session.agent_loop.config.tools[tool_name] = BaseToolConfig()
|
|
||||||
session.agent_loop.config.tools[
|
|
||||||
tool_name
|
|
||||||
].permission = ToolPermission.ALWAYS
|
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
case ToolOption.REJECT_ONCE:
|
case ToolOption.REJECT_ONCE:
|
||||||
return (
|
return (
|
||||||
|
|
@ -335,19 +343,33 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
||||||
|
|
||||||
async def approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
# Create the tool call update
|
typed_permissions: list[RequiredPermission] | None = (
|
||||||
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
|
[
|
||||||
|
rp
|
||||||
response = await self.client.request_permission(
|
for rp in required_permissions
|
||||||
session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS
|
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":
|
if response.outcome.outcome == "selected":
|
||||||
outcome = cast(AllowedOutcome, response.outcome)
|
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:
|
else:
|
||||||
return (
|
return (
|
||||||
ApprovalResponse.NO,
|
ApprovalResponse.NO,
|
||||||
|
|
@ -365,6 +387,39 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
raise SessionNotFoundError(session_id)
|
raise SessionNotFoundError(session_id)
|
||||||
return self.sessions[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:
|
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
|
||||||
if not msg.tool_calls:
|
if not msg.tool_calls:
|
||||||
return
|
return
|
||||||
|
|
@ -463,7 +518,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
|
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
|
||||||
new_config = VibeConfig.load(
|
new_config = VibeConfig.load(
|
||||||
tool_paths=session.agent_loop.config.tool_paths,
|
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(
|
await session.agent_loop.reload_with_initial_messages(
|
||||||
base_config=new_config
|
base_config=new_config
|
||||||
|
|
@ -492,7 +547,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
})
|
})
|
||||||
new_config = VibeConfig.load(
|
new_config = VibeConfig.load(
|
||||||
tool_paths=session.agent_loop.config.tool_paths,
|
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(
|
await session.agent_loop.reload_with_initial_messages(
|
||||||
base_config=new_config
|
base_config=new_config
|
||||||
|
|
@ -549,6 +604,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
session = await self._create_acp_session(session_id, agent_loop)
|
session = await self._create_acp_session(session_id, agent_loop)
|
||||||
|
|
||||||
await self._replay_conversation_history(session_id, non_system_messages)
|
await self._replay_conversation_history(session_id, non_system_messages)
|
||||||
|
self._send_usage_update(session)
|
||||||
|
|
||||||
modes_state, modes_config = make_mode_response(
|
modes_state, modes_config = make_mode_response(
|
||||||
list(agent_loop.agent_manager.available_agents.values()),
|
list(agent_loop.agent_manager.available_agents.values()),
|
||||||
|
|
@ -589,7 +645,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
|
|
||||||
new_config = VibeConfig.load(
|
new_config = VibeConfig.load(
|
||||||
tool_paths=session.agent_loop.config.tool_paths,
|
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)
|
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||||
|
|
@ -675,7 +731,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def prompt(
|
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:
|
) -> PromptResponse:
|
||||||
session = self._get_session(session_id)
|
session = self._get_session(session_id)
|
||||||
|
|
||||||
|
|
@ -708,7 +768,10 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
await session.task
|
await session.task
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
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:
|
except CoreRateLimitError as e:
|
||||||
raise RateLimitError.from_core(e) from e
|
raise RateLimitError.from_core(e) from e
|
||||||
|
|
@ -722,7 +785,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
finally:
|
finally:
|
||||||
session.task = None
|
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:
|
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
|
||||||
text_prompt = ""
|
text_prompt = ""
|
||||||
|
|
@ -841,6 +905,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
elif isinstance(event, CompactEndEvent):
|
elif isinstance(event, CompactEndEvent):
|
||||||
yield create_compact_end_session_update(event)
|
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
|
@override
|
||||||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||||
session = self._get_session(session_id)
|
session = self._get_session(session_id)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import sys
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
from vibe import __version__
|
from vibe import __version__
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import MissingAPIKeyError, VibeConfig
|
||||||
from vibe.core.config.harness_files import (
|
from vibe.core.config.harness_files import (
|
||||||
get_harness_files_manager,
|
get_harness_files_manager,
|
||||||
init_harness_files_manager,
|
init_harness_files_manager,
|
||||||
|
|
@ -78,13 +78,23 @@ def main() -> None:
|
||||||
init_harness_files_manager("user", "project")
|
init_harness_files_manager("user", "project")
|
||||||
|
|
||||||
from vibe.acp.acp_agent_loop import run_acp_server
|
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
|
from vibe.setup.onboarding import run_onboarding
|
||||||
|
|
||||||
|
load_dotenv_values()
|
||||||
bootstrap_config_files()
|
bootstrap_config_files()
|
||||||
args = parse_arguments()
|
args = parse_arguments()
|
||||||
if args.setup:
|
if args.setup:
|
||||||
run_onboarding()
|
run_onboarding()
|
||||||
sys.exit(0)
|
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()
|
run_acp_server()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from acp.schema import (
|
||||||
ContentToolCallContent,
|
ContentToolCallContent,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
PermissionOption,
|
PermissionOption,
|
||||||
SessionConfigOption,
|
|
||||||
SessionConfigOptionSelect,
|
SessionConfigOptionSelect,
|
||||||
SessionConfigSelectOption,
|
SessionConfigSelectOption,
|
||||||
SessionMode,
|
SessionMode,
|
||||||
|
|
@ -23,6 +22,7 @@ from acp.schema import (
|
||||||
|
|
||||||
from vibe.core.agents.models import AgentProfile, AgentType
|
from vibe.core.agents.models import AgentProfile, AgentType
|
||||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
|
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.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
||||||
from vibe.core.utils import compact_reduction_display
|
from vibe.core.utils import compact_reduction_display
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ TOOL_OPTIONS = [
|
||||||
),
|
),
|
||||||
PermissionOption(
|
PermissionOption(
|
||||||
option_id=ToolOption.ALLOW_ALWAYS,
|
option_id=ToolOption.ALLOW_ALWAYS,
|
||||||
name="Allow always",
|
name="Allow for this session",
|
||||||
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
|
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
|
||||||
),
|
),
|
||||||
PermissionOption(
|
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:
|
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
||||||
return any(
|
return any(
|
||||||
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
|
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(
|
def make_mode_response(
|
||||||
profiles: list[AgentProfile], current_mode_id: str
|
profiles: list[AgentProfile], current_mode_id: str
|
||||||
) -> tuple[SessionModeState, SessionConfigOption]:
|
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
|
||||||
session_modes: list[SessionMode] = []
|
session_modes: list[SessionMode] = []
|
||||||
config_options: list[SessionConfigSelectOption] = []
|
config_options: list[SessionConfigSelectOption] = []
|
||||||
|
|
||||||
|
|
@ -89,22 +127,20 @@ def make_mode_response(
|
||||||
state = SessionModeState(
|
state = SessionModeState(
|
||||||
current_mode_id=current_mode_id, available_modes=session_modes
|
current_mode_id=current_mode_id, available_modes=session_modes
|
||||||
)
|
)
|
||||||
config = SessionConfigOption(
|
config = SessionConfigOptionSelect(
|
||||||
root=SessionConfigOptionSelect(
|
id="mode",
|
||||||
id="mode",
|
name="Session Mode",
|
||||||
name="Session Mode",
|
current_value=current_mode_id,
|
||||||
current_value=current_mode_id,
|
category="mode",
|
||||||
category="mode",
|
type="select",
|
||||||
type="select",
|
options=config_options,
|
||||||
options=config_options,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return state, config
|
return state, config
|
||||||
|
|
||||||
|
|
||||||
def make_model_response(
|
def make_model_response(
|
||||||
models: list[ModelConfig], current_model_id: str
|
models: list[ModelConfig], current_model_id: str
|
||||||
) -> tuple[SessionModelState, SessionConfigOption]:
|
) -> tuple[SessionModelState, SessionConfigOptionSelect]:
|
||||||
model_infos: list[ModelInfo] = []
|
model_infos: list[ModelInfo] = []
|
||||||
config_options: list[SessionConfigSelectOption] = []
|
config_options: list[SessionConfigSelectOption] = []
|
||||||
|
|
||||||
|
|
@ -119,15 +155,13 @@ def make_model_response(
|
||||||
state = SessionModelState(
|
state = SessionModelState(
|
||||||
current_model_id=current_model_id, available_models=model_infos
|
current_model_id=current_model_id, available_models=model_infos
|
||||||
)
|
)
|
||||||
config_option = SessionConfigOption(
|
config_option = SessionConfigOptionSelect(
|
||||||
root=SessionConfigOptionSelect(
|
id="model",
|
||||||
id="model",
|
name="Model",
|
||||||
name="Model",
|
current_value=current_model_id,
|
||||||
current_value=current_model_id,
|
category="model",
|
||||||
category="model",
|
type="select",
|
||||||
type="select",
|
options=config_options,
|
||||||
options=config_options,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return state, config_option
|
return state, config_option
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from rich import print as rprint
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
from vibe import __version__
|
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.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import (
|
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.paths import HISTORY_FILE
|
||||||
from vibe.core.programmatic import run_programmatic
|
from vibe.core.programmatic import run_programmatic
|
||||||
from vibe.core.session.session_loader import SessionLoader
|
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.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
|
||||||
from vibe.core.utils import ConversationLimitException
|
from vibe.core.utils import ConversationLimitException
|
||||||
from vibe.setup.onboarding import run_onboarding
|
from vibe.setup.onboarding import run_onboarding
|
||||||
|
|
@ -104,6 +105,8 @@ def load_session(
|
||||||
f"{config.session_logging.save_dir}[/]"
|
f"{config.session_logging.save_dir}[/]"
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
elif args.resume is True:
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
session_to_load = SessionLoader.find_session_by_id(
|
session_to_load = SessionLoader.find_session_by_id(
|
||||||
args.resume, config.session_logging
|
args.resume, config.session_logging
|
||||||
|
|
@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||||
try:
|
try:
|
||||||
initial_agent_name = get_initial_agent_name(args)
|
initial_agent_name = get_initial_agent_name(args)
|
||||||
config = load_config_or_exit()
|
config = load_config_or_exit()
|
||||||
|
setup_tracing(config)
|
||||||
|
|
||||||
if args.enabled_tools:
|
if args.enabled_tools:
|
||||||
config.enabled_tools = args.enabled_tools
|
config.enabled_tools = args.enabled_tools
|
||||||
|
|
@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||||
|
|
||||||
run_textual_ui(
|
run_textual_ui(
|
||||||
agent_loop=agent_loop,
|
agent_loop=agent_loop,
|
||||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
startup=StartupOptions(
|
||||||
teleport_on_start=args.teleport,
|
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||||
|
teleport_on_start=args.teleport,
|
||||||
|
show_resume_picker=args.resume is True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,15 @@ class CommandRegistry:
|
||||||
handler="_show_help",
|
handler="_show_help",
|
||||||
),
|
),
|
||||||
"config": Command(
|
"config": Command(
|
||||||
aliases=frozenset(["/config", "/model"]),
|
aliases=frozenset(["/config"]),
|
||||||
description="Edit config settings",
|
description="Edit config settings",
|
||||||
handler="_show_config",
|
handler="_show_config",
|
||||||
),
|
),
|
||||||
|
"model": Command(
|
||||||
|
aliases=frozenset(["/model"]),
|
||||||
|
description="Select active model",
|
||||||
|
handler="_show_model",
|
||||||
|
),
|
||||||
"reload": Command(
|
"reload": Command(
|
||||||
aliases=frozenset(["/reload"]),
|
aliases=frozenset(["/reload"]),
|
||||||
description="Reload configuration from disk",
|
description="Reload configuration from disk",
|
||||||
|
|
@ -79,8 +84,8 @@ class CommandRegistry:
|
||||||
),
|
),
|
||||||
"voice": Command(
|
"voice": Command(
|
||||||
aliases=frozenset(["/voice"]),
|
aliases=frozenset(["/voice"]),
|
||||||
description="Toggle voice mode on/off",
|
description="Configure voice settings",
|
||||||
handler="_toggle_voice_mode",
|
handler="_show_voice_settings",
|
||||||
),
|
),
|
||||||
"leanstall": Command(
|
"leanstall": Command(
|
||||||
aliases=frozenset(["/leanstall"]),
|
aliases=frozenset(["/leanstall"]),
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace:
|
||||||
)
|
)
|
||||||
continuation_group.add_argument(
|
continuation_group.add_argument(
|
||||||
"--resume",
|
"--resume",
|
||||||
|
nargs="?",
|
||||||
|
const=True,
|
||||||
|
default=None,
|
||||||
metavar="SESSION_ID",
|
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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
||||||
WhoAmIPlanType,
|
WhoAmIPlanType,
|
||||||
WhoAmIResponse,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from vibe.core.utils.io import read_safe
|
||||||
|
|
||||||
|
|
||||||
class Terminal(Enum):
|
class Terminal(Enum):
|
||||||
VSCODE = "vscode"
|
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]]:
|
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
|
||||||
if keybindings_path.exists():
|
if keybindings_path.exists():
|
||||||
content = keybindings_path.read_text()
|
content = read_safe(keybindings_path)
|
||||||
return _parse_keybindings(content)
|
return _parse_keybindings(content)
|
||||||
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
|
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
import gc
|
import gc
|
||||||
import os
|
import os
|
||||||
|
|
@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import (
|
||||||
NotificationPort,
|
NotificationPort,
|
||||||
TextualNotificationAdapter,
|
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.approval_app import ApprovalApp
|
||||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
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 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.compact import CompactMessage
|
||||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
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.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.load_more import HistoryLoadMoreRequested
|
||||||
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
|
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
|
||||||
from vibe.cli.textual_ui.widgets.messages import (
|
from vibe.cli.textual_ui.widgets.messages import (
|
||||||
|
|
@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import (
|
||||||
WarningMessage,
|
WarningMessage,
|
||||||
WhatsNewMessage,
|
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.no_markup_static import NoMarkupStatic
|
||||||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
from vibe.cli.textual_ui.widgets.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.session_picker import SessionPickerApp
|
||||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
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.tools import ToolResultMessage
|
||||||
|
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
|
||||||
from vibe.cli.textual_ui.windowing import (
|
from vibe.cli.textual_ui.windowing import (
|
||||||
HISTORY_RESUME_TAIL_MESSAGES,
|
HISTORY_RESUME_TAIL_MESSAGES,
|
||||||
LOAD_MORE_BATCH_SIZE,
|
LOAD_MORE_BATCH_SIZE,
|
||||||
|
|
@ -76,6 +84,13 @@ from vibe.cli.textual_ui.windowing import (
|
||||||
should_resume_history,
|
should_resume_history,
|
||||||
sync_backfill_state,
|
sync_backfill_state,
|
||||||
)
|
)
|
||||||
|
from vibe.cli.turn_summary import (
|
||||||
|
NoopTurnSummary,
|
||||||
|
TurnSummaryPort,
|
||||||
|
TurnSummaryResult,
|
||||||
|
TurnSummaryTracker,
|
||||||
|
create_narrator_backend,
|
||||||
|
)
|
||||||
from vibe.cli.update_notifier import (
|
from vibe.cli.update_notifier import (
|
||||||
FileSystemUpdateCacheRepository,
|
FileSystemUpdateCacheRepository,
|
||||||
PyPIUpdateGateway,
|
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.cli.voice_manager.voice_manager_port import TranscribeState
|
||||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||||
from vibe.core.agents import AgentProfile
|
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.audio_recorder import AudioRecorder
|
||||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
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.logger import logger
|
||||||
from vibe.core.paths import HISTORY_FILE
|
from vibe.core.paths import HISTORY_FILE
|
||||||
from vibe.core.session.session_loader import SessionLoader
|
from vibe.core.session.session_loader import SessionLoader
|
||||||
|
|
@ -109,17 +126,20 @@ from vibe.core.teleport.types import (
|
||||||
TeleportSendingGithubTokenEvent,
|
TeleportSendingGithubTokenEvent,
|
||||||
TeleportStartingWorkflowEvent,
|
TeleportStartingWorkflowEvent,
|
||||||
)
|
)
|
||||||
from vibe.core.tools.base import ToolPermission
|
|
||||||
from vibe.core.tools.builtins.ask_user_question import (
|
from vibe.core.tools.builtins.ask_user_question import (
|
||||||
AskUserQuestionArgs,
|
AskUserQuestionArgs,
|
||||||
AskUserQuestionResult,
|
AskUserQuestionResult,
|
||||||
Choice,
|
Choice,
|
||||||
Question,
|
Question,
|
||||||
)
|
)
|
||||||
|
from vibe.core.tools.permissions import RequiredPermission
|
||||||
from vibe.core.transcribe import make_transcribe_client
|
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 (
|
from vibe.core.types import (
|
||||||
AgentStats,
|
AgentStats,
|
||||||
ApprovalResponse,
|
ApprovalResponse,
|
||||||
|
Backend,
|
||||||
LLMMessage,
|
LLMMessage,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
Role,
|
Role,
|
||||||
|
|
@ -129,6 +149,7 @@ from vibe.core.utils import (
|
||||||
get_user_cancellation_message,
|
get_user_cancellation_message,
|
||||||
is_dangerous_directory,
|
is_dangerous_directory,
|
||||||
)
|
)
|
||||||
|
from vibe.core.utils.io import read_safe
|
||||||
|
|
||||||
|
|
||||||
class BottomApp(StrEnum):
|
class BottomApp(StrEnum):
|
||||||
|
|
@ -142,9 +163,11 @@ class BottomApp(StrEnum):
|
||||||
Approval = auto()
|
Approval = auto()
|
||||||
Config = auto()
|
Config = auto()
|
||||||
Input = auto()
|
Input = auto()
|
||||||
|
ModelPicker = auto()
|
||||||
ProxySetup = auto()
|
ProxySetup = auto()
|
||||||
Question = auto()
|
Question = auto()
|
||||||
SessionPicker = auto()
|
SessionPicker = auto()
|
||||||
|
Voice = auto()
|
||||||
|
|
||||||
|
|
||||||
class ChatScroll(VerticalScroll):
|
class ChatScroll(VerticalScroll):
|
||||||
|
|
@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll):
|
||||||
def is_at_bottom(self) -> bool:
|
def is_at_bottom(self) -> bool:
|
||||||
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
||||||
|
|
||||||
def _check_anchor(self) -> None:
|
_reanchor_pending: bool = False
|
||||||
if self._anchored and self._anchor_released and self.is_at_bottom:
|
_scrolling_down: bool = False
|
||||||
self._anchor_released = 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:
|
def update_node_styles(self, animate: bool = True) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
@ -202,6 +247,13 @@ async def prune_oldest_children(
|
||||||
return True
|
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
|
class VibeApp(App): # noqa: PLR0904
|
||||||
ENABLE_COMMAND_PALETTE = False
|
ENABLE_COMMAND_PALETTE = False
|
||||||
CSS_PATH = "app.tcss"
|
CSS_PATH = "app.tcss"
|
||||||
|
|
@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
agent_loop: AgentLoop,
|
agent_loop: AgentLoop,
|
||||||
initial_prompt: str | None = None,
|
startup: StartupOptions | None = None,
|
||||||
teleport_on_start: bool = False,
|
|
||||||
update_notifier: UpdateGateway | None = None,
|
update_notifier: UpdateGateway | None = None,
|
||||||
update_cache_repository: UpdateCacheRepository | None = None,
|
update_cache_repository: UpdateCacheRepository | None = None,
|
||||||
current_version: str = CORE_VERSION,
|
current_version: str = CORE_VERSION,
|
||||||
|
|
@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._update_cache_repository = update_cache_repository
|
self._update_cache_repository = update_cache_repository
|
||||||
self._current_version = current_version
|
self._current_version = current_version
|
||||||
self._plan_offer_gateway = plan_offer_gateway
|
self._plan_offer_gateway = plan_offer_gateway
|
||||||
self._initial_prompt = initial_prompt
|
opts = startup or StartupOptions()
|
||||||
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
|
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._last_escape_time: float | None = None
|
||||||
self._banner: Banner | None = None
|
self._banner: Banner | None = None
|
||||||
self._whats_new_message: WhatsNewMessage | 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._cached_loading_area: Widget | None = None
|
||||||
self._switch_agent_generation = 0
|
self._switch_agent_generation = 0
|
||||||
self._plan_info: PlanInfo | None = None
|
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
|
@property
|
||||||
def config(self) -> VibeConfig:
|
def config(self) -> VibeConfig:
|
||||||
|
|
@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
yield VerticalGroup(id="messages")
|
yield VerticalGroup(id="messages")
|
||||||
|
|
||||||
with Horizontal(id="loading-area"):
|
with Horizontal(id="loading-area"):
|
||||||
|
yield NarratorStatus()
|
||||||
yield Static(id="loading-area-content")
|
yield Static(id="loading-area-content")
|
||||||
|
yield FeedbackBar()
|
||||||
|
|
||||||
with Static(id="bottom-app-container"):
|
with Static(id="bottom-app-container"):
|
||||||
yield ChatInputContainer(
|
yield ChatInputContainer(
|
||||||
|
|
@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._cached_messages_area = self.query_one("#messages")
|
self._cached_messages_area = self.query_one("#messages")
|
||||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||||
self._cached_loading_area = self.query_one("#loading-area-content")
|
self._cached_loading_area = self.query_one("#loading-area-content")
|
||||||
|
self._feedback_bar = self.query_one(FeedbackBar)
|
||||||
|
|
||||||
self.event_handler = EventHandler(
|
self.event_handler = EventHandler(
|
||||||
mount_callback=self._mount_and_scroll,
|
mount_callback=self._mount_and_scroll,
|
||||||
get_tools_collapsed=lambda: self._tools_collapsed,
|
get_tools_collapsed=lambda: self._tools_collapsed,
|
||||||
|
on_profile_changed=self._on_profile_changed,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._chat_input_container = self.query_one(ChatInputContainer)
|
self._chat_input_container = self.query_one(ChatInputContainer)
|
||||||
|
|
@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
|
|
||||||
self.call_after_refresh(self._refresh_banner)
|
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)
|
self.call_after_refresh(self._process_initial_prompt)
|
||||||
|
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
async def on_approval_app_approval_granted_always_tool(
|
async def on_approval_app_approval_granted_always_tool(
|
||||||
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
|
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
|
||||||
) -> None:
|
) -> None:
|
||||||
self._set_tool_permission_always(
|
self.agent_loop.approve_always(message.tool_name, message.required_permissions)
|
||||||
message.tool_name, save_permanently=message.save_permanently
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._pending_approval and not self._pending_approval.done():
|
if self._pending_approval and not self._pending_approval.done():
|
||||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||||
|
|
@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
result = AskUserQuestionResult(answers=[], cancelled=True)
|
result = AskUserQuestionResult(answers=[], cancelled=True)
|
||||||
self._pending_question.set_result(result)
|
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:
|
async def _remove_loading_widget(self) -> None:
|
||||||
if self._loading_widget and self._loading_widget.parent:
|
if self._loading_widget and self._loading_widget.parent:
|
||||||
await self._loading_widget.remove()
|
await self._loading_widget.remove()
|
||||||
self._loading_widget = None
|
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(
|
async def on_config_app_config_closed(
|
||||||
self, message: ConfigApp.ConfigClosed
|
self, message: ConfigApp.ConfigClosed
|
||||||
) -> None:
|
) -> None:
|
||||||
if message.changes:
|
await self._handle_config_settings_closed(message.changes)
|
||||||
VibeConfig.save_updates(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()
|
await self._reload_config()
|
||||||
else:
|
else:
|
||||||
await self._mount_and_scroll(
|
await self._mount_and_scroll(
|
||||||
UserCommandMessage("Configuration closed (no changes saved).")
|
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()
|
await self._switch_to_input_app()
|
||||||
|
|
||||||
async def on_proxy_setup_app_proxy_setup_closed(
|
async def on_proxy_setup_app_proxy_setup_closed(
|
||||||
|
|
@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
for widget in children[:compact_index]:
|
for widget in children[:compact_index]:
|
||||||
await widget.remove()
|
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:
|
async def _handle_command(self, user_input: str) -> bool:
|
||||||
if command := self.commands.find_command(user_input):
|
if command := self.commands.find_command(user_input):
|
||||||
if cmd_name := self.commands.get_command_name(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")
|
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
|
skill_content = read_safe(skill_info.skill_path)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
await self._mount_and_scroll(
|
await self._mount_and_scroll(
|
||||||
ErrorMessage(
|
ErrorMessage(
|
||||||
|
|
@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
user_message = UserMessage(message)
|
user_message = UserMessage(message)
|
||||||
|
|
||||||
await self._mount_and_scroll(user_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:
|
if not self._agent_running:
|
||||||
self._agent_task = asyncio.create_task(
|
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
|
return tool in self.agent_loop.tool_manager.available_tools
|
||||||
|
|
||||||
async def _approval_callback(
|
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]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
|
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
|
||||||
# This ensures subagents respect the main agent's tool restrictions
|
# 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)
|
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||||
try:
|
try:
|
||||||
with paused_timer(self._loading_widget):
|
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
|
result = await self._pending_approval
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._pending_question = None
|
self._pending_question = None
|
||||||
await self._switch_to_input_app()
|
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:
|
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||||
self._agent_running = True
|
self._agent_running = True
|
||||||
|
|
||||||
|
|
@ -730,7 +883,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
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):
|
async for event in self.agent_loop.act(rendered_prompt):
|
||||||
|
self._turn_summary.track(event)
|
||||||
if self.event_handler:
|
if self.event_handler:
|
||||||
await self.event_handler.handle_event(
|
await self.event_handler.handle_event(
|
||||||
event,
|
event,
|
||||||
|
|
@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
if self._loading_widget and self._loading_widget.parent:
|
await self._handle_turn_error()
|
||||||
await self._loading_widget.remove()
|
self._turn_summary.cancel_turn()
|
||||||
if self.event_handler:
|
|
||||||
self.event_handler.stop_current_tool_call(success=False)
|
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._loading_widget and self._loading_widget.parent:
|
await self._handle_turn_error()
|
||||||
await self._loading_widget.remove()
|
|
||||||
if self.event_handler:
|
|
||||||
self.event_handler.stop_current_tool_call(success=False)
|
|
||||||
|
|
||||||
message = str(e)
|
message = str(e)
|
||||||
if isinstance(e, RateLimitError):
|
if isinstance(e, RateLimitError):
|
||||||
message = self._rate_limit_message()
|
message = self._rate_limit_message()
|
||||||
|
self._turn_summary.set_error(message)
|
||||||
|
|
||||||
await self._mount_and_scroll(
|
await self._mount_and_scroll(
|
||||||
ErrorMessage(message, collapsed=self._tools_collapsed)
|
ErrorMessage(message, collapsed=self._tools_collapsed)
|
||||||
)
|
)
|
||||||
finally:
|
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._agent_running = False
|
||||||
self._interrupt_requested = False
|
self._interrupt_requested = False
|
||||||
self._agent_task = None
|
self._agent_task = None
|
||||||
|
|
@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
return
|
return
|
||||||
await self._switch_to_config_app()
|
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:
|
async def _show_proxy_setup(self) -> None:
|
||||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||||
return
|
return
|
||||||
|
|
@ -1045,6 +1211,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
|
|
||||||
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
|
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
|
||||||
await self._resolve_plan()
|
await self._resolve_plan()
|
||||||
|
self._sync_turn_summary()
|
||||||
|
|
||||||
if self._banner:
|
if self._banner:
|
||||||
self._banner.set_state(
|
self._banner.set_state(
|
||||||
|
|
@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
lambda: self.config,
|
lambda: self.config,
|
||||||
audio_recorder=AudioRecorder(),
|
audio_recorder=AudioRecorder(),
|
||||||
transcribe_client=transcribe_client,
|
transcribe_client=transcribe_client,
|
||||||
|
telemetry_client=self.agent_loop.telemetry_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _toggle_voice_mode(self) -> None:
|
async def _show_voice_settings(self) -> None:
|
||||||
result = self._voice_manager.toggle_voice_mode()
|
if self._current_bottom_app == BottomApp.Voice:
|
||||||
self.agent_loop.refresh_config()
|
return
|
||||||
if result.enabled:
|
await self._switch_to_voice_app()
|
||||||
msg = "Voice mode enabled. Press ctrl+r to start recording."
|
|
||||||
else:
|
|
||||||
msg = "Voice mode disabled."
|
|
||||||
await self._mount_and_scroll(UserCommandMessage(msg))
|
|
||||||
|
|
||||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||||
bottom_container = self.query_one("#bottom-app-container")
|
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.display = False
|
||||||
self._chat_input_container.disabled = True
|
self._chat_input_container.disabled = True
|
||||||
|
|
||||||
|
self._feedback_bar.hide()
|
||||||
|
|
||||||
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
|
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
|
||||||
await bottom_container.mount(widget)
|
await bottom_container.mount(widget)
|
||||||
|
|
||||||
|
|
@ -1263,6 +1429,23 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
||||||
await self._switch_from_input(ConfigApp(self.config))
|
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:
|
async def _switch_to_proxy_setup_app(self) -> None:
|
||||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||||
return
|
return
|
||||||
|
|
@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
await self._switch_from_input(ProxySetupApp())
|
await self._switch_from_input(ProxySetupApp())
|
||||||
|
|
||||||
async def _switch_to_approval_app(
|
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:
|
) -> None:
|
||||||
approval_app = ApprovalApp(
|
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)
|
await self._switch_from_input(approval_app, scroll=True)
|
||||||
|
|
||||||
|
|
@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self.query_one(ChatInputContainer).focus_input()
|
self.query_one(ChatInputContainer).focus_input()
|
||||||
case BottomApp.Config:
|
case BottomApp.Config:
|
||||||
self.query_one(ConfigApp).focus()
|
self.query_one(ConfigApp).focus()
|
||||||
|
case BottomApp.ModelPicker:
|
||||||
|
self.query_one(ModelPickerApp).focus()
|
||||||
case BottomApp.ProxySetup:
|
case BottomApp.ProxySetup:
|
||||||
self.query_one(ProxySetupApp).focus()
|
self.query_one(ProxySetupApp).focus()
|
||||||
case BottomApp.Approval:
|
case BottomApp.Approval:
|
||||||
|
|
@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self.query_one(QuestionApp).focus()
|
self.query_one(QuestionApp).focus()
|
||||||
case BottomApp.SessionPicker:
|
case BottomApp.SessionPicker:
|
||||||
self.query_one(SessionPickerApp).focus()
|
self.query_one(SessionPickerApp).focus()
|
||||||
|
case BottomApp.Voice:
|
||||||
|
self.query_one(VoiceApp).focus()
|
||||||
case app:
|
case app:
|
||||||
assert_never(app)
|
assert_never(app)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
pass
|
pass
|
||||||
self._last_escape_time = None
|
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:
|
def _handle_approval_app_escape(self) -> None:
|
||||||
try:
|
try:
|
||||||
approval_app = self.query_one(ApprovalApp)
|
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.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||||
self._last_escape_time = None
|
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:
|
def _handle_session_picker_app_escape(self) -> None:
|
||||||
try:
|
try:
|
||||||
session_picker = self.query_one(SessionPickerApp)
|
session_picker = self.query_one(SessionPickerApp)
|
||||||
|
|
@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._handle_config_app_escape()
|
self._handle_config_app_escape()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self._current_bottom_app == BottomApp.Voice:
|
||||||
|
self._handle_voice_app_escape()
|
||||||
|
return
|
||||||
|
|
||||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||||
try:
|
try:
|
||||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||||
|
|
@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._handle_question_app_escape()
|
self._handle_question_app_escape()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||||
|
self._handle_model_picker_app_escape()
|
||||||
|
return
|
||||||
|
|
||||||
if self._current_bottom_app == BottomApp.SessionPicker:
|
if self._current_bottom_app == BottomApp.SessionPicker:
|
||||||
self._handle_session_picker_app_escape()
|
self._handle_session_picker_app_escape()
|
||||||
return
|
return
|
||||||
|
|
@ -1405,6 +1622,11 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
self._handle_input_app_escape()
|
self._handle_input_app_escape()
|
||||||
return
|
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:
|
if self._agent_running:
|
||||||
self._handle_agent_running_escape()
|
self._handle_agent_running_escape()
|
||||||
|
|
||||||
|
|
@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
def _refresh_profile_widgets(self) -> None:
|
def _refresh_profile_widgets(self) -> None:
|
||||||
self._update_profile_widgets(self.agent_loop.agent_profile)
|
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:
|
def _refresh_banner(self) -> None:
|
||||||
if self._banner:
|
if self._banner:
|
||||||
self._banner.set_state(
|
self._banner.set_state(
|
||||||
|
|
@ -1730,31 +1956,99 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
# force a full layout refresh so the UI isn't garbled.
|
# force a full layout refresh so the UI isn't garbled.
|
||||||
self.refresh(layout=True)
|
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:
|
def _on_turn_summary(self, result: TurnSummaryResult) -> None:
|
||||||
if not session_id:
|
self._cancel_summary = None
|
||||||
return
|
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()
|
async def _speak_summary(self, text: str) -> None:
|
||||||
print("To continue this session, run: vibe --continue")
|
if self._tts_client is None:
|
||||||
print(f"Or: vibe --resume {session_id}")
|
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(
|
def run_textual_ui(
|
||||||
agent_loop: AgentLoop,
|
agent_loop: AgentLoop, startup: StartupOptions | None = None
|
||||||
initial_prompt: str | None = None,
|
|
||||||
teleport_on_start: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||||
plan_offer_gateway = HttpWhoAmIGateway()
|
plan_offer_gateway = HttpWhoAmIGateway()
|
||||||
app = VibeApp(
|
app = VibeApp(
|
||||||
agent_loop=agent_loop,
|
agent_loop=agent_loop,
|
||||||
initial_prompt=initial_prompt,
|
startup=startup,
|
||||||
teleport_on_start=teleport_on_start,
|
|
||||||
update_notifier=update_notifier,
|
update_notifier=update_notifier,
|
||||||
update_cache_repository=update_cache_repository,
|
update_cache_repository=update_cache_repository,
|
||||||
plan_offer_gateway=plan_offer_gateway,
|
plan_offer_gateway=plan_offer_gateway,
|
||||||
)
|
)
|
||||||
session_id = app.run()
|
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;
|
color: ansi_default;
|
||||||
|
|
||||||
.code_inline {
|
.code_inline {
|
||||||
color: ansi_yellow;
|
color: ansi_green;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-style: bold;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
|
@ -625,7 +625,8 @@ StatusMessage {
|
||||||
.loading-hint {
|
.loading-hint {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
color: $foreground;
|
color: ansi_bright_black;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-load-more-message {
|
.history-load-more-message {
|
||||||
|
|
@ -655,7 +656,7 @@ StatusMessage {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#config-app {
|
#config-app, #voice-app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -664,7 +665,7 @@ StatusMessage {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#config-content {
|
#config-content, #voice-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -675,41 +676,14 @@ StatusMessage {
|
||||||
color: ansi_blue;
|
color: ansi_blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-option {
|
#config-options {
|
||||||
height: auto;
|
width: 100%;
|
||||||
color: ansi_default;
|
max-height: 50vh;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cursor-selected {
|
#config-options:focus {
|
||||||
color: ansi_blue;
|
border: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-help {
|
.settings-help {
|
||||||
|
|
@ -953,6 +927,14 @@ ContextProgress {
|
||||||
color: ansi_bright_black;
|
color: ansi_bright_black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NarratorStatus {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
#banner-container {
|
#banner-container {
|
||||||
align: left middle;
|
align: left middle;
|
||||||
padding: 0 1 0 0;
|
padding: 0 1 0 0;
|
||||||
|
|
@ -1083,3 +1065,54 @@ ContextProgress {
|
||||||
color: ansi_bright_black;
|
color: ansi_bright_black;
|
||||||
margin-top: 1;
|
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 subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
from vibe.core.utils.io import read_safe
|
||||||
|
|
||||||
|
|
||||||
class ExternalEditor:
|
class ExternalEditor:
|
||||||
"""Handles opening an external editor to edit prompt content."""
|
"""Handles opening an external editor to edit prompt content."""
|
||||||
|
|
@ -24,7 +26,7 @@ class ExternalEditor:
|
||||||
parts = shlex.split(editor)
|
parts = shlex.split(editor)
|
||||||
subprocess.run([*parts, filepath], check=True)
|
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
|
return content if content != initial_content else None
|
||||||
except (OSError, subprocess.CalledProcessError):
|
except (OSError, subprocess.CalledProcessError):
|
||||||
return
|
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.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
|
AgentProfileChangedEvent,
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
BaseEvent,
|
BaseEvent,
|
||||||
CompactEndEvent,
|
CompactEndEvent,
|
||||||
|
|
@ -27,10 +28,14 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
class EventHandler:
|
class EventHandler:
|
||||||
def __init__(
|
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:
|
) -> None:
|
||||||
self.mount_callback = mount_callback
|
self.mount_callback = mount_callback
|
||||||
self.get_tools_collapsed = get_tools_collapsed
|
self.get_tools_collapsed = get_tools_collapsed
|
||||||
|
self.on_profile_changed = on_profile_changed
|
||||||
self.tool_calls: dict[str, ToolCallMessage] = {}
|
self.tool_calls: dict[str, ToolCallMessage] = {}
|
||||||
self.current_compact: CompactMessage | None = None
|
self.current_compact: CompactMessage | None = None
|
||||||
self.current_streaming_message: AssistantMessage | None = None
|
self.current_streaming_message: AssistantMessage | None = None
|
||||||
|
|
@ -62,6 +67,9 @@ class EventHandler:
|
||||||
case CompactEndEvent():
|
case CompactEndEvent():
|
||||||
await self.finalize_streaming()
|
await self.finalize_streaming()
|
||||||
await self._handle_compact_end(event)
|
await self._handle_compact_end(event)
|
||||||
|
case AgentProfileChangedEvent():
|
||||||
|
if self.on_profile_changed:
|
||||||
|
self.on_profile_changed()
|
||||||
case UserMessageEvent():
|
case UserMessageEvent():
|
||||||
await self.finalize_streaming()
|
await self.finalize_streaming()
|
||||||
case _:
|
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.no_markup_static import NoMarkupStatic
|
||||||
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
|
from vibe.core.tools.permissions import RequiredPermission
|
||||||
|
|
||||||
|
|
||||||
class ApprovalApp(Container):
|
class ApprovalApp(Container):
|
||||||
|
|
@ -38,12 +39,15 @@ class ApprovalApp(Container):
|
||||||
|
|
||||||
class ApprovalGrantedAlwaysTool(Message):
|
class ApprovalGrantedAlwaysTool(Message):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: BaseModel,
|
||||||
|
required_permissions: list[RequiredPermission],
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.tool_name = tool_name
|
self.tool_name = tool_name
|
||||||
self.tool_args = tool_args
|
self.tool_args = tool_args
|
||||||
self.save_permanently = save_permanently
|
self.required_permissions = required_permissions
|
||||||
|
|
||||||
class ApprovalRejected(Message):
|
class ApprovalRejected(Message):
|
||||||
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
|
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
|
||||||
|
|
@ -52,12 +56,17 @@ class ApprovalApp(Container):
|
||||||
self.tool_args = tool_args
|
self.tool_args = tool_args
|
||||||
|
|
||||||
def __init__(
|
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:
|
) -> None:
|
||||||
super().__init__(id="approval-app")
|
super().__init__(id="approval-app")
|
||||||
self.tool_name = tool_name
|
self.tool_name = tool_name
|
||||||
self.tool_args = tool_args
|
self.tool_args = tool_args
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.required_permissions = required_permissions or []
|
||||||
self.selected_option = 0
|
self.selected_option = 0
|
||||||
self.content_container: Vertical | None = None
|
self.content_container: Vertical | None = None
|
||||||
self.title_widget: Static | None = None
|
self.title_widget: Static | None = None
|
||||||
|
|
@ -104,9 +113,15 @@ class ApprovalApp(Container):
|
||||||
await self.tool_info_container.mount(approval_widget)
|
await self.tool_info_container.mount(approval_widget)
|
||||||
|
|
||||||
def _update_options(self) -> None:
|
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 = [
|
options = [
|
||||||
("Yes", "yes"),
|
("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"),
|
("No and tell the agent what to do instead", "no"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -178,7 +193,7 @@ class ApprovalApp(Container):
|
||||||
self.ApprovalGrantedAlwaysTool(
|
self.ApprovalGrantedAlwaysTool(
|
||||||
tool_name=self.tool_name,
|
tool_name=self.tool_name,
|
||||||
tool_args=self.tool_args,
|
tool_args=self.tool_args,
|
||||||
save_permanently=False,
|
required_permissions=self.required_permissions,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
case 2:
|
case 2:
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,16 @@ class ChatTextArea(TextArea):
|
||||||
self.post_message(self.HistoryNext())
|
self.post_message(self.HistoryNext())
|
||||||
return True
|
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:
|
async def _handle_voice_key(self, event: events.Key) -> bool:
|
||||||
if not self._voice_manager:
|
if not self._voice_manager:
|
||||||
return False
|
return False
|
||||||
|
|
@ -193,6 +203,15 @@ class ChatTextArea(TextArea):
|
||||||
|
|
||||||
self._mark_cursor_moved_if_needed()
|
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
|
manager = self._completion_manager
|
||||||
if manager:
|
if manager:
|
||||||
match manager.on_key(
|
match manager.on_key(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
from __future__ import annotations
|
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.app import ComposeResult
|
||||||
from textual.binding import Binding, BindingType
|
from textual.binding import Binding, BindingType
|
||||||
from textual.containers import Container, Vertical
|
from textual.containers import Container, Vertical
|
||||||
|
from textual.events import DescendantBlur
|
||||||
from textual.message import Message
|
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
|
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||||
|
|
||||||
|
|
@ -15,22 +17,13 @@ if TYPE_CHECKING:
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
|
|
||||||
|
|
||||||
class SettingDefinition(TypedDict):
|
|
||||||
key: str
|
|
||||||
label: str
|
|
||||||
type: str
|
|
||||||
options: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigApp(Container):
|
class ConfigApp(Container):
|
||||||
can_focus = True
|
"""Settings panel with navigatable option picker."""
|
||||||
can_focus_children = False
|
|
||||||
|
can_focus_children = True
|
||||||
|
|
||||||
BINDINGS: ClassVar[list[BindingType]] = [
|
BINDINGS: ClassVar[list[BindingType]] = [
|
||||||
Binding("up", "move_up", "Up", show=False),
|
Binding("escape", "close", "Close", show=False)
|
||||||
Binding("down", "move_down", "Down", show=False),
|
|
||||||
Binding("space", "toggle_setting", "Toggle", show=False),
|
|
||||||
Binding("enter", "cycle", "Next", show=False),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
class SettingChanged(Message):
|
class SettingChanged(Message):
|
||||||
|
|
@ -44,122 +37,92 @@ class ConfigApp(Container):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.changes = changes
|
self.changes = changes
|
||||||
|
|
||||||
|
class OpenModelPicker(Message):
|
||||||
|
pass
|
||||||
|
|
||||||
def __init__(self, config: VibeConfig) -> None:
|
def __init__(self, config: VibeConfig) -> None:
|
||||||
super().__init__(id="config-app")
|
super().__init__(id="config-app")
|
||||||
self.config = config
|
self.config = config
|
||||||
self.selected_index = 0
|
|
||||||
self.changes: dict[str, str] = {}
|
self.changes: dict[str, str] = {}
|
||||||
|
self._toggle_settings: list[tuple[str, str]] = [
|
||||||
self.settings: list[SettingDefinition] = [
|
("autocopy_to_clipboard", "Auto-copy"),
|
||||||
{
|
(
|
||||||
"key": "active_model",
|
"file_watcher_for_autocomplete",
|
||||||
"label": "Model",
|
"Autocomplete watcher (may delay first autocompletion)",
|
||||||
"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.title_widget: Static | None = None
|
def _get_current_model(self) -> str:
|
||||||
self.setting_widgets: list[Static] = []
|
return str(getattr(self.config, "active_model", ""))
|
||||||
self.help_widget: Static | None = None
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def _get_toggle_value(self, key: str) -> str:
|
||||||
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"]
|
|
||||||
if key in self.changes:
|
if key in self.changes:
|
||||||
return self.changes[key]
|
return self.changes[key]
|
||||||
raw_value = getattr(self.config, key, "")
|
raw = getattr(self.config, key, False)
|
||||||
if isinstance(raw_value, bool):
|
if isinstance(raw, bool):
|
||||||
return "On" if raw_value else "Off"
|
return "On" if raw else "Off"
|
||||||
return str(raw_value)
|
return str(raw)
|
||||||
|
|
||||||
def _update_display(self) -> None:
|
def _model_prompt(self) -> Text:
|
||||||
for i, (setting, widget) in enumerate(
|
text = Text(no_wrap=True)
|
||||||
zip(self.settings, self.setting_widgets, strict=True)
|
text.append("Model: ")
|
||||||
):
|
text.append(self._get_current_model(), style="bold")
|
||||||
is_selected = i == self.selected_index
|
return text
|
||||||
cursor = "› " if is_selected else " "
|
|
||||||
|
|
||||||
label: str = setting["label"]
|
def _toggle_prompt(self, key: str, label: str) -> Text:
|
||||||
value: str = self._get_display_value(setting)
|
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")
|
def on_mount(self) -> None:
|
||||||
widget.remove_class("settings-value-cycle-selected")
|
self.query_one(OptionList).focus()
|
||||||
widget.remove_class("settings-value-cycle-unselected")
|
|
||||||
|
|
||||||
if is_selected:
|
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||||
widget.add_class("settings-value-cycle-selected")
|
self.query_one(OptionList).focus()
|
||||||
else:
|
|
||||||
widget.add_class("settings-value-cycle-unselected")
|
|
||||||
|
|
||||||
def action_move_up(self) -> None:
|
def _refresh_options(self) -> None:
|
||||||
self.selected_index = (self.selected_index - 1) % len(self.settings)
|
option_list = self.query_one(OptionList)
|
||||||
self._update_display()
|
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:
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||||
self.selected_index = (self.selected_index + 1) % len(self.settings)
|
option_id = event.option.id
|
||||||
self._update_display()
|
if not option_id:
|
||||||
|
return
|
||||||
|
|
||||||
def action_toggle_setting(self) -> None:
|
if option_id == "action:active_model":
|
||||||
setting = self.settings[self.selected_index]
|
self.post_message(self.OpenModelPicker())
|
||||||
key: str = setting["key"]
|
return
|
||||||
current: str = self._get_display_value(setting)
|
|
||||||
|
|
||||||
options: list[str] = setting["options"]
|
if option_id.startswith("toggle:"):
|
||||||
new_value = ""
|
key = option_id.removeprefix("toggle:")
|
||||||
try:
|
current = self._get_toggle_value(key)
|
||||||
current_idx = options.index(current)
|
new_value = "Off" if current == "On" else "On"
|
||||||
next_idx = (current_idx + 1) % len(options)
|
self.changes[key] = new_value
|
||||||
new_value = options[next_idx]
|
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||||
except (ValueError, IndexError):
|
self._refresh_options()
|
||||||
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()
|
|
||||||
|
|
||||||
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
||||||
result: dict[str, str | bool] = {}
|
result: dict[str, str | bool] = {}
|
||||||
|
|
@ -172,6 +135,3 @@ class ConfigApp(Container):
|
||||||
|
|
||||||
def action_close(self) -> None:
|
def action_close(self) -> None:
|
||||||
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
|
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
|
||||||