Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-06-19 17:39:44 +02:00 committed by GitHub
parent 6bedf271ce
commit 725d3a56ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1330 additions and 288 deletions

View file

@ -5,6 +5,14 @@ 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.17.1] - 2026-06-19
### Changed
- Commands in `/help` are now listed alphabetically in both the CLI and ACP
- `/teleport` is now always available and shows an explicit error when its prerequisites aren't met, instead of being hidden
## [2.17.0] - 2026-06-19 ## [2.17.0] - 2026-06-19
### Added ### Added

View file

@ -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.17.0" version = "2.17.1"
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,21 +11,21 @@ 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.17.0/vibe-acp-darwin-aarch64-2.17.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-aarch64-2.17.1.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.17.0/vibe-acp-darwin-x86_64-2.17.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-x86_64-2.17.1.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.17.0/vibe-acp-linux-aarch64-2.17.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-aarch64-2.17.1.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.17.0/vibe-acp-linux-x86_64-2.17.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-x86_64-2.17.1.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[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.17.0/vibe-acp-windows-x86_64-2.17.0.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-windows-x86_64-2.17.1.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.17.0" version = "2.17.1"
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"
@ -50,7 +50,7 @@ dependencies = [
"httpx==0.28.1", "httpx==0.28.1",
"httpx-sse==0.4.3", "httpx-sse==0.4.3",
"humanize==4.15.0", "humanize==4.15.0",
"idna==3.13", "idna==3.18",
"importlib-metadata==8.7.1", "importlib-metadata==8.7.1",
"jaraco-classes==3.4.0", "jaraco-classes==3.4.0",
"jaraco-context==6.1.2", "jaraco-context==6.1.2",
@ -65,10 +65,10 @@ dependencies = [
"linkify-it-py==2.1.0", "linkify-it-py==2.1.0",
"markdown-it-py==4.0.0", "markdown-it-py==4.0.0",
"markdownify==1.2.2", "markdownify==1.2.2",
"mcp==1.27.2", "mcp==1.28.0",
"mdit-py-plugins==0.5.0", "mdit-py-plugins==0.5.0",
"mdurl==0.1.2", "mdurl==0.1.2",
"mistralai==2.4.9", "mistralai==2.4.11",
"more-itertools==11.0.2", "more-itertools==11.0.2",
"opentelemetry-api==1.39.1", "opentelemetry-api==1.39.1",
"opentelemetry-exporter-otlp-proto-common==1.39.1", "opentelemetry-exporter-otlp-proto-common==1.39.1",

View file

@ -165,6 +165,25 @@ class TestHandleHelp:
for cmd in main_commands: for cmd in main_commands:
assert f"/{cmd}" in content assert f"/{cmd}" in content
@pytest.mark.asyncio
async def test_lists_registered_commands_alphabetically(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_id = await _new_session_and_clear(acp_agent_loop)
await _prompt(acp_agent_loop, session_id, "/help")
content = _get_message_texts(acp_agent_loop)[0]
commands_section = content.split("### Available Commands\n\n", maxsplit=1)[
1
].split("\n\n", maxsplit=1)[0]
command_names = [
line.split("`", maxsplit=2)[1].removeprefix("/")
for line in commands_section.splitlines()
if line.startswith("- ")
]
assert command_names == sorted(command_names)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_includes_user_invocable_skills( async def test_includes_user_invocable_skills(
self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path
@ -267,6 +286,28 @@ class TestHandleTeleport:
] ]
assert _get_tool_updates(acp_agent_loop) == [] assert _get_tool_updates(acp_agent_loop) == []
@pytest.mark.asyncio
async def test_teleport_replies_with_error_when_model_not_mistral(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_id = await _new_session_and_clear(acp_agent_loop)
with patch(
"vibe.core.config._settings.VibeConfig.is_active_model_mistral",
return_value=False,
):
response = await _prompt(acp_agent_loop, session_id, "/teleport")
assert response.stop_reason == "end_turn"
assert response.field_meta == {
"tool_name": "teleport",
"teleport": {"status": "unavailable"},
}
texts = _get_message_texts(acp_agent_loop)
assert len(texts) == 1
assert "active Mistral model" in texts[0]
assert _get_tool_updates(acp_agent_loop) == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_teleport_sends_tool_updates_and_structured_url( async def test_teleport_sends_tool_updates_and_structured_url(
self, acp_agent_loop: VibeAcpAgentLoop self, acp_agent_loop: VibeAcpAgentLoop
@ -542,6 +583,25 @@ class TestCommandFallthrough:
class TestAvailableCommandsWithSkills: class TestAvailableCommandsWithSkills:
@pytest.mark.asyncio
async def test_available_commands_are_alphabetical(
self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path
) -> None:
create_skill(skills_dir, "alpha-skill", "First skill")
await acp_agent_loop_with_skills.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
await _wait_for_available_commands(acp_agent_loop_with_skills)
updates = _get_client(acp_agent_loop_with_skills)._session_updates
available = [
u for u in updates if isinstance(u.update, AvailableCommandsUpdate)
]
cmd_names = [c.name for c in available[0].update.available_commands]
assert cmd_names == sorted(cmd_names)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skills_appear_in_available_commands( async def test_skills_appear_in_available_commands(
self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path

View file

@ -72,7 +72,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1"
) )
assert response.auth_methods is not None assert response.auth_methods is not None
@ -172,7 +172,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1"
) )
assert response.auth_methods is not None assert response.auth_methods is not None

View file

@ -152,7 +152,7 @@ class TestLoadSession:
"cwd": str(tmp_working_directory.resolve()), "cwd": str(tmp_working_directory.resolve()),
"repoRoot": None, "repoRoot": None,
"ignoredFiles": ["AGENTS.md"], "ignoredFiles": ["AGENTS.md"],
"availableDecisions": ["trust_cwd", "trust_session", "decline"], "availableDecisions": ["trust_cwd", "decline"],
}, },
} }
} }

View file

@ -200,7 +200,7 @@ class TestACPNewSession:
"cwd": str(tmp_working_directory.resolve()), "cwd": str(tmp_working_directory.resolve()),
"repoRoot": None, "repoRoot": None,
"ignoredFiles": ["AGENTS.md"], "ignoredFiles": ["AGENTS.md"],
"availableDecisions": ["trust_cwd", "trust_session", "decline"], "availableDecisions": ["trust_cwd", "decline"],
}, },
} }
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
@ -230,12 +230,7 @@ class TestACPNewSession:
"cwd": str(cwd.resolve()), "cwd": str(cwd.resolve()),
"repoRoot": str(repo.resolve()), "repoRoot": str(repo.resolve()),
"ignoredFiles": ["AGENTS.md"], "ignoredFiles": ["AGENTS.md"],
"availableDecisions": [ "availableDecisions": ["trust_repo", "trust_cwd", "decline"],
"trust_repo",
"trust_cwd",
"trust_session",
"decline",
],
}, },
} }
assert trusted_folders_manager.is_trusted(repo) is None assert trusted_folders_manager.is_trusted(repo) is None
@ -264,7 +259,7 @@ class TestACPNewSession:
"cwd": str(tmp_working_directory.resolve()), "cwd": str(tmp_working_directory.resolve()),
"repoRoot": None, "repoRoot": None,
"ignoredFiles": ["AGENTS.md"], "ignoredFiles": ["AGENTS.md"],
"availableDecisions": ["trust_cwd", "trust_session", "decline"], "availableDecisions": ["trust_cwd", "decline"],
}, },
} }
assert trusted_folders_manager.is_trusted(tmp_working_directory) is False assert trusted_folders_manager.is_trusted(tmp_working_directory) is False

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -20,6 +21,14 @@ async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> s
return session.agent_loop.messages[0].content or "" return session.agent_loop.messages[0].content or ""
async def _wait_for_background_tasks(
acp_agent_loop: VibeAcpAgentLoop, session_id: str
) -> None:
session = acp_agent_loop.sessions[session_id]
while session._tasks:
await asyncio.gather(*list(session._tasks))
@pytest.fixture @pytest.fixture
def acp_agent_loop( def acp_agent_loop(
backend: FakeBackend, monkeypatch: pytest.MonkeyPatch backend: FakeBackend, monkeypatch: pytest.MonkeyPatch
@ -66,12 +75,12 @@ class TestWorkspaceTrustExtMethods:
"cwd": str(tmp_working_directory.resolve()), "cwd": str(tmp_working_directory.resolve()),
"repoRoot": None, "repoRoot": None,
"ignoredFiles": ["AGENTS.md"], "ignoredFiles": ["AGENTS.md"],
"availableDecisions": ["trust_cwd", "trust_session", "decline"], "availableDecisions": ["trust_cwd", "decline"],
}, },
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_workspace_trust_decision_trusts_session_and_reloads_session( async def test_workspace_trust_decision_trusts_cwd_and_reloads_session(
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
) -> None: ) -> None:
(tmp_working_directory / "AGENTS.md").write_text( (tmp_working_directory / "AGENTS.md").write_text(
@ -90,19 +99,74 @@ class TestWorkspaceTrustExtMethods:
"trust/decision", "trust/decision",
{ {
"cwd": str(tmp_working_directory), "cwd": str(tmp_working_directory),
"decision": "trust_session", "decision": "trust_cwd",
"session_id": session_response.session_id, "session_id": session_response.session_id,
}, },
) )
assert response == {"trust_status": "session", "details": None} assert response == {"trust_status": "trusted", "details": None}
normalized = str(tmp_working_directory.resolve()) normalized = str(tmp_working_directory.resolve())
assert normalized in trusted_folders_manager._session_trusted assert normalized not in trusted_folders_manager._session_trusted
assert normalized not in trusted_folders_manager._trusted assert normalized in trusted_folders_manager._trusted
await _wait_for_background_tasks(acp_agent_loop, session_response.session_id)
assert "Reloaded session instructions" in await _system_prompt( assert "Reloaded session instructions" in await _system_prompt(
acp_agent_loop, session_response.session_id acp_agent_loop, session_response.session_id
) )
@pytest.mark.asyncio
async def test_workspace_trust_decision_returns_before_reload_completes(
self,
acp_agent_loop: VibeAcpAgentLoop,
tmp_working_directory: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_working_directory / "AGENTS.md").write_text(
"Slow reload instructions", encoding="utf-8"
)
session_response = await acp_agent_loop.new_session(
cwd=str(tmp_working_directory), mcp_servers=[]
)
assert session_response.session_id is not None
reload_started = asyncio.Event()
release_reload = asyncio.Event()
async def slow_reload_session_config(session) -> None:
reload_started.set()
await release_reload.wait()
monkeypatch.setattr(
acp_agent_loop, "_reload_session_config", slow_reload_session_config
)
decision_task = asyncio.create_task(
acp_agent_loop.ext_method(
"trust/decision",
{
"cwd": str(tmp_working_directory),
"decision": "trust_cwd",
"session_id": session_response.session_id,
},
)
)
try:
await asyncio.wait_for(reload_started.wait(), timeout=1)
await asyncio.sleep(0)
assert decision_task.done()
assert decision_task.result() == {
"trust_status": "trusted",
"details": None,
}
finally:
release_reload.set()
await decision_task
await _wait_for_background_tasks(
acp_agent_loop, session_response.session_id
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_workspace_trust_decision_rejects_unavailable_decision( async def test_workspace_trust_decision_rejects_unavailable_decision(
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
@ -117,6 +181,12 @@ class TestWorkspaceTrustExtMethods:
{"cwd": str(tmp_working_directory), "decision": "trust_repo"}, {"cwd": str(tmp_working_directory), "decision": "trust_repo"},
) )
with pytest.raises(InvalidRequestError):
await acp_agent_loop.ext_method(
"trust/decision",
{"cwd": str(tmp_working_directory), "decision": "trust_session"},
)
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
@pytest.mark.asyncio @pytest.mark.asyncio
@ -132,7 +202,7 @@ class TestWorkspaceTrustExtMethods:
"trust/decision", "trust/decision",
{ {
"cwd": str(tmp_working_directory), "cwd": str(tmp_working_directory),
"decision": "trust_session", "decision": "trust_cwd",
"session_id": "missing-session", "session_id": "missing-session",
}, },
) )

View file

@ -10,6 +10,7 @@ from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import ( from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo, PlanInfo,
WhoAmIPlanType, WhoAmIPlanType,
check_teleport_eligibility,
decide_plan_offer, decide_plan_offer,
plan_offer_cta, plan_offer_cta,
plan_title, plan_title,
@ -250,6 +251,62 @@ def test_plan_offer_cta_uses_configured_vibe_url() -> None:
) )
def test_check_teleport_eligibility_returns_none_for_eligible_key() -> None:
plan_info = PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=False,
)
assert check_teleport_eligibility(plan_info) is None
@pytest.mark.parametrize(
"plan_info",
[
PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=True,
),
PlanInfo(
plan_type=WhoAmIPlanType.API,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
),
PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
),
None,
],
ids=["pro-plan-wrong-key", "api-free", "chat-free", "unresolved"],
)
def test_check_teleport_eligibility_points_ineligible_keys_to_api_key_url(
plan_info: PlanInfo | None,
) -> None:
message = check_teleport_eligibility(plan_info)
assert message is not None
assert "https://chat.mistral.ai/code/extensions?focus=key" in message
def test_check_teleport_eligibility_uses_configured_vibe_url() -> None:
plan_info = PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=True,
)
message = check_teleport_eligibility(
plan_info, vibe_base_url="https://vibe.example.com/"
)
assert message is not None
assert "https://vibe.example.com/code/extensions?focus=key" in message
@pytest.mark.parametrize( @pytest.mark.parametrize(
("response", "expected"), ("response", "expected"),
[ [

View file

@ -1,20 +1,6 @@
from __future__ import annotations from __future__ import annotations
from vibe.cli.commands import Command, CommandAvailabilityContext, CommandRegistry from vibe.cli.commands import Command, CommandRegistry
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType
def _eligible_teleport_context() -> CommandAvailabilityContext:
return CommandAvailabilityContext(
vibe_code_enabled=True,
is_active_model_mistral=True,
plan_info=PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=False,
),
)
class TestCommandRegistry: class TestCommandRegistry:
@ -79,7 +65,7 @@ class TestCommandRegistry:
assert registry.parse_command("/teleport") is None assert registry.parse_command("/teleport") is None
def test_teleport_command_registration_uses_resolved_context(self) -> None: def test_teleport_command_registration_uses_resolved_context(self) -> None:
registry = CommandRegistry(availability_context=_eligible_teleport_context()) registry = CommandRegistry(vibe_code_enabled=True)
assert registry.get_command_name("/teleport") == "teleport" assert registry.get_command_name("/teleport") == "teleport"
assert registry.has_command("teleport") assert registry.has_command("teleport")
@ -87,12 +73,23 @@ class TestCommandRegistry:
registry = CommandRegistry() registry = CommandRegistry()
assert "/teleport" not in registry.get_help_text() assert "/teleport" not in registry.get_help_text()
eligible_registry = CommandRegistry( eligible_registry = CommandRegistry(vibe_code_enabled=True)
availability_context=_eligible_teleport_context()
)
assert eligible_registry.get("teleport") is not None assert eligible_registry.get("teleport") is not None
assert "/teleport" in eligible_registry.get_help_text() assert "/teleport" in eligible_registry.get_help_text()
def test_help_text_lists_commands_alphabetically(self) -> None:
registry = CommandRegistry()
commands_section = registry.get_help_text().split(
"### Commands\n\n", maxsplit=1
)[1]
command_names = [
line.split("`", maxsplit=2)[1].removeprefix("/")
for line in commands_section.splitlines()
if line.startswith("- ")
]
assert command_names == sorted(command_names)
def test_resume_command_registration(self) -> None: def test_resume_command_registration(self) -> None:
registry = CommandRegistry() registry = CommandRegistry()
assert registry.get_command_name("/resume") == "resume" assert registry.get_command_name("/resume") == "resume"

View file

@ -11,6 +11,7 @@ from tests.conftest import build_test_vibe_app, build_test_vibe_config
from tests.constants import OPENAI_BASE_URL from tests.constants import OPENAI_BASE_URL
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.widgets.chat_input import ChatInputContainer from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.types import Backend from vibe.core.types import Backend
@ -48,6 +49,10 @@ def _teleport_failed_events(
] ]
def _error_messages(app) -> list[str]:
return [error._error for error in app.query(ErrorMessage)]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_teleport_command_visible_for_paid_chat_users() -> None: async def test_teleport_command_visible_for_paid_chat_users() -> None:
app = build_test_vibe_app( app = build_test_vibe_app(
@ -102,66 +107,101 @@ async def test_teleport_command_without_history_sends_early_failure_telemetry(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None: async def test_teleport_command_visible_but_errors_when_key_not_eligible(
telemetry_events: list[dict[str, Any]],
) -> None:
app = build_test_vibe_app( app = build_test_vibe_app(
config=_vibe_code_enabled_config(), config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
) )
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.pause(0.2) await _wait_until(
pilot.pause,
lambda: app.commands.get_command_name("/teleport") == "teleport",
)
assert app.commands.get_command_name("/teleport") is None assert "/teleport" in app.commands.get_help_text()
assert "/teleport" not in app.commands.get_help_text()
input_widget = app.query_one(ChatInputContainer).input_widget input_widget = app.query_one(ChatInputContainer).input_widget
assert input_widget is not None assert input_widget is not None
assert "&" not in input_widget.mode_characters assert "&" in input_widget.mode_characters
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("/teleport")
)
await _wait_until(
pilot.pause,
lambda: any("Vibe Pro API key" in error for error in _error_messages(app)),
)
assert _teleport_failed_events(telemetry_events) == [
{
"event_name": "vibe.teleport_failed",
"properties": {
"stage": "ineligible",
"error_class": "TeleportIneligibleError",
"push_required": False,
"nb_session_messages": 0,
"session_id": app.agent_loop.session_id,
},
}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hidden_teleport_command_falls_through_as_user_text() -> None: async def test_teleport_command_errors_instead_of_user_text_when_not_eligible() -> None:
app = build_test_vibe_app( app = build_test_vibe_app(
config=_vibe_code_enabled_config(), config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
) )
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.pause(0.2) await _wait_until(
pilot.pause,
lambda: app.commands.get_command_name("/teleport") == "teleport",
)
app._handle_teleport_command = AsyncMock()
app._handle_user_message = AsyncMock() app._handle_user_message = AsyncMock()
await app.on_chat_input_container_submitted( await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("/teleport") ChatInputContainer.Submitted("/teleport")
) )
await _wait_until(
pilot.pause,
lambda: any("Vibe Pro API key" in error for error in _error_messages(app)),
)
app._handle_teleport_command.assert_not_awaited() app._handle_user_message.assert_not_awaited()
app._handle_user_message.assert_awaited_once_with("/teleport")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hidden_ampersand_teleport_shortcut_falls_through_as_user_text() -> None: async def test_ampersand_teleport_shortcut_errors_when_not_eligible() -> None:
app = build_test_vibe_app( app = build_test_vibe_app(
config=_vibe_code_enabled_config(), config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
) )
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.pause(0.2) await _wait_until(
pilot.pause,
lambda: app.commands.get_command_name("/teleport") == "teleport",
)
app._handle_teleport_command = AsyncMock()
app._handle_user_message = AsyncMock() app._handle_user_message = AsyncMock()
await app.on_chat_input_container_submitted( await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("&continue") ChatInputContainer.Submitted("&continue")
) )
await _wait_until(
pilot.pause,
lambda: any("Vibe Pro API key" in error for error in _error_messages(app)),
)
app._handle_teleport_command.assert_not_awaited() app._handle_user_message.assert_not_awaited()
app._handle_user_message.assert_awaited_once_with("&continue")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_teleport_command_hides_after_switching_to_non_mistral_model( async def test_teleport_command_errors_after_switching_to_non_mistral_model(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-key") monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-key")
@ -223,11 +263,18 @@ async def test_teleport_command_hides_after_switching_to_non_mistral_model(
): ):
await app._reload_config() await app._reload_config()
await _wait_until( await _wait_until(pilot.pause, lambda: not app.config.is_active_model_mistral())
pilot.pause, lambda: app.commands.get_command_name("/teleport") is None assert app.commands.get_command_name("/teleport") == "teleport"
)
assert app.commands.get_command_name("/teleport") is None
input_widget = app.query_one(ChatInputContainer).input_widget input_widget = app.query_one(ChatInputContainer).input_widget
assert input_widget is not None assert input_widget is not None
assert "&" not in input_widget.mode_characters assert "&" in input_widget.mode_characters
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("/teleport")
)
await _wait_until(
pilot.pause,
lambda: any(
"active Mistral model" in error for error in _error_messages(app)
),
)

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from textual.content import Content from textual.content import Content
from textual.highlight import HighlightTheme from textual.highlight import HighlightTheme
from textual.widgets import Static from textual.widget import Widget
from vibe.cli.textual_ui.widgets.diff_rendering import ( from vibe.cli.textual_ui.widgets.diff_rendering import (
_build_diff_line, _build_diff_line,
@ -35,7 +35,9 @@ def _render_with_colors(*args, **kwargs):
return widgets, diff_border_colors(widgets) return widgets, diff_border_colors(widgets)
def _plain(widget: Static) -> str: def _plain(widget: Widget) -> str:
if plain := getattr(widget, "plain", None):
return plain
visual = widget.render() visual = widget.render()
return visual.plain if isinstance(visual, Content) else str(visual) return visual.plain if isinstance(visual, Content) else str(visual)

View file

@ -0,0 +1,195 @@
from __future__ import annotations
import httpx
import pytest
import respx
from vibe.core.skills.registry._client import RegistrySkillsClient, RegistrySkillsError
_URL = "https://api.mistral.ai/v1/skills"
def _page(skill_id: str, *, next_token: str = "") -> dict[str, object]:
return {
"data": [
{"skillId": skill_id, "skill": {"skillName": skill_id, "skillBody": "b"}}
],
"nextPageToken": next_token,
}
@pytest.mark.asyncio
@respx.mock
async def test_list_catalog_paginates() -> None:
route = respx.get(_URL)
route.side_effect = [
httpx.Response(200, json=_page("a", next_token="p2")),
httpx.Response(200, json=_page("b")),
]
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
items = await client.list_catalog(page_size=50)
assert [item.skill_id for item in items] == ["a", "b"]
assert route.calls[0].request.url.params["pageSize"] == "50"
assert "pageToken" not in route.calls[0].request.url.params
assert route.calls[1].request.url.params["pageToken"] == "p2"
assert route.calls[0].request.headers["Authorization"] == "Bearer key"
@pytest.mark.asyncio
@respx.mock
async def test_list_catalog_single_page() -> None:
respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("only")))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
items = await client.list_catalog(page_size=100)
assert [item.skill_id for item in items] == ["only"]
@pytest.mark.asyncio
@respx.mock
async def test_unauthorized_raises() -> None:
respx.get(_URL).mock(return_value=httpx.Response(401, json={"message": "no"}))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="unauthorized"):
await client.list_catalog(page_size=10)
@pytest.mark.asyncio
@respx.mock
async def test_server_error_raises() -> None:
respx.get(_URL).mock(return_value=httpx.Response(503))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="unexpected status"):
await client.list_catalog(page_size=10)
@pytest.mark.asyncio
@respx.mock
async def test_non_json_raises() -> None:
respx.get(_URL).mock(return_value=httpx.Response(200, text="not json"))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="valid JSON"):
await client.list_catalog(page_size=10)
@pytest.mark.asyncio
@respx.mock
async def test_network_error_raises() -> None:
respx.get(_URL).mock(side_effect=httpx.ConnectError("boom"))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="request failed"):
await client.list_catalog(page_size=10)
@pytest.mark.asyncio
@respx.mock
async def test_list_catalog_sends_fields_mask() -> None:
route = respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("a")))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
await client.list_catalog(page_size=10)
params = route.calls[0].request.url.params
assert params["pageSize"] == "10"
assert "skillBody" not in params["fields"]
assert "skillName" in params["fields"]
@pytest.mark.asyncio
@respx.mock
async def test_list_versions_parses_aliases_and_sorts_desc() -> None:
respx.get(f"{_URL}/sid/versions").mock(
return_value=httpx.Response(
200,
json={
"items": [
{"version": 1, "versionAttributes": {"aliases": ["old"]}},
{
"version": 3,
"versionAttributes": {"aliases": ["stable", "main"]},
},
{"version": 2},
]
},
)
)
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
versions = await client.list_versions("sid")
assert [v.version for v in versions] == [3, 2, 1]
assert versions[0].aliases == ["stable", "main"]
assert versions[1].aliases == []
assert versions[2].aliases == ["old"]
@pytest.mark.asyncio
@respx.mock
async def test_list_versions_invalid_payload_raises() -> None:
respx.get(f"{_URL}/sid/versions").mock(
return_value=httpx.Response(200, json={"items": [{"noVersion": 1}]})
)
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="invalid versions response"):
await client.list_versions("sid")
@pytest.mark.asyncio
@respx.mock
async def test_get_skill_by_id() -> None:
respx.get(f"{_URL}/sid").mock(
return_value=httpx.Response(
200,
json={
"skillId": "sid",
"skill": {"skillName": "n", "skillBody": "b"},
"version": 3,
},
)
)
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
item = await client.get_skill("sid")
assert item.skill_id == "sid"
assert item.version == 3
@pytest.mark.asyncio
@respx.mock
async def test_get_skill_not_found_raises() -> None:
respx.get(f"{_URL}/missing").mock(return_value=httpx.Response(404))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="not found"):
await client.get_skill("missing")
@pytest.mark.asyncio
@respx.mock
async def test_get_skill_invalid_payload_raises() -> None:
# A 200 whose body isn't a valid skill object must surface as a registry
# error, not a bare pydantic ValidationError.
respx.get(f"{_URL}/sid").mock(
return_value=httpx.Response(200, json="not-an-object")
)
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="invalid skill response"):
await client.get_skill("sid")
@pytest.mark.asyncio
@respx.mock
async def test_list_catalog_invalid_payload_raises() -> None:
respx.get(_URL).mock(return_value=httpx.Response(200, json="not-an-object"))
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="invalid catalog response"):
await client.list_catalog(page_size=10)
@pytest.mark.asyncio
@respx.mock
async def test_list_catalog_raises_when_page_cap_exceeded() -> None:
# Every page reports more pages -> the cap is hit with data remaining, which
# must raise rather than silently return a truncated catalog.
respx.get(_URL).mock(
return_value=httpx.Response(200, json=_page("a", next_token="more"))
)
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
with pytest.raises(RegistrySkillsError, match="maximum number of pages"):
await client.list_catalog(page_size=10)

View file

@ -0,0 +1,169 @@
from __future__ import annotations
import base64
import re
import pytest
from vibe.core.skills.registry.models import (
ListSkillsResponse,
RegistryAssetContent,
RegistrySkillItem,
sanitize_skill_name,
)
@pytest.mark.parametrize(
("raw", "expected"),
[
("my_skill", "my-skill"),
("My Skill", "my-skill"),
(" weird__name--ok ", "weird-name-ok"),
("grill-me", "grill-me"),
("___", None),
("", None),
],
)
def test_sanitize_skill_name(raw: str, expected: str | None) -> None:
assert sanitize_skill_name(raw) == expected
def test_asset_to_bytes_text() -> None:
asset = RegistryAssetContent(text_content="hello")
assert asset.to_bytes() == b"hello"
def test_asset_to_bytes_raw_base64() -> None:
encoded = base64.b64encode(b"\x00\x01binary").decode("ascii")
asset = RegistryAssetContent(raw_content=encoded, is_executable=True)
assert asset.to_bytes() == b"\x00\x01binary"
assert asset.is_executable is True
def test_asset_to_bytes_invalid_base64_returns_none() -> None:
assert RegistryAssetContent(raw_content="not!base64!").to_bytes() is None
def test_asset_to_bytes_empty_returns_none() -> None:
assert RegistryAssetContent().to_bytes() is None
def test_item_parses_camel_case() -> None:
item = RegistrySkillItem.model_validate({
"skillId": "abc",
"skill": {
"skillName": "Free Form",
"skillDescription": "does things",
"skillBody": "# body",
"skillAssets": {"ref.txt": {"textContent": "x", "isExecutable": False}},
},
"metadata": {"name": "my_skill", "latestVersion": 3},
"version": 3,
})
assert item.skill_id == "abc"
assert item.resolved_name == "my-skill"
assert item.resolved_description == "does things"
assert item.skill.skill_assets["ref.txt"].text_content == "x"
assert item.version == 3
def test_item_parses_snake_case_fallback() -> None:
item = RegistrySkillItem.model_validate({
"skill_id": "abc",
"skill": {"skill_name": "n", "skill_body": "b"},
"metadata": {"name": "snake_name"},
})
assert item.resolved_name == "snake-name"
def test_item_name_falls_back_to_skill_name() -> None:
item = RegistrySkillItem.model_validate({
"skill": {"skillName": "Fallback Name", "skillBody": "b"}
})
assert item.resolved_name == "fallback-name"
def test_item_name_falls_back_to_title() -> None:
item = RegistrySkillItem.model_validate({
"skillId": "abc",
"skill": {"skillBody": "b"},
"attributes": {"title": "My Cool Skill"},
})
assert item.resolved_name == "my-cool-skill"
def test_item_name_falls_back_to_skill_id() -> None:
item = RegistrySkillItem.model_validate({
"skillId": "019de91a-84f5-76af-84a2-5f4389f372c7",
"skill": {"skillBody": "b"},
})
assert item.resolved_name == "skill-019de91a84f576af84a25f4389f372c7"
def test_item_name_none_without_any_identifier() -> None:
item = RegistrySkillItem.model_validate({"skill": {"skillBody": "b"}})
assert item.resolved_name is None
def test_item_description_falls_back_to_attributes() -> None:
item = RegistrySkillItem.model_validate({
"skill": {"skillName": "n", "skillBody": "b"},
"attributes": {"title": "T", "description": "attr desc"},
})
assert item.resolved_description == "attr desc"
def test_list_response_parses_pagination() -> None:
response = ListSkillsResponse.model_validate({
"data": [{"skillId": "1", "skill": {"skillName": "a", "skillBody": "b"}}],
"nextPageToken": "next",
})
assert len(response.data) == 1
assert response.next_page_token == "next"
def test_list_response_defaults_empty() -> None:
response = ListSkillsResponse.model_validate({})
assert response.data == []
assert response.next_page_token == ""
# Resolved/sanitized names must always satisfy SkillMetadata.name's pattern.
_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
def test_sanitize_skill_name_caps_at_64_chars() -> None:
name = sanitize_skill_name("x" * 200)
assert name is not None
assert len(name) <= 64
assert _NAME_RE.match(name)
def test_resolved_name_id_fallback_is_lowercase_and_valid() -> None:
# No usable name anywhere -> falls back to the skill id, which may be
# uppercase; it must still be a valid, lowercase skill name.
item = RegistrySkillItem.model_validate({
"skillId": "AB12CD34-EF56-7890-ABCD-EF1234567890"
})
assert item.resolved_name == "skill-ab12cd34ef567890abcdef1234567890"
assert _NAME_RE.match(item.resolved_name or "")
def test_resolved_name_id_fallback_uses_full_id_to_avoid_collisions() -> None:
# Ids sharing a prefix (common for time-ordered UUIDv7) must not collapse
# to the same fallback name.
a = RegistrySkillItem.model_validate({
"skillId": "ab12cd34-0000-0000-0000-000000000001"
})
b = RegistrySkillItem.model_validate({
"skillId": "ab12cd34-0000-0000-0000-000000000002"
})
assert a.resolved_name != b.resolved_name
def test_resolved_name_id_fallback_handles_degenerate_id() -> None:
# A hyphen-only id must collapse to a valid name, not a trailing-hyphen
# "skill-" that would fail validation.
item = RegistrySkillItem.model_validate({"skillId": "----"})
assert item.resolved_name == "skill"
assert _NAME_RE.match(item.resolved_name or "")

View file

@ -212,31 +212,31 @@
</text><text class="terminal-r1" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></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="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></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="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r3" x="48.8" y="337.2" textLength="97.6" clip-path="url(#terminal-line-13)">Commands</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="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r3" x="48.8" y="337.2" textLength="97.6" clip-path="url(#terminal-line-13)">Commands</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="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></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="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></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="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-line-15)">&#160;</text><text class="terminal-r2" x="73.2" y="386" textLength="61" clip-path="url(#terminal-line-15)">/help</text><text class="terminal-r1" x="134.2" y="386" textLength="231.8" clip-path="url(#terminal-line-15)">:&#160;Show&#160;help&#160;message</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> </text><text class="terminal-r1" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-line-15)">&#160;</text><text class="terminal-r2" x="73.2" y="386" textLength="73.2" clip-path="url(#terminal-line-15)">/clear</text><text class="terminal-r1" x="146.4" y="386" textLength="341.6" clip-path="url(#terminal-line-15)">:&#160;Clear&#160;conversation&#160;history</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="48.8" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">&#160;</text><text class="terminal-r2" x="73.2" y="410.4" textLength="85.4" clip-path="url(#terminal-line-16)">/config</text><text class="terminal-r1" x="158.6" y="410.4" textLength="268.4" clip-path="url(#terminal-line-16)">:&#160;Edit&#160;config&#160;settings</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="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="48.8" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">&#160;</text><text class="terminal-r2" x="73.2" y="410.4" textLength="97.6" clip-path="url(#terminal-line-16)">/compact</text><text class="terminal-r1" x="170.8" y="410.4" textLength="1171.2" clip-path="url(#terminal-line-16)">:&#160;Compact&#160;conversation&#160;history&#160;by&#160;summarizing.&#160;Optionally&#160;pass&#160;instructions&#160;to&#160;guide&#160;the&#160;summary</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="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-line-17)">&#160;</text><text class="terminal-r2" x="73.2" y="434.8" textLength="73.2" clip-path="url(#terminal-line-17)">/model</text><text class="terminal-r1" x="146.4" y="434.8" textLength="256.2" clip-path="url(#terminal-line-17)">:&#160;Select&#160;active&#160;model</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="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-line-17)">&#160;</text><text class="terminal-r2" x="73.2" y="434.8" textLength="85.4" clip-path="url(#terminal-line-17)">/config</text><text class="terminal-r1" x="158.6" y="434.8" textLength="268.4" clip-path="url(#terminal-line-17)">:&#160;Edit&#160;config&#160;settings</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="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="48.8" y="459.2" textLength="24.4" clip-path="url(#terminal-line-18)">&#160;</text><text class="terminal-r2" x="73.2" y="459.2" textLength="109.8" clip-path="url(#terminal-line-18)">/thinking</text><text class="terminal-r1" x="183" y="459.2" textLength="280.6" clip-path="url(#terminal-line-18)">:&#160;Select&#160;thinking&#160;level</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="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="48.8" y="459.2" textLength="24.4" clip-path="url(#terminal-line-18)">&#160;</text><text class="terminal-r2" x="73.2" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">/copy</text><text class="terminal-r1" x="134.2" y="459.2" textLength="561.2" clip-path="url(#terminal-line-18)">:&#160;Copy&#160;the&#160;last&#160;agent&#160;message&#160;to&#160;the&#160;clipboard</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="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">&#160;</text><text class="terminal-r2" x="73.2" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">/reload</text><text class="terminal-r1" x="158.6" y="483.6" textLength="780.8" clip-path="url(#terminal-line-19)">:&#160;Reload&#160;configuration,&#160;agent&#160;instructions,&#160;and&#160;skills&#160;from&#160;disk</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="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">&#160;</text><text class="terminal-r2" x="73.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">/data-retention</text><text class="terminal-r1" x="256.2" y="483.6" textLength="402.6" clip-path="url(#terminal-line-19)">:&#160;Show&#160;data&#160;retention&#160;information</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">&#160;</text><text class="terminal-r2" x="73.2" y="508" textLength="73.2" clip-path="url(#terminal-line-20)">/clear</text><text class="terminal-r1" x="146.4" y="508" textLength="341.6" clip-path="url(#terminal-line-20)">:&#160;Clear&#160;conversation&#160;history</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> </text><text class="terminal-r1" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">&#160;</text><text class="terminal-r2" x="73.2" y="508" textLength="73.2" clip-path="url(#terminal-line-20)">/debug</text><text class="terminal-r1" x="146.4" y="508" textLength="268.4" clip-path="url(#terminal-line-20)">:&#160;Toggle&#160;debug&#160;console</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">&#160;</text><text class="terminal-r2" x="73.2" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/copy</text><text class="terminal-r1" x="134.2" y="532.4" textLength="561.2" clip-path="url(#terminal-line-21)">:&#160;Copy&#160;the&#160;last&#160;agent&#160;message&#160;to&#160;the&#160;clipboard</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="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">&#160;</text><text class="terminal-r2" x="73.2" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/exit</text><text class="terminal-r1" x="134.2" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">,&#160;</text><text class="terminal-r2" x="158.6" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">:q</text><text class="terminal-r1" x="183" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">,&#160;</text><text class="terminal-r2" x="207.4" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">:quit</text><text class="terminal-r1" x="268.4" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">,&#160;</text><text class="terminal-r2" x="292.8" y="532.4" textLength="48.8" clip-path="url(#terminal-line-21)">exit</text><text class="terminal-r1" x="341.6" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">,&#160;</text><text class="terminal-r2" x="366" y="532.4" textLength="48.8" clip-path="url(#terminal-line-21)">quit</text><text class="terminal-r1" x="414.8" y="532.4" textLength="268.4" clip-path="url(#terminal-line-21)">:&#160;Exit&#160;the&#160;application</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="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">&#160;</text><text class="terminal-r2" x="73.2" y="556.8" textLength="48.8" clip-path="url(#terminal-line-22)">/log</text><text class="terminal-r1" x="122" y="556.8" textLength="524.6" clip-path="url(#terminal-line-22)">:&#160;Show&#160;path&#160;to&#160;current&#160;interaction&#160;log&#160;file</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="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">&#160;</text><text class="terminal-r2" x="73.2" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="134.2" y="556.8" textLength="231.8" clip-path="url(#terminal-line-22)">:&#160;Show&#160;help&#160;message</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="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="24.4" clip-path="url(#terminal-line-23)">&#160;</text><text class="terminal-r2" x="73.2" y="581.2" textLength="73.2" clip-path="url(#terminal-line-23)">/debug</text><text class="terminal-r1" x="146.4" y="581.2" textLength="268.4" clip-path="url(#terminal-line-23)">:&#160;Toggle&#160;debug&#160;console</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="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="24.4" clip-path="url(#terminal-line-23)">&#160;</text><text class="terminal-r2" x="73.2" y="581.2" textLength="122" clip-path="url(#terminal-line-23)">/leanstall</text><text class="terminal-r1" x="195.2" y="581.2" textLength="463.6" clip-path="url(#terminal-line-23)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</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="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">&#160;</text><text class="terminal-r2" x="73.2" y="605.6" textLength="97.6" clip-path="url(#terminal-line-24)">/compact</text><text class="terminal-r1" x="170.8" y="605.6" textLength="1171.2" clip-path="url(#terminal-line-24)">:&#160;Compact&#160;conversation&#160;history&#160;by&#160;summarizing.&#160;Optionally&#160;pass&#160;instructions&#160;to&#160;guide&#160;the&#160;summary</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="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">&#160;</text><text class="terminal-r2" x="73.2" y="605.6" textLength="48.8" clip-path="url(#terminal-line-24)">/log</text><text class="terminal-r1" x="122" y="605.6" textLength="524.6" clip-path="url(#terminal-line-24)">:&#160;Show&#160;path&#160;to&#160;current&#160;interaction&#160;log&#160;file</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="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">&#160;</text><text class="terminal-r2" x="73.2" y="630" textLength="61" clip-path="url(#terminal-line-25)">/exit</text><text class="terminal-r1" x="134.2" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">,&#160;</text><text class="terminal-r2" x="158.6" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">:q</text><text class="terminal-r1" x="183" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">,&#160;</text><text class="terminal-r2" x="207.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">:quit</text><text class="terminal-r1" x="268.4" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">,&#160;</text><text class="terminal-r2" x="292.8" y="630" textLength="48.8" clip-path="url(#terminal-line-25)">exit</text><text class="terminal-r1" x="341.6" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">,&#160;</text><text class="terminal-r2" x="366" y="630" textLength="48.8" clip-path="url(#terminal-line-25)">quit</text><text class="terminal-r1" x="414.8" y="630" textLength="268.4" clip-path="url(#terminal-line-25)">:&#160;Exit&#160;the&#160;application</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r1" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">&#160;</text><text class="terminal-r2" x="73.2" y="630" textLength="61" clip-path="url(#terminal-line-25)">/loop</text><text class="terminal-r1" x="134.2" y="630" textLength="427" clip-path="url(#terminal-line-25)">:&#160;Schedule&#160;a&#160;recurring&#160;prompt.&#160;Use&#160;</text><text class="terminal-r2" x="561.2" y="630" textLength="305" clip-path="url(#terminal-line-25)">/loop&#160;&lt;interval&gt;&#160;&lt;prompt&gt;</text><text class="terminal-r1" x="866.2" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">,&#160;</text><text class="terminal-r2" x="890.6" y="630" textLength="122" clip-path="url(#terminal-line-25)">/loop&#160;list</text><text class="terminal-r1" x="1012.6" y="630" textLength="61" clip-path="url(#terminal-line-25)">,&#160;or&#160;</text><text class="terminal-r2" x="1073.6" y="630" textLength="256.2" clip-path="url(#terminal-line-25)">/loop&#160;cancel&#160;&lt;id|all&gt;</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" 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="24.4" clip-path="url(#terminal-line-26)">&#160;</text><text class="terminal-r2" x="73.2" y="654.4" textLength="85.4" clip-path="url(#terminal-line-26)">/status</text><text class="terminal-r1" x="158.6" y="654.4" textLength="317.2" clip-path="url(#terminal-line-26)">:&#160;Display&#160;agent&#160;statistics</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="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="24.4" clip-path="url(#terminal-line-26)">&#160;</text><text class="terminal-r2" x="73.2" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">/mcp</text><text class="terminal-r1" x="122" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">,&#160;</text><text class="terminal-r2" x="146.4" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">/connectors</text><text class="terminal-r1" x="280.6" y="654.4" textLength="1061.4" clip-path="url(#terminal-line-26)">:&#160;Display&#160;available&#160;MCP&#160;servers&#160;and&#160;connectors.&#160;Pass&#160;a&#160;name&#160;to&#160;list&#160;tools;&#160;subcommands:</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="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="24.4" clip-path="url(#terminal-line-27)">&#160;</text><text class="terminal-r2" x="73.2" y="678.8" textLength="109.8" clip-path="url(#terminal-line-27)">/teleport</text><text class="terminal-r1" x="183" y="678.8" textLength="427" clip-path="url(#terminal-line-27)">:&#160;Teleport&#160;session&#160;to&#160;Vibe&#160;Code&#160;Web</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="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="73.2" y="678.8" textLength="280.6" clip-path="url(#terminal-line-27)">status,&#160;login&#160;,&#160;logout&#160;</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="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r2" x="73.2" y="703.2" textLength="146.4" clip-path="url(#terminal-line-28)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="703.2" textLength="561.2" clip-path="url(#terminal-line-28)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</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="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r2" x="73.2" y="703.2" textLength="73.2" clip-path="url(#terminal-line-28)">/model</text><text class="terminal-r1" x="146.4" y="703.2" textLength="256.2" clip-path="url(#terminal-line-28)">:&#160;Select&#160;active&#160;model</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="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">&#160;</text><text class="terminal-r2" x="73.2" y="727.6" textLength="109.8" clip-path="url(#terminal-line-29)">/continue</text><text class="terminal-r1" x="183" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">,&#160;</text><text class="terminal-r2" x="207.4" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">/resume</text><text class="terminal-r1" x="292.8" y="727.6" textLength="512.4" clip-path="url(#terminal-line-29)">:&#160;Browse,&#160;resume,&#160;or&#160;delete&#160;saved&#160;sessions</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="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">&#160;</text><text class="terminal-r2" x="73.2" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="727.6" textLength="561.2" clip-path="url(#terminal-line-29)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</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="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">&#160;</text><text class="terminal-r2" x="73.2" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/rename</text><text class="terminal-r1" x="158.6" y="752" textLength="341.6" clip-path="url(#terminal-line-30)">:&#160;Rename&#160;the&#160;current&#160;session</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> </text><text class="terminal-r1" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">&#160;</text><text class="terminal-r2" x="73.2" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/reload</text><text class="terminal-r1" x="158.6" y="752" textLength="780.8" clip-path="url(#terminal-line-30)">:&#160;Reload&#160;configuration,&#160;agent&#160;instructions,&#160;and&#160;skills&#160;from&#160;disk</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r1" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">&#160;</text><text class="terminal-r2" x="73.2" y="776.4" textLength="134.2" clip-path="url(#terminal-line-31)">/connectors</text><text class="terminal-r1" x="207.4" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">,&#160;</text><text class="terminal-r2" x="231.8" y="776.4" textLength="48.8" clip-path="url(#terminal-line-31)">/mcp</text><text class="terminal-r1" x="280.6" y="776.4" textLength="1061.4" clip-path="url(#terminal-line-31)">:&#160;Display&#160;available&#160;MCP&#160;servers&#160;and&#160;connectors.&#160;Pass&#160;a&#160;name&#160;to&#160;list&#160;tools;&#160;subcommands:</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r1" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">&#160;</text><text class="terminal-r2" x="73.2" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">/rename</text><text class="terminal-r1" x="158.6" y="776.4" textLength="341.6" clip-path="url(#terminal-line-31)">:&#160;Rename&#160;the&#160;current&#160;session</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r1" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="73.2" y="800.8" textLength="280.6" clip-path="url(#terminal-line-32)">status,&#160;login&#160;,&#160;logout&#160;</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r1" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="48.8" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">&#160;</text><text class="terminal-r2" x="73.2" y="800.8" textLength="85.4" clip-path="url(#terminal-line-32)">/resume</text><text class="terminal-r1" x="158.6" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">,&#160;</text><text class="terminal-r2" x="183" y="800.8" textLength="109.8" clip-path="url(#terminal-line-32)">/continue</text><text class="terminal-r1" x="292.8" y="800.8" textLength="512.4" clip-path="url(#terminal-line-32)">:&#160;Browse,&#160;resume,&#160;or&#160;delete&#160;saved&#160;sessions</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">&#160;</text><text class="terminal-r2" x="73.2" y="825.2" textLength="73.2" clip-path="url(#terminal-line-33)">/voice</text><text class="terminal-r1" x="146.4" y="825.2" textLength="317.2" clip-path="url(#terminal-line-33)">:&#160;Configure&#160;voice&#160;settings</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> </text><text class="terminal-r1" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">&#160;</text><text class="terminal-r2" x="73.2" y="825.2" textLength="85.4" clip-path="url(#terminal-line-33)">/rewind</text><text class="terminal-r1" x="158.6" y="825.2" textLength="366" clip-path="url(#terminal-line-33)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"></text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">&#160;</text><text class="terminal-r2" x="73.2" y="849.6" textLength="122" clip-path="url(#terminal-line-34)">/leanstall</text><text class="terminal-r1" x="195.2" y="849.6" textLength="463.6" clip-path="url(#terminal-line-34)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> </text><text class="terminal-r1" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"></text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">&#160;</text><text class="terminal-r2" x="73.2" y="849.6" textLength="85.4" clip-path="url(#terminal-line-34)">/status</text><text class="terminal-r1" x="158.6" y="849.6" textLength="317.2" clip-path="url(#terminal-line-34)">:&#160;Display&#160;agent&#160;statistics</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r1" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">&#160;</text><text class="terminal-r2" x="73.2" y="874" textLength="146.4" clip-path="url(#terminal-line-35)">/unleanstall</text><text class="terminal-r1" x="219.6" y="874" textLength="341.6" clip-path="url(#terminal-line-35)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"> </text><text class="terminal-r1" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">&#160;</text><text class="terminal-r2" x="73.2" y="874" textLength="109.8" clip-path="url(#terminal-line-35)">/teleport</text><text class="terminal-r1" x="183" y="874" textLength="427" clip-path="url(#terminal-line-35)">:&#160;Teleport&#160;session&#160;to&#160;Vibe&#160;Code&#160;Web</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">
</text><text class="terminal-r1" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">&#160;</text><text class="terminal-r2" x="73.2" y="898.4" textLength="85.4" clip-path="url(#terminal-line-36)">/rewind</text><text class="terminal-r1" x="158.6" y="898.4" textLength="366" clip-path="url(#terminal-line-36)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"> </text><text class="terminal-r1" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">&#160;</text><text class="terminal-r2" x="73.2" y="898.4" textLength="73.2" clip-path="url(#terminal-line-36)">/theme</text><text class="terminal-r1" x="146.4" y="898.4" textLength="170.8" clip-path="url(#terminal-line-36)">:&#160;Select&#160;theme</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">
</text><text class="terminal-r1" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">&#160;</text><text class="terminal-r2" x="73.2" y="922.8" textLength="61" clip-path="url(#terminal-line-37)">/loop</text><text class="terminal-r1" x="134.2" y="922.8" textLength="427" clip-path="url(#terminal-line-37)">:&#160;Schedule&#160;a&#160;recurring&#160;prompt.&#160;Use&#160;</text><text class="terminal-r2" x="561.2" y="922.8" textLength="305" clip-path="url(#terminal-line-37)">/loop&#160;&lt;interval&gt;&#160;&lt;prompt&gt;</text><text class="terminal-r1" x="866.2" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">,&#160;</text><text class="terminal-r2" x="890.6" y="922.8" textLength="122" clip-path="url(#terminal-line-37)">/loop&#160;list</text><text class="terminal-r1" x="1012.6" y="922.8" textLength="61" clip-path="url(#terminal-line-37)">,&#160;or&#160;</text><text class="terminal-r2" x="1073.6" y="922.8" textLength="256.2" clip-path="url(#terminal-line-37)">/loop&#160;cancel&#160;&lt;id|all&gt;</text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"> </text><text class="terminal-r1" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">&#160;</text><text class="terminal-r2" x="73.2" y="922.8" textLength="109.8" clip-path="url(#terminal-line-37)">/thinking</text><text class="terminal-r1" x="183" y="922.8" textLength="280.6" clip-path="url(#terminal-line-37)">:&#160;Select&#160;thinking&#160;level</text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">
</text><text class="terminal-r1" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"></text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">&#160;</text><text class="terminal-r2" x="73.2" y="947.2" textLength="183" clip-path="url(#terminal-line-38)">/data-retention</text><text class="terminal-r1" x="256.2" y="947.2" textLength="402.6" clip-path="url(#terminal-line-38)">:&#160;Show&#160;data&#160;retention&#160;information</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"> </text><text class="terminal-r1" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"></text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">&#160;</text><text class="terminal-r2" x="73.2" y="947.2" textLength="146.4" clip-path="url(#terminal-line-38)">/unleanstall</text><text class="terminal-r1" x="219.6" y="947.2" textLength="341.6" clip-path="url(#terminal-line-38)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)">
</text><text class="terminal-r1" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"></text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">&#160;</text><text class="terminal-r2" x="73.2" y="971.6" textLength="73.2" clip-path="url(#terminal-line-39)">/theme</text><text class="terminal-r1" x="146.4" y="971.6" textLength="170.8" clip-path="url(#terminal-line-39)">:&#160;Select&#160;theme</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"> </text><text class="terminal-r1" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"></text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">&#160;</text><text class="terminal-r2" x="73.2" y="971.6" textLength="73.2" clip-path="url(#terminal-line-39)">/voice</text><text class="terminal-r1" x="146.4" y="971.6" textLength="317.2" clip-path="url(#terminal-line-39)">:&#160;Configure&#160;voice&#160;settings</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)">
</text><text class="terminal-r1" x="1464" y="996" textLength="12.2" clip-path="url(#terminal-line-40)"> </text><text class="terminal-r1" x="1464" y="996" textLength="12.2" clip-path="url(#terminal-line-40)">
</text><text class="terminal-r1" x="1464" y="1020.4" textLength="12.2" clip-path="url(#terminal-line-41)"> </text><text class="terminal-r1" x="1464" y="1020.4" textLength="12.2" clip-path="url(#terminal-line-41)">
</text><text class="terminal-r1" x="0" y="1044.8" textLength="1464" clip-path="url(#terminal-line-42)">──────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;</text><text class="terminal-r1" x="1464" y="1044.8" textLength="12.2" clip-path="url(#terminal-line-42)"> </text><text class="terminal-r1" x="0" y="1044.8" textLength="1464" clip-path="url(#terminal-line-42)">──────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;</text><text class="terminal-r1" x="1464" y="1044.8" textLength="12.2" clip-path="url(#terminal-line-42)">

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

@ -149,19 +149,6 @@ class TeleportCommandHelpProApp(TeleportCommandHelpSnapshotApp):
) )
class TeleportCommandHelpFreeApp(TeleportCommandHelpSnapshotApp):
def __init__(self):
super().__init__(
FakeWhoAmIGateway(
WhoAmIResponse(
plan_type=WhoAmIPlanType.API,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
)
)
)
def test_snapshot_teleport_status_checking_git(snap_compare: SnapCompare) -> None: def test_snapshot_teleport_status_checking_git(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None: async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2) await pilot.pause(0.2)
@ -302,16 +289,3 @@ def test_snapshot_teleport_command_visible_for_pro_account(
terminal_size=(120, 48), terminal_size=(120, 48),
run_before=run_before, run_before=run_before,
) )
def test_snapshot_teleport_command_hidden_for_non_pro_account(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_teleport.py:TeleportCommandHelpFreeApp",
terminal_size=(120, 48),
run_before=run_before,
)

38
uv.lock generated
View file

@ -1,6 +1,12 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.14' and sys_platform != 'win32'",
]
[options] [options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
@ -504,11 +510,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.13" version = "3.18"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
] ]
[[package]] [[package]]
@ -677,7 +683,7 @@ name = "macholib"
version = "1.16.4" version = "1.16.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "altgraph" }, { name = "altgraph", marker = "sys_platform != 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [ wheels = [
@ -779,7 +785,7 @@ wheels = [
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.27.2" version = "1.28.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@ -797,9 +803,9 @@ dependencies = [
{ name = "typing-inspection" }, { name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" },
] ]
[[package]] [[package]]
@ -825,7 +831,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.17.0" version = "2.17.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },
@ -980,7 +986,7 @@ requires-dist = [
{ name = "httpx", specifier = "==0.28.1" }, { name = "httpx", specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" }, { name = "httpx-sse", specifier = "==0.4.3" },
{ name = "humanize", specifier = "==4.15.0" }, { name = "humanize", specifier = "==4.15.0" },
{ name = "idna", specifier = "==3.13" }, { name = "idna", specifier = "==3.18" },
{ name = "importlib-metadata", specifier = "==8.7.1" }, { name = "importlib-metadata", specifier = "==8.7.1" },
{ name = "jaraco-classes", specifier = "==3.4.0" }, { name = "jaraco-classes", specifier = "==3.4.0" },
{ name = "jaraco-context", specifier = "==6.1.2" }, { name = "jaraco-context", specifier = "==6.1.2" },
@ -995,10 +1001,10 @@ requires-dist = [
{ name = "linkify-it-py", specifier = "==2.1.0" }, { name = "linkify-it-py", specifier = "==2.1.0" },
{ name = "markdown-it-py", specifier = "==4.0.0" }, { name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "markdownify", specifier = "==1.2.2" }, { name = "markdownify", specifier = "==1.2.2" },
{ name = "mcp", specifier = "==1.27.2" }, { name = "mcp", specifier = "==1.28.0" },
{ name = "mdit-py-plugins", specifier = "==0.5.0" }, { name = "mdit-py-plugins", specifier = "==0.5.0" },
{ name = "mdurl", specifier = "==0.1.2" }, { name = "mdurl", specifier = "==0.1.2" },
{ name = "mistralai", specifier = "==2.4.9" }, { name = "mistralai", specifier = "==2.4.11" },
{ name = "more-itertools", specifier = "==11.0.2" }, { name = "more-itertools", specifier = "==11.0.2" },
{ name = "opentelemetry-api", specifier = "==1.39.1" }, { name = "opentelemetry-api", specifier = "==1.39.1" },
{ name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.39.1" }, { name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.39.1" },
@ -1086,7 +1092,7 @@ dev = [
[[package]] [[package]]
name = "mistralai" name = "mistralai"
version = "2.4.9" version = "2.4.11"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "eval-type-backport" }, { name = "eval-type-backport" },
@ -1098,9 +1104,9 @@ dependencies = [
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/f22fa0fac767279df95e0796f37520eae0678a75b62d7bca3e85c46b684b/mistralai-2.4.9.tar.gz", hash = "sha256:9a6465b8bddf825d76cf80ab54d55bb8089fdf5aafc0a7943ae74c825df97939", size = 471000, upload-time = "2026-06-03T13:04:22.565Z" } sdist = { url = "https://files.pythonhosted.org/packages/20/de/afe647785ba603d6473dd407edfda917c31e182522381930b43bb6aa25ef/mistralai-2.4.11.tar.gz", hash = "sha256:bc2bb5cae59ce4b07928f5c824eeb5c9a33f6a2a12049725be14619969036f82", size = 488149, upload-time = "2026-06-16T15:41:18.87Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/bc/977b65cddce185dce0414d14c783040a9f9d647bbfe9b0f5c74ff9262816/mistralai-2.4.9-py3-none-any.whl", hash = "sha256:8e2f8a58697455f2113b4f6d0d1bd28d66d3eb51f94ff24b9d22a21dea531520", size = 1121336, upload-time = "2026-06-03T13:04:20.694Z" }, { url = "https://files.pythonhosted.org/packages/21/6d/0529d895ca381cfa636857259c1fec91d6d7c985b77bf72b9cf5a626d431/mistralai-2.4.11-py3-none-any.whl", hash = "sha256:479f8007ef522e674fbf617e65f8dcdf5d702fc1468c470ae40b1ee0aa0f8136", size = 1170945, upload-time = "2026-06-16T15:41:17.11Z" },
] ]
[[package]] [[package]]
@ -1969,8 +1975,8 @@ name = "secretstorage"
version = "3.5.0" version = "3.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography", marker = "sys_platform != 'win32'" },
{ name = "jeepney" }, { name = "jeepney", marker = "sys_platform != 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [ wheels = [

View file

@ -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.17.0" __version__ = "2.17.1"

View file

@ -73,7 +73,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
from vibe import VIBE_ROOT, __version__ from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer from vibe.acp.acp_logger import acp_message_observer
from vibe.acp.commands import AcpCommandAvailabilityContext, AcpCommandRegistry from vibe.acp.commands import AcpCommandRegistry
from vibe.acp.exceptions import ( from vibe.acp.exceptions import (
CompactionError, CompactionError,
ConfigurationError, ConfigurationError,
@ -224,7 +224,6 @@ WORKSPACE_TRUST_META_KEY = "workspace_trust"
TRUST_GRANT_DECISIONS = { TRUST_GRANT_DECISIONS = {
WorkspaceTrustDecision.TRUST_REPO, WorkspaceTrustDecision.TRUST_REPO,
WorkspaceTrustDecision.TRUST_CWD, WorkspaceTrustDecision.TRUST_CWD,
WorkspaceTrustDecision.TRUST_SESSION,
} }
@ -711,10 +710,8 @@ class VibeAcpAgentLoop(AcpAgent):
self, session_id: str, agent_loop: AgentLoop self, session_id: str, agent_loop: AgentLoop
) -> AcpSessionLoop: ) -> AcpSessionLoop:
command_registry = AcpCommandRegistry( command_registry = AcpCommandRegistry(
availability_context=AcpCommandAvailabilityContext(
vibe_code_enabled=agent_loop.base_config.vibe_code_enabled vibe_code_enabled=agent_loop.base_config.vibe_code_enabled
) )
)
session = AcpSessionLoop( session = AcpSessionLoop(
id=session_id, agent_loop=agent_loop, command_registry=command_registry id=session_id, agent_loop=agent_loop, command_registry=command_registry
) )
@ -824,7 +821,7 @@ class VibeAcpAgentLoop(AcpAgent):
repoRoot=str(prompt.repo_root.resolve()) if prompt.repo_root else None, repoRoot=str(prompt.repo_root.resolve()) if prompt.repo_root else None,
ignoredFiles=self._workspace_trust_ignored_files(prompt), ignoredFiles=self._workspace_trust_ignored_files(prompt),
availableDecisions=available_workspace_trust_decisions( availableDecisions=available_workspace_trust_decisions(
prompt, include_session=True prompt, include_session=False
), ),
) )
@ -873,7 +870,7 @@ class VibeAcpAgentLoop(AcpAgent):
raise InvalidRequestError("No workspace trust decision is available.") raise InvalidRequestError("No workspace trust decision is available.")
available_decisions = available_workspace_trust_decisions( available_decisions = available_workspace_trust_decisions(
prompt, include_session=True prompt, include_session=False
) )
if decision not in available_decisions: if decision not in available_decisions:
raise InvalidRequestError(f"Unsupported trust decision: {decision}") raise InvalidRequestError(f"Unsupported trust decision: {decision}")
@ -914,9 +911,7 @@ class VibeAcpAgentLoop(AcpAgent):
raise InvalidRequestError(str(exc)) from exc raise InvalidRequestError(str(exc)) from exc
if session is not None and request.decision in TRUST_GRANT_DECISIONS: if session is not None and request.decision in TRUST_GRANT_DECISIONS:
os.chdir(cwd) session.spawn(self._reload_trusted_workspace_session(session, cwd))
await self._reload_session_config(session)
await session.command_registry.notify_changed()
return self._workspace_trust_response(cwd).model_dump( return self._workspace_trust_response(cwd).model_dump(
mode="json", by_alias=True mode="json", by_alias=True
@ -1201,6 +1196,7 @@ class VibeAcpAgentLoop(AcpAgent):
) )
) )
commands.sort(key=lambda command: command.name)
await self.client.session_update( await self.client.session_update(
session_id=session.id, update=update_available_commands(commands) session_id=session.id, update=update_available_commands(commands)
) )
@ -1277,10 +1273,7 @@ class VibeAcpAgentLoop(AcpAgent):
return True return True
async def _reload_config(self, session: AcpSessionLoop) -> None: async def _reload_config(self, session: AcpSessionLoop) -> None:
new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths) await self._reload_session_config(session)
self._apply_client_project_name(new_config)
_merge_non_interactive_disabled_tools(new_config)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool: async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
model_aliases = [model.alias for model in session.agent_loop.config.models] model_aliases = [model.alias for model in session.agent_loop.config.models]
@ -2037,20 +2030,25 @@ class VibeAcpAgentLoop(AcpAgent):
self, session: AcpSessionLoop, text_prompt: str, message_id: str self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse: ) -> PromptResponse:
lines = ["### Available Commands", ""] lines = ["### Available Commands", ""]
for cmd in session.command_registry.commands.values(): for cmd in sorted(
session.command_registry.commands.values(), key=lambda command: command.name
):
hint = f" `<{cmd.input_hint}>`" if cmd.input_hint else "" hint = f" `<{cmd.input_hint}>`" if cmd.input_hint else ""
lines.append(f"- `/{cmd.name}`{hint}: {cmd.description}") lines.append(f"- `/{cmd.name}`{hint}: {cmd.description}")
builtin_names = set(session.command_registry.commands) builtin_names = set(session.command_registry.commands)
invocable = { invocable = sorted(
n: s (
for n, s in session.agent_loop.skill_manager.available_skills.items() skill
if s.user_invocable and n not in builtin_names for skill in session.agent_loop.skill_manager.available_skills.values()
} if skill.user_invocable and skill.name not in builtin_names
),
key=lambda skill: skill.name,
)
if invocable: if invocable:
lines.extend(["", "### Available Skills", ""]) lines.extend(["", "### Available Skills", ""])
for name, info in invocable.items(): for skill in invocable:
lines.append(f"- `/{name}`: {info.description}") lines.append(f"- `/{skill.name}`: {skill.description}")
return await self._command_reply(session, "\n".join(lines), message_id) return await self._command_reply(session, "\n".join(lines), message_id)
@ -2186,6 +2184,23 @@ class VibeAcpAgentLoop(AcpAgent):
_merge_non_interactive_disabled_tools(new_config) _merge_non_interactive_disabled_tools(new_config)
await session.agent_loop.reload_with_initial_messages(base_config=new_config) await session.agent_loop.reload_with_initial_messages(base_config=new_config)
async def _reload_trusted_workspace_session(
self, session: AcpSessionLoop, cwd: Path
) -> None:
try:
os.chdir(cwd)
await self._reload_session_config(session)
await session.command_registry.notify_changed()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
"Failed to reload trusted workspace session config: session_id=%s cwd=%s",
session.id,
cwd,
exc_info=exc,
)
async def _handle_reload( async def _handle_reload(
self, session: AcpSessionLoop, text_prompt: str, message_id: str self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse: ) -> PromptResponse:

View file

@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from vibe.acp.commands.registry import AcpCommandAvailabilityContext, AcpCommandRegistry from vibe.acp.commands.registry import AcpCommandRegistry
__all__ = ["AcpCommandAvailabilityContext", "AcpCommandRegistry"] __all__ = ["AcpCommandRegistry"]

View file

@ -6,17 +6,7 @@ from dataclasses import dataclass, field
type OnCommandsChanged = Callable[[], Awaitable[None]] type OnCommandsChanged = Callable[[], Awaitable[None]]
@dataclass(frozen=True) type CommandAvailability = Callable[[bool], bool]
class AcpCommandAvailabilityContext:
"""Context used to decide whether a command should be advertised."""
vibe_code_enabled: bool = False
def is_teleport_available(self) -> bool:
return self.vibe_code_enabled
type CommandAvailability = Callable[[AcpCommandAvailabilityContext], bool]
@dataclass(frozen=True) @dataclass(frozen=True)
@ -34,9 +24,7 @@ class AcpCommand:
class AcpCommandRegistry: class AcpCommandRegistry:
"""Registry of ACP commands. Notifies listeners when commands change.""" """Registry of ACP commands. Notifies listeners when commands change."""
availability_context: AcpCommandAvailabilityContext = field( vibe_code_enabled: bool = False
default_factory=AcpCommandAvailabilityContext
)
_commands: dict[str, AcpCommand] = field(default_factory=dict) _commands: dict[str, AcpCommand] = field(default_factory=dict)
_on_changed: OnCommandsChanged | None = None _on_changed: OnCommandsChanged | None = None
@ -51,7 +39,7 @@ class AcpCommandRegistry:
def _is_available(self, command: AcpCommand) -> bool: def _is_available(self, command: AcpCommand) -> bool:
if command.is_available is None: if command.is_available is None:
return True return True
return command.is_available(self.availability_context) return command.is_available(self.vibe_code_enabled)
def set_on_changed(self, callback: OnCommandsChanged) -> None: def set_on_changed(self, callback: OnCommandsChanged) -> None:
self._on_changed = callback self._on_changed = callback
@ -101,7 +89,7 @@ def _build_commands() -> dict[str, AcpCommand]:
name="teleport", name="teleport",
description="Teleport session to Vibe Code Web", description="Teleport session to Vibe Code Web",
handler="_handle_teleport", handler="_handle_teleport",
is_available=AcpCommandAvailabilityContext.is_teleport_available, is_available=lambda vibe_code_enabled: vibe_code_enabled,
), ),
"proxy-setup": AcpCommand( "proxy-setup": AcpCommand(
name="proxy-setup", name="proxy-setup",

View file

@ -194,6 +194,22 @@ async def handle_teleport_command(
field_meta=_teleport_field_meta("unavailable"), field_meta=_teleport_field_meta("unavailable"),
) )
if not session.agent_loop.config.is_active_model_mistral():
send_teleport_early_failure_telemetry(
session.agent_loop.telemetry_client,
stage="ineligible",
error_class="TeleportIneligibleError",
nb_session_messages=len(session.agent_loop.messages[1:]),
)
return await _teleport_command_reply(
client,
session,
"Teleport requires an active Mistral model. Switch to a Mistral "
"model, then try again.",
message_id,
field_meta=_teleport_field_meta("unavailable"),
)
last_user_message = next( last_user_message = next(
( (
msg msg

View file

@ -4,27 +4,10 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import sys import sys
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
ALT_KEY = "" if sys.platform == "darwin" else "Alt" ALT_KEY = "" if sys.platform == "darwin" else "Alt"
@dataclass(frozen=True) CommandAvailability = Callable[[bool], bool]
class CommandAvailabilityContext:
vibe_code_enabled: bool = False
is_active_model_mistral: bool = False
plan_info: PlanInfo | None = None
def is_teleport_available(self) -> bool:
return (
self.vibe_code_enabled
and self.is_active_model_mistral
and self.plan_info is not None
and self.plan_info.is_teleport_eligible()
)
CommandAvailability = Callable[[CommandAvailabilityContext], bool]
@dataclass @dataclass
@ -40,14 +23,13 @@ class CommandRegistry:
def __init__( def __init__(
self, self,
excluded_commands: list[str] | None = None, excluded_commands: list[str] | None = None,
availability_context: CommandAvailabilityContext | None = None, vibe_code_enabled: bool = False,
) -> None: ) -> None:
if excluded_commands is None: if excluded_commands is None:
excluded_commands = [] excluded_commands = []
self._disabled_commands = set(excluded_commands) self._disabled_commands = set(excluded_commands)
self._availability_context = CommandAvailabilityContext()
self._commands: dict[str, Command] = {} self._commands: dict[str, Command] = {}
self.refresh(availability_context) self.refresh(vibe_code_enabled)
def _build_commands(self) -> dict[str, Command]: def _build_commands(self) -> dict[str, Command]:
return { return {
@ -116,7 +98,7 @@ class CommandRegistry:
aliases=frozenset(["/teleport"]), aliases=frozenset(["/teleport"]),
description="Teleport session to Vibe Code Web", description="Teleport session to Vibe Code Web",
handler="_teleport_command", handler="_teleport_command",
is_available=CommandAvailabilityContext.is_teleport_available, is_available=lambda vibe_code_enabled: vibe_code_enabled,
), ),
"proxy-setup": Command( "proxy-setup": Command(
aliases=frozenset(["/proxy-setup"]), aliases=frozenset(["/proxy-setup"]),
@ -186,12 +168,8 @@ class CommandRegistry:
def commands(self) -> dict[str, Command]: def commands(self) -> dict[str, Command]:
return self._commands return self._commands
def refresh( def refresh(self, vibe_code_enabled: bool = False) -> None:
self, availability_context: CommandAvailabilityContext | None = None self._vibe_code_enabled = vibe_code_enabled
) -> None:
self._availability_context = (
availability_context or CommandAvailabilityContext()
)
self._commands = { self._commands = {
name: command name: command
for name, command in self._build_commands().items() for name, command in self._build_commands().items()
@ -202,7 +180,7 @@ class CommandRegistry:
def _is_command_available(self, command: Command) -> bool: def _is_command_available(self, command: Command) -> bool:
if command.is_available is None: if command.is_available is None:
return True return True
return command.is_available(self._availability_context) return command.is_available(self._vibe_code_enabled)
def _alias_map(self) -> dict[str, str]: def _alias_map(self) -> dict[str, str]:
return { return {
@ -261,7 +239,13 @@ class CommandRegistry:
"", "",
] ]
for cmd in self.commands.values(): for name, cmd in sorted(self.commands.items()):
aliases = ", ".join(f"`{alias}`" for alias in sorted(cmd.aliases)) canonical_alias = f"/{name}"
aliases = ", ".join(
f"`{alias}`"
for alias in sorted(
cmd.aliases, key=lambda alias: (alias != canonical_alias, alias)
)
)
lines.append(f"- {aliases}: {cmd.description}") lines.append(f"- {aliases}: {cmd.description}")
return "\n".join(lines) return "\n".join(lines)

View file

@ -113,20 +113,41 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
return resolve_api_key(api_env_key) return resolve_api_key(api_env_key)
def vibe_api_key_url(vibe_base_url: str = DEFAULT_VIBE_BASE_URL) -> str:
return f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key"
def plan_offer_cta( def plan_offer_cta(
payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL
) -> str | None: ) -> str | None:
if not payload: if not payload:
return return
vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key" key_url = vibe_api_key_url(vibe_base_url)
if payload.prompt_switching_to_pro_plan: if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Vibe Pro API key]({vibe_api_key_url})" return f"### Switch to your [Vibe Pro API key]({key_url})"
if ( if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED} payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_chat_plan() or payload.is_free_chat_plan()
or payload.is_free_mistral_code_plan() or payload.is_free_mistral_code_plan()
): ):
return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({vibe_api_key_url})" return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({key_url})"
def check_teleport_eligibility(
payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL
) -> str | None:
if payload is not None and payload.is_teleport_eligible():
return None
key_url = vibe_api_key_url(vibe_base_url)
if payload is not None and payload.prompt_switching_to_pro_plan:
return (
"Teleport requires a Vibe Pro API key, but the current key is on a "
f"different plan. Switch to your Vibe Pro API key: {key_url}"
)
return (
"Teleport requires a Vibe Pro subscription. Your current API key isn't "
f"eligible. Upgrade to Vibe Pro: {key_url}"
)
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911 def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911

View file

@ -31,7 +31,7 @@ from textual.widgets import Static
from vibe import __version__ as CORE_VERSION from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard
from vibe.cli.commands import CommandAvailabilityContext, CommandRegistry from vibe.cli.commands import CommandRegistry
from vibe.cli.narrator_manager import ( from vibe.cli.narrator_manager import (
NarratorManager, NarratorManager,
NarratorManagerPort, NarratorManagerPort,
@ -40,6 +40,7 @@ from vibe.cli.narrator_manager import (
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import ( from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo, PlanInfo,
check_teleport_eligibility,
decide_plan_offer, decide_plan_offer,
plan_offer_cta, plan_offer_cta,
plan_title, plan_title,
@ -184,6 +185,7 @@ from vibe.core.session.saved_sessions import (
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
from vibe.core.session.title_format import format_session_title from vibe.core.session.title_format import format_session_title
from vibe.core.skills.manager import SkillManager from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.types import TeleportFailureStage
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import ( from vibe.core.teleport.types import (
TeleportCheckingGitEvent, TeleportCheckingGitEvent,
@ -588,20 +590,13 @@ class VibeApp(App): # noqa: PLR0904
def _connectors_enabled(self) -> bool: def _connectors_enabled(self) -> bool:
return self.agent_loop.connector_registry is not None return self.agent_loop.connector_registry is not None
def _get_command_availability_context(self) -> CommandAvailabilityContext:
return CommandAvailabilityContext(
vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled,
is_active_model_mistral=self.config.is_active_model_mistral(),
plan_info=self._plan_info,
)
def _build_command_registry(self) -> CommandRegistry: def _build_command_registry(self) -> CommandRegistry:
return CommandRegistry( return CommandRegistry(
availability_context=self._get_command_availability_context() vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled
) )
def _refresh_command_registry(self) -> None: def _refresh_command_registry(self) -> None:
self.commands.refresh(self._get_command_availability_context()) self.commands.refresh(self.agent_loop.base_config.vibe_code_enabled)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with ChatScroll(id="chat"): with ChatScroll(id="chat"):
@ -1881,29 +1876,54 @@ class VibeApp(App): # noqa: PLR0904
async def _teleport_command(self, **kwargs: Any) -> None: async def _teleport_command(self, **kwargs: Any) -> None:
await self._handle_teleport_command(show_message=False) await self._handle_teleport_command(show_message=False)
def _teleport_unavailable_reason(self) -> str | None:
if not self.config.is_active_model_mistral():
return (
"Teleport requires an active Mistral model. Use /model to switch to "
"a Mistral model, then try again."
)
return check_teleport_eligibility(
self._plan_info, vibe_base_url=self.config.vibe_base_url
)
async def _fail_teleport_early(
self, *, stage: TeleportFailureStage, error_class: str, message: str
) -> None:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage=stage,
error_class=error_class,
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await self._mount_and_scroll(
ErrorMessage(message, collapsed=self._tools_collapsed)
)
async def _handle_teleport_command( async def _handle_teleport_command(
self, value: str | None = None, show_message: bool = True self, value: str | None = None, show_message: bool = True
) -> None: ) -> None:
has_history = any(msg.role != Role.system for msg in self.agent_loop.messages) has_history = any(msg.role != Role.system for msg in self.agent_loop.messages)
if not value:
if show_message: if show_message:
await self._mount_and_scroll(SlashCommandMessage("teleport"))
if not has_history:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="no_history",
error_class="TeleportNoHistoryError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await self._mount_and_scroll( await self._mount_and_scroll(
ErrorMessage( TeleportUserMessage(value) if value else SlashCommandMessage("teleport")
"No conversation history to teleport.",
collapsed=self._tools_collapsed,
) )
if reason := self._teleport_unavailable_reason():
await self._fail_teleport_early(
stage="ineligible",
error_class="TeleportIneligibleError",
message=reason,
) )
return return
elif show_message:
await self._mount_and_scroll(TeleportUserMessage(value)) if not value and not has_history:
await self._fail_teleport_early(
stage="no_history",
error_class="TeleportNoHistoryError",
message="No conversation history to teleport.",
)
return
self.run_worker(self._teleport(value), exclusive=False) self.run_worker(self._teleport(value), exclusive=False)
async def _teleport(self, prompt: str | None = None) -> None: async def _teleport(self, prompt: str | None = None) -> None:

View file

@ -842,9 +842,24 @@ StatusMessage {
color: $warning; color: $warning;
} }
.diff-removed { .diff-removed,
.diff-added,
.diff-context {
width: 100%;
height: auto; height: auto;
.diff-gutter {
width: auto;
height: auto;
}
.diff-body {
width: 1fr;
height: auto;
}
}
.diff-removed {
&:dark { &:dark {
background: $error 10%; background: $error 10%;
} }
@ -859,8 +874,6 @@ StatusMessage {
} }
.diff-added { .diff-added {
height: auto;
&:dark { &:dark {
background: $success 10%; background: $success 10%;
} }
@ -883,10 +896,6 @@ StatusMessage {
} }
} }
.diff-context {
height: auto;
}
.todo-empty, .todo-empty,
.todo-cancelled { .todo-cancelled {
height: auto; height: auto;

View file

@ -5,6 +5,8 @@ import difflib
from pathlib import Path from pathlib import Path
import re import re
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.content import Content from textual.content import Content
from textual.highlight import ( from textual.highlight import (
ANSIDarkHighlightTheme, ANSIDarkHighlightTheme,
@ -12,9 +14,13 @@ from textual.highlight import (
HighlightTheme, HighlightTheme,
highlight as highlight_code, highlight as highlight_code,
) )
from textual.widget import Widget
from textual.widgets import Static 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,
NonSelectableStatic,
)
from vibe.core.utils.io import read_safe from vibe.core.utils.io import read_safe
from vibe.core.utils.text import snippet_start_lines from vibe.core.utils.text import snippet_start_lines
@ -60,6 +66,38 @@ def _highlight_line(code: str, language: str, theme: type[HighlightTheme]) -> Co
return lines[0] if lines else Content(code) return lines[0] if lines else Content(code)
def _gutter_styles(prefix_char: str, *, ansi: bool) -> tuple[str, str]:
if prefix_char == "-":
if ansi:
return f"bold {_REMOVED_STYLE}", f"bold {_REMOVED_STYLE}"
return _REMOVED_STYLE, _DIM_MUTED_STYLE
if prefix_char == "+":
sign_style = _ADDED_STYLE
return sign_style, _ADDED_STYLE if ansi else _DIM_MUTED_STYLE
return _MUTED_STYLE, _DIM_MUTED_STYLE
def _build_diff_gutter(prefix_char: str, lineno: int | None, *, ansi: bool) -> Content:
sign_style, lineno_style = _gutter_styles(prefix_char, ansi=ansi)
lineno_str = f"{lineno:>4} " if lineno is not None else ""
prefix = f"{prefix_char} "
return Content.styled(lineno_str, lineno_style) + Content.styled(prefix, sign_style)
def _build_diff_body(
code: str,
prefix_char: str,
language: str,
*,
ansi: bool,
theme: type[HighlightTheme],
) -> Content:
body = _highlight_line(code, language, theme)
if prefix_char == "-" and ansi:
body = body.stylize("dim")
return body
def _build_diff_line( def _build_diff_line(
code: str, code: str,
prefix_char: str, prefix_char: str,
@ -69,31 +107,23 @@ def _build_diff_line(
ansi: bool, ansi: bool,
theme: type[HighlightTheme], theme: type[HighlightTheme],
) -> Content: ) -> Content:
# ANSI themes lack row backgrounds; the gutter carries the diff color instead. return _build_diff_gutter(prefix_char, lineno, ansi=ansi) + _build_diff_body(
body = _highlight_line(code, language, theme) code, prefix_char, language, ansi=ansi, theme=theme
if prefix_char == "-":
if ansi:
sign_style = lineno_style = f"bold {_REMOVED_STYLE}"
body = body.stylize("dim")
else:
sign_style, lineno_style = _REMOVED_STYLE, _DIM_MUTED_STYLE
elif prefix_char == "+":
sign_style = _ADDED_STYLE
lineno_style = _ADDED_STYLE if ansi else _DIM_MUTED_STYLE
else:
sign_style, lineno_style = _MUTED_STYLE, _DIM_MUTED_STYLE
lineno_str = f"{lineno:>4} " if lineno is not None else ""
prefix = f"{prefix_char} "
return (
Content.styled(lineno_str, lineno_style)
+ Content.styled(prefix, sign_style)
+ body
) )
class _DiffRow(Horizontal):
def __init__(self, gutter: Content, body: Content, *, classes: str) -> None:
self._gutter = gutter
self._body = body
self.plain = gutter.plain + body.plain
super().__init__(classes=classes)
def compose(self) -> ComposeResult:
yield NonSelectableStatic(self._gutter, classes="diff-gutter")
yield Static(self._body, classes="diff-body")
def render_edit_diff( def render_edit_diff(
old_string: str, old_string: str,
new_string: str, new_string: str,
@ -102,7 +132,7 @@ def render_edit_diff(
*, *,
ansi: bool, ansi: bool,
dark: bool, dark: bool,
) -> list[Static]: ) -> list[Widget]:
theme = _pick_theme(ansi=ansi, dark=dark) theme = _pick_theme(ansi=ansi, dark=dark)
diff_lines = list( diff_lines = list(
difflib.unified_diff( difflib.unified_diff(
@ -119,7 +149,7 @@ def render_edit_diff(
# replace_all repeats the same change at each match; render one block per # replace_all repeats the same change at each match; render one block per
# occurrence, anchored at its own line number, with a gap in between. # occurrence, anchored at its own line number, with a gap in between.
widgets: list[Static] = [] widgets: list[Widget] = []
for index, start_line in enumerate(start_lines): for index, start_line in enumerate(start_lines):
if index > 0: if index > 0:
widgets.append(NoMarkupStatic("", classes="diff-gap")) widgets.append(NoMarkupStatic("", classes="diff-gap"))
@ -136,10 +166,10 @@ def _render_occurrence(
*, *,
ansi: bool, ansi: bool,
theme: type[HighlightTheme], theme: type[HighlightTheme],
) -> list[Static]: ) -> list[Widget]:
offset = (start_line - 1) if start_line else 0 offset = (start_line - 1) if start_line else 0
old_lineno = new_lineno = 0 # overwritten by the first @@ header old_lineno = new_lineno = 0 # overwritten by the first @@ header
widgets: list[Static] = [] widgets: list[Widget] = []
first_hunk = True first_hunk = True
for line in diff_lines: for line in diff_lines:
@ -167,20 +197,18 @@ def _render_occurrence(
old_lineno += 1 old_lineno += 1
new_lineno += 1 new_lineno += 1
content = _build_diff_line( lineno_val = lineno if start_line else None
code, gutter = _build_diff_gutter(prefix_char, lineno_val, ansi=ansi)
prefix_char, body = _build_diff_body(code, prefix_char, language, ansi=ansi, theme=theme)
lineno if start_line else None,
language, widgets.append(
ansi=ansi, _DiffRow(gutter, body, classes=_DIFF_CSS_CLASS_BY_PREFIX[prefix_char])
theme=theme,
) )
widgets.append(Static(content, classes=_DIFF_CSS_CLASS_BY_PREFIX[prefix_char]))
return widgets return widgets
def diff_border_colors(rows: Iterable[Static]) -> dict[int, str]: def diff_border_colors(rows: Iterable[Widget]) -> dict[int, str]:
return { return {
i: color i: color
for i, row in enumerate(rows) for i, row in enumerate(rows)

View file

@ -245,7 +245,7 @@ class EditResultWidget(ToolResultWidget[EditResult]):
if not self.result: if not self.result:
yield from self._footer() yield from self._footer()
return return
rows: list[Static] = [ rows: list[Widget] = [
NoMarkupStatic(f"{w}", classes="tool-result-warning") NoMarkupStatic(f"{w}", classes="tool-result-warning")
for w in self.warnings for w in self.warnings
] ]

View file

@ -785,6 +785,15 @@ class VibeConfig(BaseSettings):
" is set. Supports glob patterns and regex with 're:' prefix." " is set. Supports glob patterns and regex with 're:' prefix."
), ),
) )
experimental_enable_registry_skills: bool = Field(
default=False,
description=(
"Experimental: pull workspace skills from the Mistral AI Registry"
" (api.mistral.ai) and make them available alongside local skills."
" Requires a Mistral provider and API key. Local and builtin skills take"
" precedence on name collision."
),
)
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_prefix="VIBE_", case_sensitive=False, extra="ignore" env_prefix="VIBE_", case_sensitive=False, extra="ignore"

View file

@ -177,6 +177,15 @@ class VibeConfigSchema(ConfigSchema):
" is set. Supports glob patterns and regex with 're:' prefix." " is set. Supports glob patterns and regex with 're:' prefix."
), ),
) )
experimental_enable_registry_skills: Annotated[bool, WithReplaceMerge()] = Field(
default=False,
description=(
"Experimental: pull workspace skills from the Mistral AI Registry"
" (api.mistral.ai) and make them available alongside local skills."
" Requires a Mistral provider and API key. Local and builtin skills take"
" precedence on name collision."
),
)
# Internal # Internal
vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True

View file

@ -1,11 +1,39 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum, auto
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
class SkillSource(StrEnum):
BUILTIN = auto()
LOCAL = auto()
REGISTRY = auto()
class SkillScope(StrEnum):
BUILTIN = auto()
GLOBAL = auto()
PROJECT = auto()
# The registry's reserved alias that always resolves to the newest version,
# server-side (see ai-registry versioning.RESERVED_ALIAS). A pin set to this
# alias auto-resolves to latest and never reports an "update available".
REGISTRY_LATEST_ALIAS = "latest"
class RegistryRef(BaseModel):
skill_id: str
# The concrete, materialized version currently on disk (for display/loading).
version: int
# The pinned alias (e.g. "latest") when this is an alias pin; None when the
# pin is frozen to a specific version number.
alias: str | None = None
class SkillMetadata(BaseModel): class SkillMetadata(BaseModel):
model_config = {"populate_by_name": True} model_config = {"populate_by_name": True}
@ -72,6 +100,9 @@ class SkillInfo(BaseModel):
user_invocable: bool = True user_invocable: bool = True
skill_path: Path | None = None skill_path: Path | None = None
prompt: str prompt: str
source: SkillSource = SkillSource.LOCAL
scope: SkillScope = SkillScope.GLOBAL
registry: RegistryRef | None = None
model_config = {"arbitrary_types_allowed": True} model_config = {"arbitrary_types_allowed": True}
@ -83,7 +114,14 @@ class SkillInfo(BaseModel):
@classmethod @classmethod
def from_metadata( def from_metadata(
cls, meta: SkillMetadata, skill_path: Path, prompt: str cls,
meta: SkillMetadata,
skill_path: Path,
prompt: str,
*,
source: SkillSource = SkillSource.LOCAL,
scope: SkillScope = SkillScope.GLOBAL,
registry: RegistryRef | None = None,
) -> SkillInfo: ) -> SkillInfo:
return cls( return cls(
name=meta.name, name=meta.name,
@ -95,6 +133,9 @@ class SkillInfo(BaseModel):
user_invocable=meta.user_invocable, user_invocable=meta.user_invocable,
skill_path=skill_path.resolve(), skill_path=skill_path.resolve(),
prompt=prompt, prompt=prompt,
source=source,
scope=scope,
registry=registry,
) )

View file

View file

@ -0,0 +1,135 @@
from __future__ import annotations
from types import TracebackType
from typing import Any
import httpx
from pydantic import BaseModel, ValidationError
from vibe.core.skills.registry.models import (
ListSkillsResponse,
ListVersionsResponse,
RegistrySkillItem,
SkillVersionInfo,
)
from vibe.core.utils.http import build_ssl_context
_MAX_PAGES = 50
class RegistrySkillsError(Exception):
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def _parse[M: BaseModel](model: type[M], payload: Any, what: str) -> M:
"""Validate a registry payload, normalizing failures to RegistrySkillsError.
Keeps every response-parsing path consistent: a malformed 200 surfaces as a
registry error, not a bare ValidationError that callers wouldn't catch.
"""
try:
return model.model_validate(payload)
except ValidationError as exc:
raise RegistrySkillsError(f"invalid {what} response") from exc
class RegistrySkillsClient:
_CATALOG_FIELDS = (
"skillId,skill.skillName,skill.skillDescription,attributes,metadata,version"
)
def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0) -> None:
self._skills_url = f"{base_url.rstrip('/')}/skills"
self._api_key = api_key
self._timeout = timeout
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> RegistrySkillsClient:
self._client = httpx.AsyncClient(
timeout=self._timeout,
headers={
"Authorization": f"Bearer {self._api_key}",
"Accept": "application/json",
},
verify=build_ssl_context(),
)
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
async def list_catalog(self, *, page_size: int) -> list[RegistrySkillItem]:
return await self._list(page_size=page_size, fields=self._CATALOG_FIELDS)
async def list_versions(self, skill_id: str) -> list[SkillVersionInfo]:
payload = await self._get_json(f"{self._skills_url}/{skill_id}/versions", {})
parsed = _parse(ListVersionsResponse, payload, "versions")
infos = [row.to_info() for row in parsed.items]
infos.sort(key=lambda v: v.version, reverse=True)
return infos
async def get_skill(
self, skill_id: str, *, version: int | None = None, alias: str | None = None
) -> RegistrySkillItem:
# version_selector is a oneof: version XOR alias (omit both = latest).
params: dict[str, Any] = {}
if version is not None:
params["version"] = version
elif alias is not None:
params["alias"] = alias
url = f"{self._skills_url}/{skill_id}"
payload = await self._get_json(url, params)
return _parse(RegistrySkillItem, payload, "skill")
async def _list(
self, *, page_size: int, fields: str | None
) -> list[RegistrySkillItem]:
items: list[RegistrySkillItem] = []
page_token = ""
for _ in range(_MAX_PAGES):
params: dict[str, Any] = {"pageSize": page_size}
if fields:
params["fields"] = fields
if page_token:
params["pageToken"] = page_token
page = _parse(
ListSkillsResponse,
await self._get_json(self._skills_url, params),
"catalog",
)
items.extend(page.data)
if not page.next_page_token:
return items
page_token = page.next_page_token
# Cap reached with pages still remaining: fail loudly rather than return
# a silently-truncated catalog that a caller would treat as complete.
raise RegistrySkillsError("catalog exceeds the maximum number of pages")
async def _get_json(self, url: str, params: dict[str, Any]) -> Any:
if self._client is None:
raise RegistrySkillsError("client used outside of an async context")
try:
response = await self._client.get(url, params=params)
except httpx.RequestError as exc:
raise RegistrySkillsError(f"request failed: {exc}") from exc
if response.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
raise RegistrySkillsError(f"unauthorized ({response.status_code})")
if response.status_code == httpx.codes.NOT_FOUND:
raise RegistrySkillsError("not found (404)")
if not response.is_success:
raise RegistrySkillsError(f"unexpected status {response.status_code}")
try:
return response.json()
except ValueError as exc:
raise RegistrySkillsError("response was not valid JSON") from exc

View file

@ -0,0 +1,187 @@
from __future__ import annotations
import base64
import binascii
import re
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
_NON_NAME_CHARS = re.compile(r"[^a-z0-9]+")
# Mirror SkillMetadata.name's max_length so a sanitized name always validates.
_MAX_NAME_LEN = 64
def sanitize_skill_name(raw: str) -> str | None:
collapsed = _NON_NAME_CHARS.sub("-", raw.strip().lower()).strip("-")
collapsed = collapsed[:_MAX_NAME_LEN].rstrip("-")
return collapsed or None
class RegistryAssetContent(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
text_content: str | None = Field(
default=None, validation_alias=AliasChoices("textContent", "text_content")
)
raw_content: str | None = Field(
default=None, validation_alias=AliasChoices("rawContent", "raw_content")
)
is_executable: bool = Field(
default=False, validation_alias=AliasChoices("isExecutable", "is_executable")
)
def to_bytes(self) -> bytes | None:
if self.text_content is not None:
return self.text_content.encode("utf-8")
if self.raw_content is not None:
try:
return base64.b64decode(self.raw_content, validate=True)
except (binascii.Error, ValueError):
return None
return None
class RegistrySkillPayload(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
skill_name: str = Field(
default="", validation_alias=AliasChoices("skillName", "skill_name")
)
skill_description: str = Field(
default="",
validation_alias=AliasChoices("skillDescription", "skill_description"),
)
skill_body: str = Field(
default="", validation_alias=AliasChoices("skillBody", "skill_body")
)
skill_assets: dict[str, RegistryAssetContent] = Field(
default_factory=dict,
validation_alias=AliasChoices("skillAssets", "skill_assets"),
)
class RegistryAttributes(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
title: str = ""
description: str | None = None
class RegistryMetadata(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
name: str = ""
latest_version: int = Field(
default=0, validation_alias=AliasChoices("latestVersion", "latest_version")
)
sharing_scope: str = Field(
default="", validation_alias=AliasChoices("sharingScope", "sharing_scope")
)
created_at: str = Field(
default="", validation_alias=AliasChoices("createdAt", "created_at")
)
last_modified_at: str = Field(
default="", validation_alias=AliasChoices("lastModifiedAt", "last_modified_at")
)
created_by: str = Field(
default="", validation_alias=AliasChoices("createdBy", "created_by")
)
class RegistryVersionMetadata(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
created_at: str = Field(
default="", validation_alias=AliasChoices("createdAt", "created_at")
)
class RegistryVersionAttributes(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
notes: str = ""
aliases: list[str] = Field(default_factory=list)
class RegistrySkillItem(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
skill_id: str = Field(
default="", validation_alias=AliasChoices("skillId", "skill_id")
)
skill: RegistrySkillPayload = Field(default_factory=RegistrySkillPayload)
attributes: RegistryAttributes = Field(default_factory=RegistryAttributes)
metadata: RegistryMetadata = Field(default_factory=RegistryMetadata)
version: int = 0
version_metadata: RegistryVersionMetadata = Field(
default_factory=RegistryVersionMetadata,
validation_alias=AliasChoices("versionMetadata", "version_metadata"),
)
version_attributes: RegistryVersionAttributes = Field(
default_factory=RegistryVersionAttributes,
validation_alias=AliasChoices("versionAttributes", "version_attributes"),
)
@property
def resolved_name(self) -> str | None:
for candidate in (
self.metadata.name,
self.skill.skill_name,
self.attributes.title,
):
if (name := sanitize_skill_name(candidate)) is not None:
return name
if self.skill_id:
# Run the id-based fallback through the same sanitizer so it obeys
# the charset / length / hyphen rules SkillMetadata.name enforces.
# The full id (not a prefix) keeps unnamed skills collision-free.
return sanitize_skill_name(f"skill-{self.skill_id.replace('-', '')}")
return None
@property
def resolved_description(self) -> str:
if self.skill.skill_description.strip():
return self.skill.skill_description.strip()
if self.attributes.description and self.attributes.description.strip():
return self.attributes.description.strip()
return ""
class ListSkillsResponse(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
data: list[RegistrySkillItem] = Field(default_factory=list)
next_page_token: str = Field(
default="", validation_alias=AliasChoices("nextPageToken", "next_page_token")
)
class SkillVersionInfo(BaseModel):
"""One row from the versions endpoint: a concrete version and its author
aliases (e.g. 'main', 'stable'). The reserved 'latest' alias is dynamic and
not part of this list.
"""
version: int
aliases: list[str] = Field(default_factory=list)
class RegistryVersionRow(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
version: int
version_attributes: RegistryVersionAttributes = Field(
default_factory=RegistryVersionAttributes,
validation_alias=AliasChoices("versionAttributes", "version_attributes"),
)
def to_info(self) -> SkillVersionInfo:
return SkillVersionInfo(
version=self.version, aliases=list(self.version_attributes.aliases)
)
class ListVersionsResponse(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
items: list[RegistryVersionRow] = Field(default_factory=list)

View file

@ -60,7 +60,7 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata):
TeleportFailureStage = Literal[ TeleportFailureStage = Literal[
"no_history", "git_check", "push", "workflow_start", "cancelled" "no_history", "ineligible", "git_check", "push", "workflow_start", "cancelled"
] ]