diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml similarity index 85% rename from .github/ISSUE_TEMPLATE/feature-request.yml rename to .github/DISCUSSION_TEMPLATE/ideas.yml index 42df0e6..69a8367 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -1,7 +1,5 @@ -name: Feature Request -description: Pitch an improvement for Mistral Vibe -title: "feat: " -labels: ["enhancement"] +title: "[Feature Request]: " +labels: [] body: - type: markdown attributes: @@ -37,7 +35,7 @@ body: attributes: label: Proposed solution description: > - Sketch the feature you’d like to see. Code snippets welcome. + Sketch the feature you'd like to see. Code snippets welcome. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 072bd95..60a498b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Feature request + url: https://github.com/mistralai/mistral-vibe/discussions/new?category=ideas + about: Suggest a new feature or improvement via GitHub Discussions - name: Ask a question url: https://discord.com/channels/1144547040454508606/1447989080720670915 about: Join Mistral AI Discord server for support and discussions diff --git a/.typos.toml b/.typos.toml index bb244b8..0886bbe 100644 --- a/.typos.toml +++ b/.typos.toml @@ -4,3 +4,4 @@ extend-ignore-re = ["(?m)^.*(#|//)\\s*typos:disable-line$", "datas"] [default.extend-words] iterm = "iterm" ITERM = "ITERM" +asend = "asend" diff --git a/.vscode/launch.json b/.vscode/launch.json index 2993318..ce1d0d5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.0.2", + "version": "2.1.0", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index 363d32b..5249d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2026-02-11 + +### Added + +- Incremental load of long sessions: windowing (20 messages), "Load more" to fetch older messages, scroll to bottom when resuming +- ACP support for thinking (agent-client-protocol 0.8.0) +- Support for FIFO path for env file + +### Changed + +- **UI redesign**: new look and layout for the CLI +- Textual UI optimizations: ChatScroll to reduce style recalculations, VerticalGroup for messages, stream layout for streaming blocks, cached DOM queries +- Bumped agent-client-protocol to 0.8.0 +- Use UTC date for timestamps +- Clipboard behavior improvements +- Docs updated for GitHub discussions +- Made the Upgrade to Pro banner less prominent + +### Fixed + +- Fixed inaccurate token count in UI in some cases +- Fixed agent prompt overrides being ignored +- Terminal setup: avoid overwriting Wezterm config + +### Removed + +- Legacy terminal theme module and agent indicator widget +- Standalone onboarding theme selection screen (replaced by redesign) + + ## [2.0.2] - 2026-01-30 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dde90ba..c4ccc53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,11 +31,12 @@ If you encounter a bug, please open an issue with the following information: ### Feature Requests and Feedback -We'd love to hear your ideas! When submitting feedback or feature requests: +We'd love to hear your ideas! When submitting feedback or feature request discussions: -1. **Clear Description**: Explain what you'd like to see or improve -2. **Use Case**: Describe your use case and why this would be valuable -3. **Alternatives**: If applicable, mention any alternatives you've considered +1. **Avoid duplicates**: Check opened discussions before creating a new one +2. **Clear Description**: Explain what you'd like to see or improve +3. **Use Case**: Describe your use case and why this would be valuable +4. **Alternatives**: If applicable, mention any alternatives you've considered ## Development Setup diff --git a/README.md b/README.md index 6967706..19ec8cf 100644 --- a/README.md +++ b/README.md @@ -593,7 +593,7 @@ Mistral Vibe can be used in text editors and IDEs that support [Agent Client Pro ## Resources - [CHANGELOG](CHANGELOG.md) - See what's new in each version -- [CONTRIBUTING](CONTRIBUTING.md) - Guidelines for feedback and bug reports +- [CONTRIBUTING](CONTRIBUTING.md) - Guidelines for feature requests, feedback and bug reports ## License diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 473a2e5..a483c9f 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.0.2" +version = "2.1.0" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-darwin-aarch64-2.0.2.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-darwin-aarch64-2.1.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-darwin-x86_64-2.0.2.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-darwin-x86_64-2.1.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-linux-aarch64-2.0.2.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-aarch64-2.1.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-linux-x86_64-2.0.2.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-x86_64-2.1.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-windows-aarch64-2.0.2.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-aarch64-2.1.0.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.0.2/vibe-acp-windows-x86_64-2.0.2.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-x86_64-2.1.0.zip" cmd = "./vibe-acp.exe" diff --git a/docs/README.md b/docs/README.md index a4a025d..6805160 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Mistral Vibe Documentation -Welcome to the Mistral Vibe documentation! For basic setup, see the [main README](https://github.com/mistral-vibe/mistral-vibe#readme). +Welcome to the Mistral Vibe documentation! For basic setup, see the [main README](https://github.com/mistralai/mistral-vibe#readme). ## Available Documentation diff --git a/pyproject.toml b/pyproject.toml index 043ce92..72ca01d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.0.2" +version = "2.1.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -27,7 +27,7 @@ classifiers = [ "Topic :: Utilities", ] dependencies = [ - "agent-client-protocol==0.7.1", + "agent-client-protocol==0.8.0", "anyio>=4.12.0", "httpx>=0.28.1", "mcp>=1.14.0", @@ -46,6 +46,11 @@ dependencies = [ "textual-speedups>=0.2.1", "tree-sitter>=0.25.2", "tree-sitter-bash>=0.25.1", + "keyring>=25.6.0", + "cryptography>=44.0.0", + "zstandard>=0.25.0", + "gitpython>=3.1.46", + "giturlparse>=0.14.0", ] [project.urls] @@ -134,6 +139,8 @@ ignore = ["D203", "D205", "D213", "ANN401", "PLR6301"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["ANN", "PLR"] +# Textual parent classes don't use ClassVar, so we can't either without breaking pyright +"vibe/cli/textual_ui/ansi_markdown.py" = ["RUF012"] [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" diff --git a/scripts/install.sh b/scripts/install.sh index ae979cd..0607cd8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -78,6 +78,15 @@ function install_uv() { fi } +function check_vibe_installed() { + if command -v vibe &> /dev/null; then + info "vibe is already installed" + VIBE_INSTALLED=true + else + VIBE_INSTALLED=false + fi +} + function install_vibe() { info "Installing mistral-vibe from GitHub repository using uv..." uv tool install mistral-vibe @@ -85,6 +94,13 @@ function install_vibe() { success "Mistral Vibe installed successfully! (commands: vibe, vibe-acp)" } +function update_vibe() { + info "Updating mistral-vibe from GitHub repository using uv..." + uv tool upgrade mistral-vibe + + success "Mistral Vibe updated successfully!" +} + function main() { echo echo "██████████████████░░" @@ -108,7 +124,13 @@ function main() { install_uv fi - install_vibe + check_vibe_installed + + if [[ "$VIBE_INSTALLED" == "false" ]]; then + install_vibe + else + update_vibe + fi if command -v vibe &> /dev/null; then success "Installation completed successfully!" diff --git a/tests/acp/test_agent_thought.py b/tests/acp/test_agent_thought.py new file mode 100644 index 0000000..41aac43 --- /dev/null +++ b/tests/acp/test_agent_thought.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from acp.schema import AgentThoughtChunk, TextContentBlock +import pytest + +from tests.acp.conftest import _create_acp_agent +from tests.conftest import build_test_vibe_config +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.core.agent_loop import AgentLoop +from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role + + +def _create_backend_with_reasoning( + reasoning_content: str, content: str = "Hi" +) -> FakeBackend: + return FakeBackend( + LLMChunk( + message=LLMMessage( + role=Role.assistant, + content=content, + reasoning_content=reasoning_content, + ), + usage=LLMUsage(prompt_tokens=1, completion_tokens=1), + ) + ) + + +@pytest.fixture +def backend_with_reasoning() -> FakeBackend: + return _create_backend_with_reasoning("Let me think about this...") + + +@pytest.fixture +def acp_agent_loop_with_reasoning( + backend_with_reasoning: FakeBackend, +) -> VibeAcpAgentLoop: + config = build_test_vibe_config(active_model="devstral-latest") + + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **{**kwargs, "backend": backend_with_reasoning}) + self._base_config = config + self.agent_manager.invalidate_config() + + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start() + return _create_acp_agent() + + +class TestACPAgentThought: + @pytest.mark.asyncio + async def test_prompt_with_reasoning_emits_agent_thought_chunk( + self, acp_agent_loop_with_reasoning: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop_with_reasoning.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + await acp_agent_loop_with_reasoning.prompt( + session_id=session_id, + prompt=[TextContentBlock(type="text", text="Just say hi")], + ) + + fake_client: FakeClient = acp_agent_loop_with_reasoning.client # type: ignore + thought_updates = [ + update + for update in fake_client._session_updates + if isinstance(update.update, AgentThoughtChunk) + ] + + assert len(thought_updates) == 1 + thought_chunk = thought_updates[0].update + assert thought_chunk.session_update == "agent_thought_chunk" + assert thought_chunk.content is not None + assert isinstance(thought_chunk.content, TextContentBlock) + assert thought_chunk.content.text == "Let me think about this..." + + @pytest.mark.asyncio + async def test_prompt_without_reasoning_does_not_emit_agent_thought_chunk( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + await acp_agent_loop.prompt( + session_id=session_id, + prompt=[TextContentBlock(type="text", text="Just say hi")], + ) + + fake_client: FakeClient = acp_agent_loop.client # type: ignore + thought_updates = [ + update + for update in fake_client._session_updates + if isinstance(update.update, AgentThoughtChunk) + ] + + assert len(thought_updates) == 0 + + @pytest.mark.asyncio + async def test_agent_thought_chunk_contains_text_content_block( + self, acp_agent_loop_with_reasoning: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop_with_reasoning.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + await acp_agent_loop_with_reasoning.prompt( + session_id=session_id, prompt=[TextContentBlock(type="text", text="Hello")] + ) + + fake_client: FakeClient = acp_agent_loop_with_reasoning.client # type: ignore + thought_updates = [ + update + for update in fake_client._session_updates + if isinstance(update.update, AgentThoughtChunk) + ] + + assert len(thought_updates) == 1 + thought_chunk = thought_updates[0].update + assert thought_chunk.content.type == "text" + + @pytest.mark.asyncio + async def test_agent_thought_chunk_contains_message_id( + self, acp_agent_loop_with_reasoning: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop_with_reasoning.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + await acp_agent_loop_with_reasoning.prompt( + session_id=session_id, prompt=[TextContentBlock(type="text", text="Hello")] + ) + + fake_client: FakeClient = acp_agent_loop_with_reasoning.client # type: ignore + thought_updates = [ + update + for update in fake_client._session_updates + if isinstance(update.update, AgentThoughtChunk) + ] + + assert len(thought_updates) == 1 + thought_chunk = thought_updates[0].update + assert thought_chunk.field_meta is not None + assert "messageId" in thought_chunk.field_meta + assert thought_chunk.field_meta["messageId"] is not None diff --git a/tests/acp/test_bash.py b/tests/acp/test_bash.py index 3b3de6e..68f3245 100644 --- a/tests/acp/test_bash.py +++ b/tests/acp/test_bash.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from acp import CreateTerminalResponse from acp.schema import EnvVariable, TerminalOutputResponse, WaitForTerminalExitResponse import pytest @@ -58,7 +59,7 @@ class MockClient: env: list | None = None, output_byte_limit: int | None = None, **kwargs, - ) -> MockTerminalHandle: + ) -> CreateTerminalResponse: self._create_terminal_called = True self._last_create_params = { "command": command, @@ -70,7 +71,7 @@ class MockClient: } if self._create_terminal_error: raise self._create_terminal_error - return self._terminal_handle + return CreateTerminalResponse(terminal_id=self._terminal_handle.id) async def terminal_output( self, session_id: str, terminal_id: str, **kwargs diff --git a/tests/acp/test_compact_session_updates.py b/tests/acp/test_compact_session_updates.py index ff830e9..a48df72 100644 --- a/tests/acp/test_compact_session_updates.py +++ b/tests/acp/test_compact_session_updates.py @@ -7,11 +7,11 @@ from unittest.mock import patch from acp.schema import TextContentBlock, ToolCallProgress, ToolCallStart import pytest +from tests.conftest import build_test_vibe_config from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_client import FakeClient from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig @pytest.fixture @@ -19,10 +19,7 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop: class PatchedAgent(AgentLoop): def __init__(self, *args, **kwargs) -> None: # Force our config with auto_compact_threshold=1 - kwargs["config"] = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - auto_compact_threshold=1, - ) + kwargs["config"] = build_test_vibe_config(auto_compact_threshold=1) super().__init__(*args, **kwargs, backend=backend) patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start() diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 6900f11..d5a7d51 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -25,7 +25,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.2" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0" ) assert response.auth_methods == [] @@ -48,7 +48,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.2" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0" ) assert response.auth_methods is not None diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index 2ba0d45..7791815 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -6,15 +6,16 @@ from unittest.mock import patch import pytest from tests.acp.conftest import _create_acp_agent +from tests.conftest import build_test_vibe_config from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName -from vibe.core.config import ModelConfig, VibeConfig +from vibe.core.config import ModelConfig @pytest.fixture def acp_agent_loop(backend) -> VibeAcpAgentLoop: - config = VibeConfig( + config = build_test_vibe_config( active_model="devstral-latest", models=[ ModelConfig( diff --git a/tests/acp/test_set_model.py b/tests/acp/test_set_model.py index ef51871..4967c08 100644 --- a/tests/acp/test_set_model.py +++ b/tests/acp/test_set_model.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from tests.acp.conftest import _create_acp_agent +from tests.conftest import build_test_vibe_config from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop from vibe.core.config import ModelConfig, VibeConfig @@ -14,7 +15,7 @@ from vibe.core.types import LLMMessage, Role @pytest.fixture def acp_agent_loop(backend) -> VibeAcpAgentLoop: - config = VibeConfig( + config = build_test_vibe_config( active_model="devstral-latest", models=[ ModelConfig( diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py index 3e751c9..be779c9 100644 --- a/tests/autocompletion/test_ui_chat_autocompletion.py +++ b/tests/autocompletion/test_ui_chat_autocompletion.py @@ -10,19 +10,6 @@ from textual.widgets import Markdown from vibe.cli.textual_ui.app import VibeApp from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig - - -@pytest.fixture -def vibe_config() -> VibeConfig: - return VibeConfig(session_logging=SessionLoggingConfig(enabled=False)) - - -@pytest.fixture -def vibe_app(vibe_config: VibeConfig) -> VibeApp: - agent_loop = AgentLoop(vibe_config) - return VibeApp(agent_loop=agent_loop) @pytest.mark.asyncio diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index e5b3f55..772de89 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -1,6 +1,8 @@ from __future__ import annotations +from collections.abc import Generator import logging +from os import environ import pytest @@ -9,8 +11,23 @@ from vibe.cli.plan_offer.decide_plan_offer import ( PlanOfferAction, PlanType, decide_plan_offer, + resolve_api_key_for_plan, ) from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse +from vibe.core.config import Backend, ProviderConfig + + +@pytest.fixture +def mistral_api_key_env() -> Generator[str, None, None]: + original_value = environ.get("MISTRAL_API_KEY") + test_api_key = "test_mistral_api_key" + environ["MISTRAL_API_KEY"] = test_api_key + yield test_api_key + + if original_value is not None: + environ["MISTRAL_API_KEY"] = original_value + else: + del environ["MISTRAL_API_KEY"] @pytest.mark.asyncio @@ -115,3 +132,51 @@ async def test_proposes_none_and_logs_warning_when_gateway_error_occurs( assert plan_type is PlanType.UNKNOWN assert gateway.calls == ["api-key"] assert "Failed to fetch plan status." in caplog.text + + +def test_resolve_api_key_for_plan_with_mistral_backend( + mistral_api_key_env: str, +) -> None: + test_api_key = mistral_api_key_env + + provider = ProviderConfig( + name="test_mistral", + api_base="https://api.mistral.ai", + backend=Backend.MISTRAL, + api_key_env_var="MISTRAL_API_KEY", + ) + + result = resolve_api_key_for_plan(provider) + assert result == test_api_key + + +def test_resolve_api_key_for_plan_with_non_mistral_backend( + mistral_api_key_env: str, +) -> None: + provider = ProviderConfig( + name="test_generic", + api_base="https://api.generic.ai", + backend=Backend.GENERIC, + api_key_env_var="GENERIC_API_KEY", + ) + + result = resolve_api_key_for_plan(provider) + assert result == mistral_api_key_env + + +def test_resolve_api_key_for_plan_with_missing_env_var() -> None: + previous_api_key = environ["MISTRAL_API_KEY"] + del environ["MISTRAL_API_KEY"] + + provider = ProviderConfig( + name="test_mistral", + api_base="https://api.mistral.ai", + backend=Backend.MISTRAL, + api_key_env_var="MISTRAL_API_KEY", + ) + + result = resolve_api_key_for_plan(provider) + assert result is None + + if previous_api_key is not None: + environ["MISTRAL_API_KEY"] = previous_api_key diff --git a/tests/cli/plan_offer/test_plan_offer_in_app.py b/tests/cli/plan_offer/test_plan_offer_in_app.py deleted file mode 100644 index c9dc1ec..0000000 --- a/tests/cli/plan_offer/test_plan_offer_in_app.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import pytest - -from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway -from tests.stubs.fake_backend import FakeBackend -from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse -from vibe.cli.textual_ui.app import VibeApp -from vibe.cli.textual_ui.widgets.messages import PlanOfferMessage -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig - - -def _make_app(gateway: FakeWhoAmIGateway, config: VibeConfig | None = None) -> VibeApp: - config = config or VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False - ) - agent_loop = AgentLoop(config=config, backend=FakeBackend()) - return VibeApp(agent_loop=agent_loop, plan_offer_gateway=gateway) - - -@pytest.mark.asyncio -async def test_app_shows_upgrade_offer_in_plan_offer_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("MISTRAL_API_KEY", "api-key") - gateway = FakeWhoAmIGateway( - WhoAmIResponse( - is_pro_plan=False, - advertise_pro_plan=True, - prompt_switching_to_pro_plan=False, - ) - ) - app = _make_app(gateway) - - async with app.run_test() as pilot: - await pilot.pause(0.1) - offer = app.query_one(PlanOfferMessage) - assert "Upgrade to" in offer.get_text() - assert "Pro" in offer.get_text() - assert gateway.calls == ["api-key"] - - -@pytest.mark.asyncio -async def test_app_shows_switch_to_pro_key_offer_in_plan_offer_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("MISTRAL_API_KEY", "api-key") - gateway = FakeWhoAmIGateway( - WhoAmIResponse( - is_pro_plan=False, - advertise_pro_plan=False, - prompt_switching_to_pro_plan=True, - ) - ) - app = _make_app(gateway) - - async with app.run_test() as pilot: - await pilot.pause(0.1) - offer = app.query_one(PlanOfferMessage) - assert "Switch to your" in offer.get_text() - assert "Pro API key" in offer.get_text() - assert gateway.calls == ["api-key"] diff --git a/tests/cli/test_braille_renderer.py b/tests/cli/test_braille_renderer.py new file mode 100644 index 0000000..e29a3b0 --- /dev/null +++ b/tests/cli/test_braille_renderer.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import pytest + +from vibe.cli.textual_ui.widgets.braille_renderer import ( + _braille_char_from_dot_indices, + _braille_dot_index, + render_braille, +) + + +class TestBrailleDotIndex: + """Tests for _braille_dot_index(x, y).""" + + def test_dot_positions_per_docstring(self) -> None: + # Layout from docstring: x in {0,1}, y in {0,1,2,3} + # -x-> | 1 4 | 2 5 | 3 6 | V 7 8 + assert _braille_dot_index(0, 0) == 1 + assert _braille_dot_index(1, 0) == 4 + assert _braille_dot_index(0, 1) == 2 + assert _braille_dot_index(1, 1) == 5 + assert _braille_dot_index(0, 2) == 3 + assert _braille_dot_index(1, 2) == 6 + assert _braille_dot_index(0, 3) == 7 + assert _braille_dot_index(1, 3) == 8 + + @pytest.mark.parametrize("x", [0, 1]) + @pytest.mark.parametrize("y", [0, 1, 2, 3]) + def test_all_indices_in_range_one_to_eight(self, x: int, y: int) -> None: + idx = _braille_dot_index(x, y) + assert 1 <= idx <= 8 + + +class TestBrailleCharFromDotIndices: + """Tests for _braille_char_from_dot_indices.""" + + def test_empty_indices_returns_space(self) -> None: + assert _braille_char_from_dot_indices([]) == " " + + def test_single_dot_one_returns_braille_char(self) -> None: + # U+2800 is empty, +1 for dot 1 = U+2801 + char = _braille_char_from_dot_indices([1]) + assert char == "⠁" + + def test_all_dots_returns_full_cell(self) -> None: + char = _braille_char_from_dot_indices([1, 2, 3, 4, 5, 6, 7, 8]) + # Full block: 0x2800 + (2^8 - 1) = 0x28FF + assert char == "⣿" + + def test_invalid_index_below_one_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid braille dot indices"): + _braille_char_from_dot_indices([0]) + + def test_invalid_index_above_eight_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid braille dot indices"): + _braille_char_from_dot_indices([9]) + + def test_order_of_indices_does_not_matter(self) -> None: + a = _braille_char_from_dot_indices([1, 2, 3]) + b = _braille_char_from_dot_indices([3, 1, 2]) + assert a == b + + +class TestRenderBraille: + """Tests for render_braille(dot_coords, width, height).""" + + def test_empty_coords_produces_blank_grid(self) -> None: + result = render_braille([], width=4, height=2) + assert result == " " + + def test_origin_one_dot(self) -> None: + # (0, 0) -> first cell, dot 1 + result = render_braille([0], width=2, height=4) + lines = result.split("\n") + assert len(lines) == 1 + assert lines[0][0] == "⠁" + assert lines[0].strip() == "⠁" + + def test_output_dimensions_match_ceiled_width_and_height(self) -> None: + result = render_braille([], width=3, height=7) + lines = result.split("\n") + assert len(lines) == 2 + assert len(lines[0]) == 2 + + def test_multiple_dots_in_same_cell_combine(self) -> None: + # Two dots in first cell: (0,0) and (1,0) -> indices 1 and 4 + result = render_braille([0, 1], width=4, height=4) + lines = result.split("\n") + assert len(lines) == 1 + assert lines[0][0] == "⠉" + + def test_dots_in_different_cells(self) -> None: + # First cell (0,0), second cell (2,0) + result = render_braille([0, 2], width=4, height=4) + lines = result.split("\n") + assert len(lines) == 1 + assert len(lines[0]) == 2 + assert lines[0][0] == "⠁" + assert lines[0][1] == "⠁" + + def test_multiple_rows(self) -> None: + # Dot at (0,0) and (0,4) -> two rows + result = render_braille([0, 4j], width=2, height=8) + lines = result.split("\n") + assert len(lines) == 2 + assert lines[0][0] == "⠁" + assert lines[1][0] == "⠁" + + def test_accepts_complex_coords(self) -> None: + result = render_braille([1 + 2j], width=4, height=4) + lines = result.split("\n") + assert len(lines) == 1 + # x=1,y=2 -> first cell (0,0), sub_x=1, sub_y=2 -> dot index 6 + assert lines[0][0] == _braille_char_from_dot_indices([6]) diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py index c677daa..e2b57e0 100644 --- a/tests/cli/test_clipboard.py +++ b/tests/cli/test_clipboard.py @@ -5,17 +5,10 @@ from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock, mock_open, patch -import pyperclip import pytest from textual.app import App -from vibe.cli.clipboard import ( - _copy_osc52, - _copy_wayland_clipboard, - _copy_x11_clipboard, - _get_copy_fns, - copy_selection_to_clipboard, -) +from vibe.cli.clipboard import _copy_osc52, copy_selection_to_clipboard class MockWidget: @@ -42,7 +35,6 @@ def mock_app() -> App: app = MagicMock(spec=App) app.query = MagicMock(return_value=[]) app.notify = MagicMock() - app.copy_to_clipboard = MagicMock() return cast(App, app) @@ -86,21 +78,18 @@ def test_copy_selection_to_clipboard_no_notification( mock_app.notify.assert_not_called() -@patch("vibe.cli.clipboard._get_copy_fns") +@patch("vibe.cli.clipboard._copy_osc52") def test_copy_selection_to_clipboard_success( - mock_get_copy_fns: MagicMock, mock_app: MagicMock + mock_copy_osc52: MagicMock, mock_app: MagicMock ) -> None: widget = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("selected text", None) ) mock_app.query.return_value = [widget] - mock_copy_fn = MagicMock() - mock_get_copy_fns.return_value = [mock_copy_fn] - copy_selection_to_clipboard(mock_app) - mock_copy_fn.assert_called_once_with("selected text") + mock_copy_osc52.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( '"selected text" copied to clipboard', severity="information", @@ -109,54 +98,22 @@ def test_copy_selection_to_clipboard_success( ) -@patch("vibe.cli.clipboard._get_copy_fns") -def test_copy_selection_to_clipboard_tries_all( - mock_get_copy_fns: MagicMock, mock_app: MagicMock +@patch("vibe.cli.clipboard._copy_osc52") +def test_copy_selection_to_clipboard_failure( + mock_copy_osc52: MagicMock, mock_app: MagicMock ) -> None: widget = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("selected text", None) ) mock_app.query.return_value = [widget] - fn_1 = MagicMock(side_effect=Exception("failed")) - fn_2 = MagicMock() - fn_3 = MagicMock() - mock_get_copy_fns.return_value = [fn_1, fn_2, fn_3] + mock_copy_osc52.side_effect = Exception("OSC52 failed") copy_selection_to_clipboard(mock_app) - fn_1.assert_called_once_with("selected text") - fn_2.assert_called_once_with("selected text") - fn_3.assert_called_once_with("selected text") + mock_copy_osc52.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( - '"selected text" copied to clipboard', - severity="information", - timeout=2, - markup=False, - ) - - -@patch("vibe.cli.clipboard._get_copy_fns") -def test_copy_selection_to_clipboard_all_methods_fail( - mock_get_copy_fns: MagicMock, mock_app: MagicMock -) -> None: - widget = MockWidget( - text_selection=SimpleNamespace(), get_selection_result=("selected text", None) - ) - mock_app.query.return_value = [widget] - - failing_fn1 = MagicMock(side_effect=Exception("failed 1")) - failing_fn2 = MagicMock(side_effect=Exception("failed 2")) - failing_fn3 = MagicMock(side_effect=Exception("failed 3")) - mock_get_copy_fns.return_value = [failing_fn1, failing_fn2, failing_fn3] - - copy_selection_to_clipboard(mock_app) - - failing_fn1.assert_called_once_with("selected text") - failing_fn2.assert_called_once_with("selected text") - failing_fn3.assert_called_once_with("selected text") - mock_app.notify.assert_called_once_with( - "Failed to copy - no clipboard method available", severity="warning", timeout=3 + "Failed to copy - clipboard not available", severity="warning", timeout=3 ) @@ -171,14 +128,12 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No widget3 = MockWidget(text_selection=None) mock_app.query.return_value = [widget1, widget2, widget3] - with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns: - mock_copy_fn = MagicMock() - mock_get_copy_fns.return_value = [mock_copy_fn] + with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52: copy_selection_to_clipboard(mock_app) - mock_copy_fn.assert_called_once_with("first selection\nsecond selection") + mock_copy_osc52.assert_called_once_with("first selection\nsecond selection") mock_app.notify.assert_called_once_with( - '"first selection⏎second selection" copied to clipboard', + '"first selection\u23cesecond selection" copied to clipboard', severity="information", timeout=2, markup=False, @@ -192,12 +147,10 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) -> ) mock_app.query.return_value = [widget] - with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns: - mock_copy_fn = MagicMock() - mock_get_copy_fns.return_value = [mock_copy_fn] + with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52: copy_selection_to_clipboard(mock_app) - mock_copy_fn.assert_called_once_with(long_text) + mock_copy_osc52.assert_called_once_with(long_text) notification_call = mock_app.notify.call_args assert notification_call is not None assert '"' in notification_call[0][0] @@ -210,7 +163,7 @@ def test_copy_osc52_writes_correct_sequence( mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv("TMUX", raising=False) - test_text = "héllo wörld 🎉" + test_text = "hello world" _copy_osc52(test_text) @@ -237,109 +190,16 @@ def test_copy_osc52_with_tmux( handle.write.assert_called_once_with(expected_seq) -@patch("vibe.cli.clipboard.subprocess.run") -def test_copy_x11_clipboard(mock_subprocess: MagicMock) -> None: - test_text = "test text" - - _copy_x11_clipboard(test_text) - - mock_subprocess.assert_called_once_with( - ["xclip", "-selection", "clipboard"], - input=test_text.encode("utf-8"), - check=True, - ) - - -@patch("vibe.cli.clipboard.subprocess.run") -def test_copy_wayland_clipboard(mock_subprocess: MagicMock) -> None: - test_text = "test text" - - _copy_wayland_clipboard(test_text) - - mock_subprocess.assert_called_once_with( - ["wl-copy"], input=test_text.encode("utf-8"), check=True - ) - - -@patch("vibe.cli.clipboard.shutil.which") -def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> None: - mock_which.return_value = None - - copy_fns = _get_copy_fns(mock_app) - - assert len(copy_fns) == 3 - assert copy_fns[0] == _copy_osc52 - assert copy_fns[1] == pyperclip.copy - assert copy_fns[2] == mock_app.copy_to_clipboard - - -@patch("vibe.cli.clipboard.platform.system") -@patch("vibe.cli.clipboard.shutil.which") -def test_get_copy_fns_with_xclip( - mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App +@patch("builtins.open", new_callable=mock_open) +def test_copy_osc52_unicode( + mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: - mock_platform_system.return_value = "Linux" + monkeypatch.delenv("TMUX", raising=False) + test_text = "hello world" - def which_side_effect(cmd: str) -> str | None: - return "/usr/bin/xclip" if cmd == "xclip" else None + _copy_osc52(test_text) - mock_which.side_effect = which_side_effect - - copy_fns = _get_copy_fns(mock_app) - - assert len(copy_fns) == 4 - assert copy_fns[0] == _copy_x11_clipboard - assert copy_fns[1] == _copy_osc52 - assert copy_fns[2] == pyperclip.copy - assert copy_fns[3] == mock_app.copy_to_clipboard - - -@patch("vibe.cli.clipboard.platform.system") -@patch("vibe.cli.clipboard.shutil.which") -def test_get_copy_fns_with_wl_copy( - mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App -) -> None: - mock_platform_system.return_value = "Linux" - - def which_side_effect(cmd: str) -> str | None: - return "/usr/bin/wl-copy" if cmd == "wl-copy" else None - - mock_which.side_effect = which_side_effect - - copy_fns = _get_copy_fns(mock_app) - - assert len(copy_fns) == 4 - assert copy_fns[0] == _copy_wayland_clipboard - assert copy_fns[1] == _copy_osc52 - assert copy_fns[2] == pyperclip.copy - assert copy_fns[3] == mock_app.copy_to_clipboard - - -@patch("vibe.cli.clipboard.platform.system") -@patch("vibe.cli.clipboard.shutil.which") -def test_get_copy_fns_with_both_system_tools( - mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App -) -> None: - mock_platform_system.return_value = "Linux" - - def which_side_effect(cmd: str) -> str | None: - match cmd: - case "wl-copy": - return "/usr/bin/wl-copy" - case "xclip": - return "/usr/bin/xclip" - case _: - return None - - mock_which.side_effect = which_side_effect - - copy_fns = _get_copy_fns(mock_app) - - assert len(copy_fns) == 5 - # xclip is checked last, so it's added last and ends up first in the list - assert copy_fns[0] == _copy_x11_clipboard - # wl-copy is checked first, so it's added before xclip - assert copy_fns[1] == _copy_wayland_clipboard - assert copy_fns[2] == _copy_osc52 - assert copy_fns[3] == pyperclip.copy - assert copy_fns[4] == mock_app.copy_to_clipboard + encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii") + expected_seq = f"\033]52;c;{encoded}\a" + handle = mock_file() + handle.write.assert_called_once_with(expected_seq) diff --git a/tests/cli/test_copy_shortcuts.py b/tests/cli/test_copy_shortcuts.py new file mode 100644 index 0000000..43a1952 --- /dev/null +++ b/tests/cli/test_copy_shortcuts.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp + + +@pytest.mark.asyncio +async def test_ctrl_y_triggers_copy_selection() -> None: + """Test that ctrl+y keybinding triggers copy_selection_to_clipboard.""" + app = BaseSnapshotTestApp() + + with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: + async with app.run_test() as pilot: + await pilot.press("ctrl+y") + mock_copy.assert_called_once_with(app, show_toast=False) + + +@pytest.mark.asyncio +async def test_ctrl_shift_c_triggers_copy_selection() -> None: + """Test that ctrl+shift+c keybinding triggers copy_selection_to_clipboard.""" + app = BaseSnapshotTestApp() + + with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: + async with app.run_test() as pilot: + await pilot.press("ctrl+shift+c") + mock_copy.assert_called_once_with(app, show_toast=False) + + +@pytest.mark.asyncio +async def test_mouse_up_respects_autocopy_config_enabled() -> None: + """Test that mouse up copies when autocopy_to_clipboard is True.""" + from tests.snapshots.base_snapshot_test_app import default_config + + config = default_config() + config.autocopy_to_clipboard = True + app = BaseSnapshotTestApp(config=config) + + with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: + async with app.run_test() as pilot: + await pilot.click() + mock_copy.assert_called_once_with(app, show_toast=True) + + +@pytest.mark.asyncio +async def test_mouse_up_respects_autocopy_config_disabled() -> None: + """Test that mouse up does not copy when autocopy_to_clipboard is False.""" + from tests.snapshots.base_snapshot_test_app import default_config + + config = default_config() + config.autocopy_to_clipboard = False + app = BaseSnapshotTestApp(config=config) + + with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: + async with app.run_test() as pilot: + await pilot.click() + mock_copy.assert_not_called() diff --git a/tests/cli/test_spinner.py b/tests/cli/test_spinner.py new file mode 100644 index 0000000..736a29b --- /dev/null +++ b/tests/cli/test_spinner.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import random + +from vibe.cli.textual_ui.widgets.spinner import SpinnerType, create_spinner + + +def test_generate_100_frames_no_crash() -> None: + """Generate 100 frames per spinner type with seeded random for determinism.""" + random.seed(42) + for spinner_type in SpinnerType: + spinner = create_spinner(spinner_type) + for _ in range(100): + frame = spinner.next_frame() + assert isinstance(frame, str) + assert len(frame) > 0 diff --git a/tests/cli/test_ui_clipboard_notifications.py b/tests/cli/test_ui_clipboard_notifications.py index b214c8a..b81edd6 100644 --- a/tests/cli/test_ui_clipboard_notifications.py +++ b/tests/cli/test_ui_clipboard_notifications.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from textual.selection import Selection @@ -8,8 +8,6 @@ from textual.widget import Widget from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.textual_ui.app import VibeApp -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig class ClipboardSelectionWidget(Widget): @@ -27,24 +25,15 @@ class ClipboardSelectionWidget(Widget): @pytest.mark.asyncio async def test_ui_clipboard_notification_does_not_crash_on_markup_text( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, vibe_app: VibeApp ) -> None: - agent_loop = AgentLoop( - config=VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - enable_update_checks=False, - ) - ) - app = VibeApp(agent_loop=agent_loop) - - async with app.run_test(notifications=True) as pilot: - await app.mount(ClipboardSelectionWidget("[/]")) - with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns: - mock_get_copy_fns.return_value = [MagicMock()] - copy_selection_to_clipboard(app) + async with vibe_app.run_test(notifications=True) as pilot: + await vibe_app.mount(ClipboardSelectionWidget("[/]")) + with patch("vibe.cli.clipboard._copy_osc52"): + copy_selection_to_clipboard(vibe_app) await pilot.pause(0.1) - notifications = list(app._notifications) + notifications = list(vibe_app._notifications) assert notifications notification = notifications[-1] assert notification.markup is False diff --git a/tests/cli/test_ui_session_incremental_renderer.py b/tests/cli/test_ui_session_incremental_renderer.py new file mode 100644 index 0000000..c396aef --- /dev/null +++ b/tests/cli/test_ui_session_incremental_renderer.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import time + +import pytest +from textual.widgets import Button + +from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway +from tests.conftest import build_test_agent_loop +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse +from vibe.cli.textual_ui.app import ChatScroll, VibeApp +from vibe.cli.textual_ui.widgets.load_more import ( + HistoryLoadMoreMessage, + HistoryLoadMoreRequested, +) +from vibe.cli.textual_ui.widgets.messages import UserMessage +from vibe.cli.textual_ui.windowing import ( + HISTORY_RESUME_TAIL_MESSAGES, + LOAD_MORE_BATCH_SIZE, +) +from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.types import LLMMessage, Role + + +@pytest.fixture +def vibe_config() -> VibeConfig: + return VibeConfig( + session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False + ) + + +def _pro_plan_gateway() -> FakeWhoAmIGateway: + return FakeWhoAmIGateway( + response=WhoAmIResponse( + is_pro_plan=True, + advertise_pro_plan=False, + prompt_switching_to_pro_plan=False, + ) + ) + + +async def _wait_until(pause, predicate, timeout: float = 2.0) -> None: + start = time.monotonic() + while (time.monotonic() - start) < timeout: + if predicate(): + return + await pause(0.02) + raise AssertionError("Condition was not met within the timeout") + + +async def _wait_for_load_more(app: VibeApp, pause) -> None: + await _wait_until( + pause, lambda: len(app.query(HistoryLoadMoreMessage)) == 1, timeout=5.0 + ) + + +def _load_more_remaining(app: VibeApp) -> int: + label = app.query_one(HistoryLoadMoreMessage).query_one(Button).label + text = str(label) + _, _, remainder = text.rpartition("(") + return int(remainder.rstrip(")")) + + +@pytest.mark.asyncio +async def test_ui_session_incremental_loader_shows_tail_and_load_more( + vibe_config: VibeConfig, +) -> None: + agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False) + agent_loop.messages.extend([ + LLMMessage(role=Role.user, content=f"msg-{idx}") for idx in range(66) + ]) + + app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway()) + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES, + timeout=5.0, + ) + await _wait_for_load_more(app, pilot.pause) + + assert len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES + load_more = app.query_one(HistoryLoadMoreMessage) + label = load_more.query_one(Button).label + assert "(" in str(label) + + +@pytest.mark.asyncio +async def test_ui_session_incremental_loader_load_more_shows_remaining_count( + vibe_config: VibeConfig, +) -> None: + total_messages = 31 + agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False) + agent_loop.messages.extend([ + LLMMessage(role=Role.user, content=f"msg-{idx}") + for idx in range(total_messages) + ]) + + app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway()) + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES, + timeout=5.0, + ) + await _wait_for_load_more(app, pilot.pause) + + initial_remaining = total_messages - HISTORY_RESUME_TAIL_MESSAGES + assert _load_more_remaining(app) == initial_remaining + + app.post_message(HistoryLoadMoreRequested()) + expected_remaining = initial_remaining - LOAD_MORE_BATCH_SIZE + await _wait_until( + pilot.pause, + lambda: _load_more_remaining(app) == expected_remaining, + timeout=5.0, + ) + + +@pytest.mark.asyncio +async def test_ui_session_incremental_loader_load_more_batches_until_done( + vibe_config: VibeConfig, +) -> None: + agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False) + agent_loop.messages.extend([ + LLMMessage(role=Role.user, content=f"msg-{idx}") for idx in range(31) + ]) + + app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway()) + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES, + timeout=5.0, + ) + await _wait_for_load_more(app, pilot.pause) + + total_messages = 31 + while len(app.query(HistoryLoadMoreMessage)) == 1: + current_count = len(app.query(UserMessage)) + app.post_message(HistoryLoadMoreRequested()) + await _wait_until( + pilot.pause, + lambda current_count=current_count: len(app.query(UserMessage)) + > current_count, + ) + + await _wait_until( + pilot.pause, lambda: len(app.query(UserMessage)) == total_messages + ) + + +@pytest.mark.asyncio +async def test_ui_session_incremental_loader_keeps_top_alignment_when_not_scrollable( + vibe_config: VibeConfig, +) -> None: + agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False) + agent_loop.messages.extend([ + LLMMessage(role=Role.user, content=f"msg-{idx}") + for idx in range(HISTORY_RESUME_TAIL_MESSAGES + 1) + ]) + + app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway()) + + async with app.run_test(size=(120, 80)) as pilot: + await _wait_for_load_more(app, pilot.pause) + chat = app.query_one("#chat", ChatScroll) + assert chat.max_scroll_y == 0 + assert chat.scroll_y == 0 diff --git a/tests/cli/test_ui_session_resume.py b/tests/cli/test_ui_session_resume.py index 983b436..3fc2064 100644 --- a/tests/cli/test_ui_session_resume.py +++ b/tests/cli/test_ui_session_resume.py @@ -1,29 +1,39 @@ from __future__ import annotations +from pathlib import Path +import time +from unittest.mock import patch + import pytest from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway -from vibe.cli.textual_ui.app import VibeApp -from vibe.cli.textual_ui.widgets.messages import AssistantMessage, UserMessage +from tests.conftest import ( + build_test_agent_loop, + build_test_vibe_app, + build_test_vibe_config, +) +from tests.update_notifier.adapters.fake_update_cache_repository import ( + FakeUpdateCacheRepository, +) +from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse +from vibe.cli.textual_ui.widgets.messages import ( + AssistantMessage, + UserMessage, + WhatsNewMessage, +) from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.cli.update_notifier import UpdateCache +from vibe.core.config import VibeConfig from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall -@pytest.fixture -def vibe_config() -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False - ) - - @pytest.mark.asyncio async def test_ui_displays_messages_when_resuming_session( vibe_config: VibeConfig, ) -> None: """Test that messages are properly displayed when resuming a session.""" - agent_loop = AgentLoop(config=vibe_config, enable_streaming=False) + agent_loop = build_test_agent_loop(config=vibe_config) # Simulate a previous session with messages user_msg = LLMMessage(role=Role.user, content="Hello, how are you?") @@ -49,7 +59,7 @@ async def test_ui_displays_messages_when_resuming_session( agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg]) - app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway()) + app = build_test_vibe_app(agent_loop=agent_loop) async with app.run_test() as pilot: # Wait for the app to initialize and rebuild history @@ -82,13 +92,13 @@ async def test_ui_does_not_display_messages_when_only_system_messages_exist( vibe_config: VibeConfig, ) -> None: """Test that no messages are displayed when only system messages exist.""" - agent_loop = AgentLoop(config=vibe_config, enable_streaming=False) + agent_loop = build_test_agent_loop(config=vibe_config) # Only system messages system_msg = LLMMessage(role=Role.system, content="System prompt") agent_loop.messages.append(system_msg) - app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway()) + app = build_test_vibe_app(agent_loop=agent_loop) async with app.run_test() as pilot: await pilot.pause(0.5) @@ -106,7 +116,7 @@ async def test_ui_displays_multiple_user_assistant_turns( vibe_config: VibeConfig, ) -> None: """Test that multiple conversation turns are properly displayed.""" - agent_loop = AgentLoop(config=vibe_config, enable_streaming=False) + agent_loop = build_test_agent_loop(config=vibe_config) # Multiple conversation turns messages = [ @@ -118,7 +128,7 @@ async def test_ui_displays_multiple_user_assistant_turns( agent_loop.messages.extend(messages) - app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway()) + app = build_test_vibe_app(agent_loop=agent_loop) async with app.run_test() as pilot: await pilot.pause(0.5) @@ -133,3 +143,55 @@ async def test_ui_displays_multiple_user_assistant_turns( assert len(assistant_messages) == 2 assert assistant_messages[0]._content == "First answer" assert assistant_messages[1]._content == "Second answer" + + +@pytest.mark.asyncio +async def test_ui_rebuilds_history_when_whats_new_is_shown( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # we have to define an api key to make sure we display the Plan Offer message + monkeypatch.setenv("MISTRAL_API_KEY", "api-key") + config = build_test_vibe_config(enable_update_checks=True) + agent_loop = build_test_agent_loop(config=config) + agent_loop.messages.extend([ + LLMMessage(role=Role.user, content="Hello from the previous session."), + LLMMessage(role=Role.assistant, content="Welcome back!"), + ]) + update_cache = UpdateCache( + latest_version="1.0.0", + stored_at_timestamp=int(time.time()), + seen_whats_new_version=None, + ) + update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache) + plan_offer_gateway = FakeWhoAmIGateway( + WhoAmIResponse( + is_pro_plan=False, + advertise_pro_plan=True, + prompt_switching_to_pro_plan=False, + ) + ) + app = build_test_vibe_app( + agent_loop=agent_loop, + update_notifier=FakeUpdateGateway(update=None), + update_cache_repository=update_cache_repository, + plan_offer_gateway=plan_offer_gateway, + current_version="1.0.0", + config=config, + ) + + with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): + whats_new_file = tmp_path / "whats_new.md" + whats_new_file.write_text("# What's New\n\n- Feature 1") + + async with app.run_test() as pilot: + await pilot.pause(0.5) + whats_new_message = app.query_one(WhatsNewMessage) + message = app.query_one(UserMessage) + assistant_message = app.query_one(AssistantMessage) + messages_area = app.query_one("#messages") + children = list(messages_area.children) + + assert message._content == "Hello from the previous session." + assert whats_new_message is not None + assert "after-history" in whats_new_message.classes + assert children == [message, assistant_message, whats_new_message] diff --git a/tests/conftest.py b/tests/conftest.py index 6ea2b66..53be439 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,18 @@ from typing import Any import pytest import tomli_w +from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway +from tests.stubs.fake_backend import FakeBackend +from tests.update_notifier.adapters.fake_update_cache_repository import ( + FakeUpdateCacheRepository, +) +from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse +from vibe.cli.textual_ui.app import CORE_VERSION, VibeApp +from vibe.core.agent_loop import AgentLoop +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.llm.types import BackendLike from vibe.core.paths import global_paths from vibe.core.paths.config_paths import unlock_config_paths @@ -80,3 +92,101 @@ def _mock_platform(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture(autouse=True) def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]) + + +@pytest.fixture +def vibe_app() -> VibeApp: + return build_test_vibe_app() + + +@pytest.fixture +def agent_loop() -> AgentLoop: + return build_test_agent_loop() + + +@pytest.fixture +def vibe_config() -> VibeConfig: + return build_test_vibe_config() + + +def build_test_vibe_config(**kwargs) -> VibeConfig: + session_logging = kwargs.pop("session_logging", None) + resolved_session_logging = ( + SessionLoggingConfig(enabled=False) + if session_logging is None + else session_logging + ) + enable_update_checks = kwargs.pop("enable_update_checks", None) + resolved_enable_update_checks = ( + False if enable_update_checks is None else enable_update_checks + ) + return VibeConfig( + session_logging=resolved_session_logging, + enable_update_checks=resolved_enable_update_checks, + **kwargs, + ) + + +def build_test_agent_loop( + *, + config: VibeConfig | None = None, + agent_name: str = BuiltinAgentName.DEFAULT, + backend: BackendLike | None = None, + enable_streaming: bool = False, + **kwargs, +) -> AgentLoop: + + resolved_config = config or build_test_vibe_config() + + return AgentLoop( + config=resolved_config, + agent_name=agent_name, + backend=backend or FakeBackend(), + enable_streaming=enable_streaming, + **kwargs, + ) + + +def build_test_vibe_app( + *, config: VibeConfig | None = None, agent_loop: AgentLoop | None = None, **kwargs +) -> VibeApp: + app_config = config or build_test_vibe_config() + + resolved_agent_loop = agent_loop or build_test_agent_loop(config=app_config) + + update_notifier = kwargs.pop("update_notifier", None) + resolved_update_notifier = ( + FakeUpdateGateway() if update_notifier is None else update_notifier + ) + update_cache_repository = kwargs.pop("update_cache_repository", None) + resolved_update_cache_repository = ( + FakeUpdateCacheRepository() + if update_cache_repository is None + else update_cache_repository + ) + plan_offer_gateway = kwargs.pop("plan_offer_gateway", None) + resolved_plan_offer_gateway = ( + FakeWhoAmIGateway( + WhoAmIResponse( + is_pro_plan=True, + advertise_pro_plan=False, + prompt_switching_to_pro_plan=False, + ) + ) + if plan_offer_gateway is None + else plan_offer_gateway + ) + current_version = kwargs.pop("current_version", None) + resolved_current_version = ( + CORE_VERSION if current_version is None else current_version + ) + + return VibeApp( + agent_loop=resolved_agent_loop, + current_version=resolved_current_version, + update_notifier=resolved_update_notifier, + update_cache_repository=resolved_update_cache_repository, + plan_offer_gateway=resolved_plan_offer_gateway, + initial_prompt=kwargs.pop("initial_prompt", None), + **kwargs, + ) diff --git a/tests/core/test_agents.py b/tests/core/test_agents.py index 53b86e0..f1235ed 100644 --- a/tests/core/test_agents.py +++ b/tests/core/test_agents.py @@ -2,9 +2,9 @@ from __future__ import annotations import pytest +from tests.conftest import build_test_vibe_config from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import BUILTIN_AGENTS, EXPLORE, AgentSafety, AgentType -from vibe.core.config import VibeConfig class TestAgentProfile: @@ -31,7 +31,9 @@ class TestAgentProfile: class TestAgentManager: @pytest.fixture def manager(self) -> AgentManager: - config = VibeConfig(include_project_context=False, include_prompt_detail=False) + config = build_test_vibe_config( + include_project_context=False, include_prompt_detail=False + ) return AgentManager(lambda: config) def test_get_subagents_returns_only_subagents(self, manager: AgentManager) -> None: diff --git a/tests/core/test_auth_crypto.py b/tests/core/test_auth_crypto.py new file mode 100644 index 0000000..df7fc43 --- /dev/null +++ b/tests/core/test_auth_crypto.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import asdict + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from vibe.core.auth import EncryptedPayload, decrypt, encrypt + + +def _generate_test_key_pair() -> tuple[bytes, bytes]: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return private_pem, public_pem + + +class TestEncryptDecrypt: + def test_encrypt_decrypt_roundtrip(self) -> None: + private_pem, public_pem = _generate_test_key_pair() + + plaintext = "ghp_test_token_12345" + encrypted = encrypt(plaintext, public_pem) + + assert encrypted.encrypted_key != plaintext + assert encrypted.ciphertext != plaintext + + decrypted = decrypt(encrypted, private_pem) + assert decrypted == plaintext + + def test_encrypted_payload_serialization(self) -> None: + payload = EncryptedPayload( + encrypted_key="enc_key", nonce="nonce123", ciphertext="cipher" + ) + + data = asdict(payload) + restored = EncryptedPayload(**data) + + assert restored == payload diff --git a/tests/core/test_auth_github.py b/tests/core/test_auth_github.py new file mode 100644 index 0000000..587bdbd --- /dev/null +++ b/tests/core/test_auth_github.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from vibe.core.auth.github import ( + DeviceFlowHandle, + DeviceFlowInfo, + GitHubAuthError, + GitHubAuthProvider, +) + + +class TestDeviceFlowModels: + def test_device_flow_info(self) -> None: + info = DeviceFlowInfo( + user_code="ABC-123", verification_uri="https://example.com" + ) + assert info.user_code == "ABC-123" + assert info.verification_uri == "https://example.com" + + def test_device_flow_handle(self) -> None: + info = DeviceFlowInfo( + user_code="ABC-123", verification_uri="https://example.com" + ) + handle = DeviceFlowHandle(device_code="dc_123", expires_in=900, info=info) + assert handle.device_code == "dc_123" + assert handle.expires_in == 900 + assert handle.info.user_code == "ABC-123" + + +class TestGitHubAuthProviderContextManager: + @pytest.mark.asyncio + async def test_creates_client_on_enter(self) -> None: + provider = GitHubAuthProvider() + assert provider._client is None + async with provider: + assert provider._client is not None + assert provider._client is None + + @pytest.mark.asyncio + async def test_uses_provided_client(self) -> None: + external_client = httpx.AsyncClient() + provider = GitHubAuthProvider(client=external_client) + async with provider: + assert provider._client is external_client + assert provider._client is external_client + await external_client.aclose() + + +class TestGitHubAuthProviderGetToken: + def test_returns_token_from_keyring(self) -> None: + with patch("vibe.core.auth.github.keyring") as mock_keyring: + mock_keyring.get_password.return_value = "ghp_test_token" + provider = GitHubAuthProvider() + token = provider.get_token() + assert token == "ghp_test_token" + + def test_returns_none_on_keyring_error(self) -> None: + with patch("vibe.core.auth.github.keyring") as mock_keyring: + import keyring.errors + + mock_keyring.errors = keyring.errors + mock_keyring.get_password.side_effect = keyring.errors.KeyringError("error") + provider = GitHubAuthProvider() + token = provider.get_token() + assert token is None + + def test_returns_none_when_no_token(self) -> None: + with patch("vibe.core.auth.github.keyring") as mock_keyring: + mock_keyring.get_password.return_value = None + provider = GitHubAuthProvider() + token = provider.get_token() + assert token is None + + +class TestGitHubAuthProviderHasToken: + def test_returns_true_when_token_exists(self) -> None: + with patch("vibe.core.auth.github.keyring") as mock_keyring: + mock_keyring.get_password.return_value = "ghp_token" + provider = GitHubAuthProvider() + assert provider.has_token() is True + + def test_returns_false_when_no_token(self) -> None: + with patch("vibe.core.auth.github.keyring") as mock_keyring: + mock_keyring.get_password.return_value = None + provider = GitHubAuthProvider() + assert provider.has_token() is False + + +class TestGitHubAuthProviderStartDeviceFlow: + @pytest.fixture + def mock_client(self) -> MagicMock: + return MagicMock(spec=httpx.AsyncClient) + + @pytest.fixture + def provider(self, mock_client: MagicMock) -> GitHubAuthProvider: + return GitHubAuthProvider(client=mock_client) + + @pytest.mark.asyncio + async def test_start_device_flow_success( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = { + "device_code": "dc_123", + "user_code": "ABC-123", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + } + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("vibe.core.auth.github.webbrowser") as mock_browser: + handle = await provider.start_device_flow(open_browser=True) + mock_browser.open.assert_called_once_with("https://github.com/login/device") + + assert handle.device_code == "dc_123" + assert handle.info.user_code == "ABC-123" + assert handle.expires_in == 900 + + @pytest.mark.asyncio + async def test_start_device_flow_without_browser( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = { + "device_code": "dc_123", + "user_code": "ABC-123", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + } + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("vibe.core.auth.github.webbrowser") as mock_browser: + await provider.start_device_flow(open_browser=False) + mock_browser.open.assert_not_called() + + @pytest.mark.asyncio + async def test_start_device_flow_failure( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = False + mock_response.text = "Bad request" + mock_client.post = AsyncMock(return_value=mock_response) + + with pytest.raises(GitHubAuthError, match="Failed to initiate device flow"): + await provider.start_device_flow() + + +class TestGitHubAuthProviderPollForToken: + @pytest.fixture + def mock_client(self) -> MagicMock: + return MagicMock(spec=httpx.AsyncClient) + + @pytest.fixture + def provider(self, mock_client: MagicMock) -> GitHubAuthProvider: + return GitHubAuthProvider(client=mock_client) + + @pytest.mark.asyncio + async def test_poll_returns_token_on_success( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"access_token": "ghp_new_token"} + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("vibe.core.auth.github.asyncio.sleep", new_callable=AsyncMock): + token = await provider._poll_for_token( + mock_client, "dc_123", expires_in=10, interval=1 + ) + assert token == "ghp_new_token" + + @pytest.mark.asyncio + async def test_poll_handles_slow_down( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + responses = [ + MagicMock( + json=MagicMock(return_value={"error": "slow_down", "interval": 5}) + ), + MagicMock(json=MagicMock(return_value={"access_token": "ghp_token"})), + ] + mock_client.post = AsyncMock(side_effect=responses) + + with patch("vibe.core.auth.github.asyncio.sleep", new_callable=AsyncMock): + token = await provider._poll_for_token( + mock_client, "dc_123", expires_in=30, interval=1 + ) + assert token == "ghp_token" + + @pytest.mark.asyncio + async def test_poll_raises_on_expired_token( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"error": "expired_token"} + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("vibe.core.auth.github.asyncio.sleep", new_callable=AsyncMock): + with pytest.raises(GitHubAuthError, match="expired_token"): + await provider._poll_for_token( + mock_client, "dc_123", expires_in=10, interval=1 + ) + + @pytest.mark.asyncio + async def test_poll_raises_on_access_denied( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"error": "access_denied"} + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("vibe.core.auth.github.asyncio.sleep", new_callable=AsyncMock): + with pytest.raises(GitHubAuthError, match="access_denied"): + await provider._poll_for_token( + mock_client, "dc_123", expires_in=10, interval=1 + ) + + @pytest.mark.asyncio + async def test_poll_raises_on_timeout( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"error": "authorization_pending"} + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("vibe.core.auth.github.asyncio.sleep", new_callable=AsyncMock): + with pytest.raises(GitHubAuthError, match="timed out"): + await provider._poll_for_token( + mock_client, "dc_123", expires_in=2, interval=1 + ) + + +class TestGitHubAuthProviderSaveToken: + def test_save_token_success(self) -> None: + with patch("vibe.core.auth.github.keyring") as mock_keyring: + provider = GitHubAuthProvider() + provider._save_token("ghp_token") + mock_keyring.set_password.assert_called_once_with( + "vibe", "github_token", "ghp_token" + ) + + def test_save_token_raises_on_keyring_error(self) -> None: + with patch("vibe.core.auth.github.keyring") as mock_keyring: + import keyring.errors + + mock_keyring.errors = keyring.errors + mock_keyring.set_password.side_effect = keyring.errors.KeyringError("error") + provider = GitHubAuthProvider() + with pytest.raises(GitHubAuthError, match="Failed to save token"): + provider._save_token("ghp_token") + + +class TestGitHubAuthProviderWaitForToken: + @pytest.fixture + def mock_client(self) -> MagicMock: + return MagicMock(spec=httpx.AsyncClient) + + @pytest.fixture + def provider(self, mock_client: MagicMock) -> GitHubAuthProvider: + return GitHubAuthProvider(client=mock_client) + + @pytest.mark.asyncio + async def test_wait_for_token_polls_and_saves( + self, provider: GitHubAuthProvider, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.json.return_value = {"access_token": "ghp_token"} + mock_client.post = AsyncMock(return_value=mock_response) + + info = DeviceFlowInfo(user_code="ABC", verification_uri="https://example.com") + handle = DeviceFlowHandle(device_code="dc_123", expires_in=10, info=info) + + with ( + patch("vibe.core.auth.github.asyncio.sleep", new_callable=AsyncMock), + patch("vibe.core.auth.github.keyring") as mock_keyring, + ): + token = await provider.wait_for_token(handle) + + assert token == "ghp_token" + mock_keyring.set_password.assert_called_once() diff --git a/tests/core/test_config_load_dotenv.py b/tests/core/test_config_load_dotenv.py index ddd14f3..19ed0fc 100644 --- a/tests/core/test_config_load_dotenv.py +++ b/tests/core/test_config_load_dotenv.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os from pathlib import Path +import threading from vibe.core.config import load_dotenv_values @@ -59,3 +61,21 @@ def test_ignores_empty_values(tmp_path: Path) -> None: assert "EMPTY" not in environ assert "MISTRAL_API_KEY" not in environ assert "NO_VALUE" not in environ + + +def test_reads_from_fifo(tmp_path: Path) -> None: + fifo_path = tmp_path / ".env.fifo" + os.mkfifo(fifo_path) + environ: dict[str, str] = {} + + def write_to_fifo() -> None: + with open(fifo_path, "w") as f: + f.write("FIFO_KEY=fifo-value\n") + + writer = threading.Thread(target=write_to_fifo) + writer.start() + + load_dotenv_values(env_path=fifo_path, environ=environ) + + writer.join() + assert environ["FIFO_KEY"] == "fifo-value" diff --git a/tests/core/test_teleport_git.py b/tests/core/test_teleport_git.py new file mode 100644 index 0000000..5d0c283 --- /dev/null +++ b/tests/core/test_teleport_git.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vibe.core.teleport.errors import ( + ServiceTeleportError, + ServiceTeleportNotSupportedError, +) +from vibe.core.teleport.git import GitRepoInfo, GitRepository + + +def make_mock_remote(url: str) -> MagicMock: + remote = MagicMock() + remote.urls = [url] + return remote + + +def make_mock_repo( + urls: list[str] | None = None, + commit: str | None = "abc123", + branch: str | None = "main", + is_detached: bool = False, + diff: str = "", +) -> MagicMock: + mock = MagicMock() + if urls: + mock.remotes = [make_mock_remote(url) for url in urls] + else: + mock.remotes = [] + mock.head.commit.hexsha = commit + mock.head.is_detached = is_detached + mock.active_branch.name = branch + mock.git.diff.return_value = diff + mock.git.branch.return_value = "" + mock.git.rev_list.return_value = "0" + mock.git.rev_parse.return_value = "abc123" + mock.remote.return_value = make_mock_remote(urls[0] if urls else "") + return mock + + +class TestGitRepositoryParseGithubUrl: + def test_parse_ssh_url(self) -> None: + result = GitRepository._parse_github_url("git@github.com:owner/repo.git") + assert result == ("owner", "repo") + + def test_parse_ssh_url_without_git_suffix(self) -> None: + result = GitRepository._parse_github_url("git@github.com:owner/repo") + assert result == ("owner", "repo") + + def test_parse_https_url(self) -> None: + result = GitRepository._parse_github_url("https://github.com/owner/repo.git") + assert result == ("owner", "repo") + + def test_parse_https_url_without_git_suffix(self) -> None: + result = GitRepository._parse_github_url("https://github.com/owner/repo") + assert result == ("owner", "repo") + + def test_parse_https_url_with_credentials(self) -> None: + result = GitRepository._parse_github_url( + "https://x-access-token:gho_xxxx@github.com/owner/repo.git" + ) + assert result == ("owner", "repo") + + def test_parse_non_github_url_returns_none(self) -> None: + result = GitRepository._parse_github_url("git@gitlab.com:owner/repo.git") + assert result is None + + def test_parse_invalid_url_returns_none(self) -> None: + result = GitRepository._parse_github_url("not-a-valid-url") + assert result is None + + +class TestGitRepositoryToHttpsUrl: + def test_converts_to_https_url(self) -> None: + result = GitRepository._to_https_url("owner", "repo") + assert result == "https://github.com/owner/repo.git" + + +class TestGitRepositoryIsSupported: + @pytest.fixture + def repo(self, tmp_path: Path) -> GitRepository: + return GitRepository(tmp_path) + + @pytest.mark.asyncio + async def test_returns_false_when_not_git_repo(self, tmp_path: Path) -> None: + repo = GitRepository(tmp_path) + assert await repo.is_supported() is False + + @pytest.mark.asyncio + async def test_returns_false_when_no_remote(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=None) + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.is_supported() is False + + @pytest.mark.asyncio + async def test_returns_false_when_non_github_remote( + self, repo: GitRepository + ) -> None: + mock = make_mock_repo(urls=["git@gitlab.com:owner/repo.git"]) + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.is_supported() is False + + @pytest.mark.asyncio + async def test_returns_true_when_github_repo(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"]) + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.is_supported() is True + + @pytest.mark.asyncio + async def test_finds_github_among_multiple_remotes( + self, repo: GitRepository + ) -> None: + mock = make_mock_repo( + urls=["git@gitlab.com:owner/repo.git", "git@github.com:owner/repo.git"] + ) + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.is_supported() is True + + +class TestGitRepositoryGetInfo: + @pytest.fixture + def repo(self, tmp_path: Path) -> GitRepository: + return GitRepository(tmp_path) + + @pytest.mark.asyncio + async def test_raises_when_not_git_repo(self, tmp_path: Path) -> None: + repo = GitRepository(tmp_path) + with pytest.raises( + ServiceTeleportNotSupportedError, match="Not a git repository" + ): + await repo.get_info() + + @pytest.mark.asyncio + async def test_raises_when_no_remote(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=None) + with patch.object(repo, "_repo_or_raise", return_value=mock): + with pytest.raises( + ServiceTeleportNotSupportedError, match="No GitHub remote" + ): + await repo.get_info() + + @pytest.mark.asyncio + async def test_raises_when_non_github_remote(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@gitlab.com:owner/repo.git"]) + with patch.object(repo, "_repo_or_raise", return_value=mock): + with pytest.raises( + ServiceTeleportNotSupportedError, match="No GitHub remote" + ): + await repo.get_info() + + @pytest.mark.asyncio + async def test_raises_when_no_commit(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"], commit=None) + with patch.object(repo, "_repo_or_raise", return_value=mock): + with pytest.raises( + ServiceTeleportNotSupportedError, + match="Could not determine current commit", + ): + await repo.get_info() + + @pytest.mark.asyncio + async def test_returns_info_on_success(self, repo: GitRepository) -> None: + mock = make_mock_repo( + urls=["git@github.com:owner/repo.git"], + commit="abc123def456", + branch="main", + diff="diff content", + ) + with patch.object(repo, "_repo_or_raise", return_value=mock): + info = await repo.get_info() + assert info == GitRepoInfo( + remote_url="https://github.com/owner/repo.git", + owner="owner", + repo="repo", + branch="main", + commit="abc123def456", + diff="diff content", + ) + + @pytest.mark.asyncio + async def test_handles_detached_head(self, repo: GitRepository) -> None: + mock = make_mock_repo( + urls=["git@github.com:owner/repo.git"], + commit="abc123def456", + is_detached=True, + ) + with patch.object(repo, "_repo_or_raise", return_value=mock): + info = await repo.get_info() + assert info.branch is None + + +class TestGitRepositoryIsCommitPushed: + @pytest.fixture + def repo(self, tmp_path: Path) -> GitRepository: + return GitRepository(tmp_path) + + @pytest.mark.asyncio + async def test_returns_false_when_not_on_remote(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"]) + mock.git.branch.return_value = "" + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.is_commit_pushed("abc123") is False + + @pytest.mark.asyncio + async def test_returns_true_when_on_remote(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"]) + mock.git.branch.return_value = " origin/main\n origin/feature" + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.is_commit_pushed("abc123") is True + + @pytest.mark.asyncio + async def test_checks_correct_remote(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"]) + mock.git.branch.return_value = " upstream/main\n" + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.is_commit_pushed("abc123", remote="upstream") is True + assert await repo.is_commit_pushed("abc123", remote="origin") is False + + +class TestGitRepositoryGetUnpushedCommitCount: + @pytest.fixture + def repo(self, tmp_path: Path) -> GitRepository: + return GitRepository(tmp_path) + + @pytest.mark.asyncio + async def test_raises_when_no_branch(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"], is_detached=True) + with patch.object(repo, "_repo_or_raise", return_value=mock): + with pytest.raises(ServiceTeleportError, match="no current branch"): + await repo.get_unpushed_commit_count() + + @pytest.mark.asyncio + async def test_returns_count(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"], branch="main") + mock.git.rev_list.return_value = "5" + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.get_unpushed_commit_count() == 5 + + @pytest.mark.asyncio + async def test_fallback_to_default_branch(self, repo: GitRepository) -> None: + from git.exc import GitCommandError + + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"], branch="feature") + + def rev_list_side_effect(*args: object, **kwargs: object) -> str: + if "origin/feature..HEAD" in args: + raise GitCommandError("git rev-list", 128) + if "origin/main..HEAD" in args: + return "3" + return "0" + + mock.git.rev_list.side_effect = rev_list_side_effect + with ( + patch.object(repo, "_repo_or_raise", return_value=mock), + patch.object( + repo, + "_get_remote_default_branch", + AsyncMock(return_value="origin/main"), + ), + ): + assert await repo.get_unpushed_commit_count() == 3 + + +class TestGitRepositoryPushCurrentBranch: + @pytest.fixture + def repo(self, tmp_path: Path) -> GitRepository: + return GitRepository(tmp_path) + + @pytest.mark.asyncio + async def test_returns_false_when_no_branch(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"], is_detached=True) + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.push_current_branch() is False + + @pytest.mark.asyncio + async def test_returns_false_when_push_fails(self, repo: GitRepository) -> None: + from git.exc import GitCommandError + + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"], branch="main") + mock.remote.return_value.push.side_effect = GitCommandError("git push", 1) + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.push_current_branch() is False + + @pytest.mark.asyncio + async def test_returns_true_when_push_succeeds(self, repo: GitRepository) -> None: + mock = make_mock_repo(urls=["git@github.com:owner/repo.git"], branch="main") + with patch.object(repo, "_repo_or_raise", return_value=mock): + assert await repo.push_current_branch() is True + + +class TestGitRepositoryGetRemoteDefaultBranch: + @pytest.fixture + def repo(self, tmp_path: Path) -> GitRepository: + return GitRepository(tmp_path) + + @pytest.mark.asyncio + async def test_returns_default_branch_when_head_exists( + self, repo: GitRepository + ) -> None: + mock = MagicMock() + mock_head_ref = MagicMock() + mock_head_ref.reference.name = "origin/main" + mock.remotes.__getitem__.return_value.refs.HEAD = mock_head_ref + mock.git.rev_parse.return_value = "abc123" + result = await repo._get_remote_default_branch(mock, "origin") + assert result == "origin/main" + + @pytest.mark.asyncio + async def test_returns_none_when_remote_not_found( + self, repo: GitRepository + ) -> None: + mock = MagicMock() + mock.remotes.__getitem__.side_effect = IndexError("No item found") + result = await repo._get_remote_default_branch(mock, "upstream") + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_head_ref_missing( + self, repo: GitRepository + ) -> None: + mock = MagicMock() + mock.remotes.__getitem__.return_value.refs.HEAD = None + result = await repo._get_remote_default_branch(mock, "origin") + assert result is None diff --git a/tests/core/test_teleport_nuage.py b/tests/core/test_teleport_nuage.py new file mode 100644 index 0000000..69fe1d6 --- /dev/null +++ b/tests/core/test_teleport_nuage.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from vibe.core.auth import EncryptedPayload +from vibe.core.teleport.errors import ServiceTeleportError +from vibe.core.teleport.nuage import ( + CreateLeChatThreadInput, + GitRepoConfig, + NuageClient, + VibeNewSandbox, + VibeSandboxConfig, + WorkflowParams, +) + + +class TestNuageModels: + def test_git_repo_config_defaults(self) -> None: + config = GitRepoConfig(url="https://github.com/owner/repo.git") + assert config.url == "https://github.com/owner/repo.git" + assert config.branch is None + assert config.commit is None + + def test_git_repo_config_with_values(self) -> None: + config = GitRepoConfig( + url="https://github.com/owner/repo.git", branch="main", commit="abc123" + ) + assert config.branch == "main" + assert config.commit == "abc123" + + def test_vibe_sandbox_config_defaults(self) -> None: + config = VibeSandboxConfig() + assert config.git_repo is None + + def test_vibe_new_sandbox_defaults(self) -> None: + sandbox = VibeNewSandbox() + assert sandbox.type == "new" + assert sandbox.config.git_repo is None + assert sandbox.teleported_diffs is None + + def test_workflow_params_serialization(self) -> None: + params = WorkflowParams( + prompt="test prompt", + sandbox=VibeNewSandbox( + config=VibeSandboxConfig( + git_repo=GitRepoConfig( + url="https://github.com/owner/repo.git", + branch="main", + commit="abc123", + ) + ), + teleported_diffs=b"base64data", + ), + ) + data = params.model_dump() + assert data["prompt"] == "test prompt" + assert data["sandbox"]["type"] == "new" + assert data["sandbox"]["config"]["git_repo"]["url"] == ( + "https://github.com/owner/repo.git" + ) + assert data["sandbox"]["teleported_diffs"] == b"base64data" + + def test_create_le_chat_thread_input(self) -> None: + input_data = CreateLeChatThreadInput( + encrypted_api_key={"key": "value"}, + user_message="test message", + project_name="test-project", + ) + assert input_data.encrypted_api_key == {"key": "value"} + assert input_data.user_message == "test message" + assert input_data.project_name == "test-project" + + +class TestNuageClientContextManager: + @pytest.mark.asyncio + async def test_creates_client_on_enter(self) -> None: + nuage = NuageClient("https://api.example.com", "api-key", "workflow-id") + assert nuage._client is None + async with nuage: + assert nuage._client is not None + assert nuage._client is None + + @pytest.mark.asyncio + async def test_uses_provided_client(self) -> None: + external_client = httpx.AsyncClient() + nuage = NuageClient( + "https://api.example.com", "api-key", "workflow-id", client=external_client + ) + async with nuage: + assert nuage._client is external_client + assert nuage._client is external_client + await external_client.aclose() + + +class TestNuageClientStartWorkflow: + @pytest.fixture + def mock_client(self) -> MagicMock: + client = MagicMock(spec=httpx.AsyncClient) + return client + + @pytest.fixture + def nuage(self, mock_client: MagicMock) -> NuageClient: + return NuageClient( + "https://api.example.com", "api-key", "workflow-id", client=mock_client + ) + + @pytest.mark.asyncio + async def test_start_workflow_success( + self, nuage: NuageClient, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = {"execution_id": "exec-123"} + mock_client.post = AsyncMock(return_value=mock_response) + + params = WorkflowParams(prompt="test", sandbox=VibeNewSandbox()) + execution_id = await nuage.start_workflow(params) + + assert execution_id == "exec-123" + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert "workflows/workflow-id/execute" in call_args[0][0] + + @pytest.mark.asyncio + async def test_start_workflow_failure( + self, nuage: NuageClient, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = False + mock_response.text = "Internal Server Error" + mock_client.post = AsyncMock(return_value=mock_response) + + params = WorkflowParams(prompt="test", sandbox=VibeNewSandbox()) + with pytest.raises(ServiceTeleportError, match="Nuage workflow trigger failed"): + await nuage.start_workflow(params) + + @pytest.mark.asyncio + async def test_start_workflow_unauthorized_hint( + self, nuage: NuageClient, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = False + mock_response.text = "Unauthorized" + mock_client.post = AsyncMock(return_value=mock_response) + + params = WorkflowParams(prompt="test", sandbox=VibeNewSandbox()) + with pytest.raises(ServiceTeleportError, match="STAGING_MISTRAL_API_KEY"): + await nuage.start_workflow(params) + + +class TestNuageClientSendGithubToken: + @pytest.fixture + def mock_client(self) -> MagicMock: + return MagicMock(spec=httpx.AsyncClient) + + @pytest.fixture + def nuage(self, mock_client: MagicMock) -> NuageClient: + return NuageClient( + "https://api.example.com", "api-key", "workflow-id", client=mock_client + ) + + @pytest.mark.asyncio + async def test_send_github_token_success( + self, + nuage: NuageClient, + mock_client: MagicMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + query_response = MagicMock() + query_response.is_success = True + query_response.json.return_value = { + "result": { + "public_key": ( + "-----BEGIN PUBLIC KEY-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Z3VS5JJcds3xfn/" + "ygWyf8TFXQNZ0XsLOqXB1Mi2+bKPFv1WfhECTxJ3c6SXl0p1sGyWTFxRV8u/" + "bKqYZ0E6VZ6YRTRPFiGq0kkONjVBFxOQ8Y0jeT0d9e0Y3E3MWDL8tQ0Nz9v8" + "5Y7gC8F1m/dEbBwPjCJQV0Dg0z3gZDO8RCG0GrBoLO0b+NNqL8FXPPDXQ1l4" + "FGnYM0gZ1rCU7Y/zTN1wI4sCQ0GJQPDA1hWB8KRJl5x0ZDXE3rRwT1E8c+Fn" + "ZFV1nN0C6zxF7GpVY3FVWXS4PA0FH+8C1+TnYgBL7xS0o+LF6PgjGT5F3CXD" + "BZmYSxKL+EsVVGT5EuYbJE9TxVwIDAQAB\n" + "-----END PUBLIC KEY-----" + ) + } + } + + signal_response = MagicMock() + signal_response.is_success = True + + mock_client.post = AsyncMock(side_effect=[query_response, signal_response]) + + encrypted = EncryptedPayload( + encrypted_key="enc_key", nonce="nonce", ciphertext="cipher" + ) + monkeypatch.setattr( + "vibe.core.teleport.nuage.encrypt", lambda _token, _key: encrypted + ) + + await nuage.send_github_token("exec-123", "ghp_token") + + assert mock_client.post.call_count == 2 + + @pytest.mark.asyncio + async def test_query_public_key_failure( + self, nuage: NuageClient, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = False + mock_response.text = "Not found" + mock_client.post = AsyncMock(return_value=mock_response) + + with pytest.raises(ServiceTeleportError, match="Failed to get public key"): + await nuage._query_public_key("exec-123") + + @pytest.mark.asyncio + async def test_signal_encrypted_token_failure( + self, nuage: NuageClient, mock_client: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.is_success = False + mock_response.text = "Signal failed" + mock_client.post = AsyncMock(return_value=mock_response) + + encrypted = EncryptedPayload( + encrypted_key="enc_key", nonce="nonce", ciphertext="cipher" + ) + with pytest.raises(ServiceTeleportError, match="Failed to send GitHub token"): + await nuage._signal_encrypted_token("exec-123", encrypted) + + +class TestNuageClientCreateLeChatThread: + @pytest.fixture + def mock_client(self) -> MagicMock: + return MagicMock(spec=httpx.AsyncClient) + + @pytest.fixture + def nuage(self, mock_client: MagicMock) -> NuageClient: + return NuageClient( + "https://api.example.com", "api-key", "workflow-id", client=mock_client + ) + + @pytest.mark.asyncio + async def test_create_le_chat_thread_success( + self, + nuage: NuageClient, + mock_client: MagicMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + query_response = MagicMock() + query_response.is_success = True + query_response.json.return_value = { + "result": { + "public_key": ( + "-----BEGIN PUBLIC KEY-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Z3VS5JJcds3xfn/" + "ygWyf8TFXQNZ0XsLOqXB1Mi2+bKPFv1WfhECTxJ3c6SXl0p1sGyWTFxRV8u/" + "bKqYZ0E6VZ6YRTRPFiGq0kkONjVBFxOQ8Y0jeT0d9e0Y3E3MWDL8tQ0Nz9v8" + "5Y7gC8F1m/dEbBwPjCJQV0Dg0z3gZDO8RCG0GrBoLO0b+NNqL8FXPPDXQ1l4" + "FGnYM0gZ1rCU7Y/zTN1wI4sCQ0GJQPDA1hWB8KRJl5x0ZDXE3rRwT1E8c+Fn" + "ZFV1nN0C6zxF7GpVY3FVWXS4PA0FH+8C1+TnYgBL7xS0o+LF6PgjGT5F3CXD" + "BZmYSxKL+EsVVGT5EuYbJE9TxVwIDAQAB\n" + "-----END PUBLIC KEY-----" + ) + } + } + + update_response = MagicMock() + update_response.is_success = True + update_response.json.return_value = { + "result": {"chat_url": "https://chat.example.com/thread/123"} + } + + mock_client.post = AsyncMock(side_effect=[query_response, update_response]) + + encrypted = EncryptedPayload( + encrypted_key="enc_key", nonce="nonce", ciphertext="cipher" + ) + monkeypatch.setattr( + "vibe.core.teleport.nuage.encrypt", lambda _token, _key: encrypted + ) + + url = await nuage.create_le_chat_thread("exec-123", "test message") + assert url == "https://chat.example.com/thread/123" + + @pytest.mark.asyncio + async def test_create_le_chat_thread_failure( + self, + nuage: NuageClient, + mock_client: MagicMock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + query_response = MagicMock() + query_response.is_success = True + query_response.json.return_value = { + "result": { + "public_key": ( + "-----BEGIN PUBLIC KEY-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Z3VS5JJcds3xfn/" + "ygWyf8TFXQNZ0XsLOqXB1Mi2+bKPFv1WfhECTxJ3c6SXl0p1sGyWTFxRV8u/" + "bKqYZ0E6VZ6YRTRPFiGq0kkONjVBFxOQ8Y0jeT0d9e0Y3E3MWDL8tQ0Nz9v8" + "5Y7gC8F1m/dEbBwPjCJQV0Dg0z3gZDO8RCG0GrBoLO0b+NNqL8FXPPDXQ1l4" + "FGnYM0gZ1rCU7Y/zTN1wI4sCQ0GJQPDA1hWB8KRJl5x0ZDXE3rRwT1E8c+Fn" + "ZFV1nN0C6zxF7GpVY3FVWXS4PA0FH+8C1+TnYgBL7xS0o+LF6PgjGT5F3CXD" + "BZmYSxKL+EsVVGT5EuYbJE9TxVwIDAQAB\n" + "-----END PUBLIC KEY-----" + ) + } + } + + update_response = MagicMock() + update_response.is_success = False + update_response.text = "Failed to create thread" + + mock_client.post = AsyncMock(side_effect=[query_response, update_response]) + + encrypted = EncryptedPayload( + encrypted_key="enc_key", nonce="nonce", ciphertext="cipher" + ) + monkeypatch.setattr( + "vibe.core.teleport.nuage.encrypt", lambda _token, _key: encrypted + ) + + with pytest.raises( + ServiceTeleportError, match="Failed to create Le Chat thread" + ): + await nuage.create_le_chat_thread("exec-123", "test message") + + +class TestNuageClientHeaders: + def test_headers_include_auth(self) -> None: + nuage = NuageClient("https://api.example.com", "test-api-key", "workflow-id") + headers = nuage._headers() + assert headers["Authorization"] == "Bearer test-api-key" + assert headers["Content-Type"] == "application/json" diff --git a/tests/core/test_teleport_service.py b/tests/core/test_teleport_service.py new file mode 100644 index 0000000..5a39a56 --- /dev/null +++ b/tests/core/test_teleport_service.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import base64 +import importlib +import os +from pathlib import Path +import sys +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import zstandard + +from vibe.core.teleport.errors import ( + ServiceTeleportError, + ServiceTeleportNotSupportedError, +) +from vibe.core.teleport.git import GitRepoInfo +from vibe.core.teleport.nuage import TeleportSession +from vibe.core.teleport.teleport import _NUAGE_EXECUTION_URL_TEMPLATE, TeleportService +from vibe.core.teleport.types import ( + TeleportAuthCompleteEvent, + TeleportAuthRequiredEvent, + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportPushResponseEvent, + TeleportSendingGithubTokenEvent, + TeleportStartingWorkflowEvent, +) + + +def _reimport_agent_loop() -> Any: + to_clear = ("vibe.core.agent_loop", "git", "vibe.core.teleport") + for k in [k for k in sys.modules if any(k.startswith(m) for m in to_clear)]: + del sys.modules[k] + return importlib.import_module("vibe.core.agent_loop") + + +class TestTeleportServiceCompressDiff: + @pytest.fixture + def service(self, tmp_path: Path) -> TeleportService: + mock_session_logger = MagicMock() + return TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="api-key", + workdir=tmp_path, + ) + + def test_returns_none_for_empty_diff(self, service: TeleportService) -> None: + assert service._compress_diff("") is None + + def test_compresses_and_encodes_diff(self, service: TeleportService) -> None: + diff = "diff --git a/file.txt b/file.txt\n+new line" + result = service._compress_diff(diff) + + assert result is not None + decoded = base64.b64decode(result) + decompressed = zstandard.ZstdDecompressor().decompress(decoded) + assert decompressed.decode("utf-8") == diff + + def test_raises_when_diff_too_large(self, service: TeleportService) -> None: + large_diff = "x" * 2_000_000 + with pytest.raises(ServiceTeleportError, match="Diff too large"): + service._compress_diff(large_diff, max_size=100) + + +class TestTeleportServiceBuildSandbox: + @pytest.fixture + def service(self, tmp_path: Path) -> TeleportService: + mock_session_logger = MagicMock() + return TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="api-key", + workdir=tmp_path, + ) + + def test_builds_sandbox_from_git_info(self, service: TeleportService) -> None: + git_info = GitRepoInfo( + remote_url="https://github.com/owner/repo.git", + owner="owner", + repo="repo", + branch="main", + commit="abc123", + diff="", + ) + sandbox = service._build_sandbox(git_info) + + assert sandbox.type == "new" + assert sandbox.config.git_repo is not None + assert sandbox.config.git_repo.url == "https://github.com/owner/repo.git" + assert sandbox.config.git_repo.branch == "main" + assert sandbox.config.git_repo.commit == "abc123" + assert sandbox.teleported_diffs is None + + def test_includes_compressed_diff(self, service: TeleportService) -> None: + git_info = GitRepoInfo( + remote_url="https://github.com/owner/repo.git", + owner="owner", + repo="repo", + branch="main", + commit="abc123", + diff="diff content", + ) + sandbox = service._build_sandbox(git_info) + + assert sandbox.teleported_diffs is not None + + +class TestTeleportServiceValidateConfig: + def test_raises_when_no_api_key(self, tmp_path: Path) -> None: + mock_session_logger = MagicMock() + service = TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="", + workdir=tmp_path, + ) + with pytest.raises( + ServiceTeleportError, match="STAGING_MISTRAL_API_KEY not set" + ): + service._validate_config() + + def test_passes_when_api_key_set(self, tmp_path: Path) -> None: + mock_session_logger = MagicMock() + service = TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="valid-key", + workdir=tmp_path, + ) + service._validate_config() + + +class TestTeleportServiceCheckSupported: + @pytest.fixture + def service(self, tmp_path: Path) -> TeleportService: + mock_session_logger = MagicMock() + return TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="api-key", + workdir=tmp_path, + ) + + @pytest.mark.asyncio + async def test_check_supported_calls_git_info( + self, service: TeleportService + ) -> None: + service._git.get_info = AsyncMock( + return_value=GitRepoInfo( + remote_url="https://github.com/owner/repo.git", + owner="owner", + repo="repo", + branch="main", + commit="abc123", + diff="", + ) + ) + await service.check_supported() + service._git.get_info.assert_called_once() + + @pytest.mark.asyncio + async def test_check_supported_raises_when_not_supported( + self, service: TeleportService + ) -> None: + service._git.get_info = AsyncMock( + side_effect=ServiceTeleportNotSupportedError("Not a git repository") + ) + with pytest.raises(ServiceTeleportNotSupportedError): + await service.check_supported() + + +class TestTeleportServiceIsSupported: + @pytest.fixture + def service(self, tmp_path: Path) -> TeleportService: + mock_session_logger = MagicMock() + return TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="api-key", + workdir=tmp_path, + ) + + @pytest.mark.asyncio + async def test_is_supported_returns_true(self, service: TeleportService) -> None: + service._git.is_supported = AsyncMock(return_value=True) + assert await service.is_supported() is True + + @pytest.mark.asyncio + async def test_is_supported_returns_false(self, service: TeleportService) -> None: + service._git.is_supported = AsyncMock(return_value=False) + assert await service.is_supported() is False + + +class TestTeleportServiceExecute: + @pytest.fixture + def service(self, tmp_path: Path) -> TeleportService: + mock_session_logger = MagicMock() + service = TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="api-key", + workdir=tmp_path, + ) + return service + + @pytest.fixture + def git_info(self) -> GitRepoInfo: + return GitRepoInfo( + remote_url="https://github.com/owner/repo.git", + owner="owner", + repo="repo", + branch="main", + commit="abc123", + diff="", + ) + + @pytest.mark.asyncio + async def test_execute_happy_path_commit_pushed_with_token( + self, service: TeleportService, git_info: GitRepoInfo + ) -> None: + service._git.get_info = AsyncMock(return_value=git_info) + service._git.is_commit_pushed = AsyncMock(return_value=True) + + mock_github_auth = MagicMock() + mock_github_auth.get_valid_token = AsyncMock(return_value="ghp_existing_token") + service._github_auth = mock_github_auth + + mock_nuage = MagicMock() + mock_nuage.start_workflow = AsyncMock(return_value="exec-123") + mock_nuage.send_github_token = AsyncMock() + service._nuage = mock_nuage + + session = TeleportSession() + events = [] + gen = service.execute("test prompt", session) + async for event in gen: + events.append(event) + + assert isinstance(events[0], TeleportCheckingGitEvent) + assert isinstance(events[1], TeleportStartingWorkflowEvent) + assert isinstance(events[2], TeleportSendingGithubTokenEvent) + assert isinstance(events[3], TeleportCompleteEvent) + expected_url = _NUAGE_EXECUTION_URL_TEMPLATE.format( + workflow_id="workflow-id", execution_id="exec-123" + ) + assert events[3].url == expected_url + + @pytest.mark.asyncio + async def test_execute_requires_push_and_user_approves( + self, service: TeleportService, git_info: GitRepoInfo + ) -> None: + service._git.get_info = AsyncMock(return_value=git_info) + service._git.is_commit_pushed = AsyncMock(return_value=False) + service._git.get_unpushed_commit_count = AsyncMock(return_value=3) + service._git.push_current_branch = AsyncMock(return_value=True) + + mock_github_auth = MagicMock() + mock_github_auth.get_valid_token = AsyncMock(return_value="ghp_token") + service._github_auth = mock_github_auth + + mock_nuage = MagicMock() + mock_nuage.start_workflow = AsyncMock(return_value="exec-123") + mock_nuage.send_github_token = AsyncMock() + service._nuage = mock_nuage + + session = TeleportSession() + events = [] + gen = service.execute("test prompt", session) + + event = await gen.asend(None) + events.append(event) + assert isinstance(event, TeleportCheckingGitEvent) + + event = await gen.asend(None) + events.append(event) + assert isinstance(event, TeleportPushRequiredEvent) + assert event.unpushed_count == 3 + + event = await gen.asend(TeleportPushResponseEvent(approved=True)) + events.append(event) + assert isinstance(event, TeleportPushingEvent) + + async for event in gen: + events.append(event) + + assert isinstance(events[-1], TeleportCompleteEvent) + + @pytest.mark.asyncio + async def test_execute_requires_push_and_user_declines( + self, service: TeleportService, git_info: GitRepoInfo + ) -> None: + service._git.get_info = AsyncMock(return_value=git_info) + service._git.is_commit_pushed = AsyncMock(return_value=False) + service._git.get_unpushed_commit_count = AsyncMock(return_value=1) + + session = TeleportSession() + gen = service.execute("test prompt", session) + + await gen.asend(None) + await gen.asend(None) + + with pytest.raises(ServiceTeleportError, match="Teleport cancelled"): + await gen.asend(TeleportPushResponseEvent(approved=False)) + + @pytest.mark.asyncio + async def test_execute_requires_auth_flow( + self, service: TeleportService, git_info: GitRepoInfo + ) -> None: + service._git.get_info = AsyncMock(return_value=git_info) + service._git.is_commit_pushed = AsyncMock(return_value=True) + + mock_handle = MagicMock() + mock_handle.info.user_code = "ABC-123" + mock_handle.info.verification_uri = "https://github.com/login/device" + + mock_github_auth = MagicMock() + mock_github_auth.get_valid_token = AsyncMock(return_value=None) + mock_github_auth.start_device_flow = AsyncMock(return_value=mock_handle) + mock_github_auth.wait_for_token = AsyncMock(return_value="ghp_new_token") + service._github_auth = mock_github_auth + + mock_nuage = MagicMock() + mock_nuage.start_workflow = AsyncMock(return_value="exec-123") + mock_nuage.send_github_token = AsyncMock() + service._nuage = mock_nuage + + session = TeleportSession() + events = [] + gen = service.execute("test prompt", session) + async for event in gen: + events.append(event) + + assert isinstance(events[0], TeleportCheckingGitEvent) + assert isinstance(events[1], TeleportAuthRequiredEvent) + assert events[1].user_code == "ABC-123" + assert isinstance(events[2], TeleportAuthCompleteEvent) + assert isinstance(events[3], TeleportStartingWorkflowEvent) + assert isinstance(events[-1], TeleportCompleteEvent) + + @pytest.mark.asyncio + async def test_execute_uses_default_prompt_when_none( + self, service: TeleportService, git_info: GitRepoInfo + ) -> None: + service._git.get_info = AsyncMock(return_value=git_info) + service._git.is_commit_pushed = AsyncMock(return_value=True) + + mock_github_auth = MagicMock() + mock_github_auth.get_valid_token = AsyncMock(return_value="ghp_token") + service._github_auth = mock_github_auth + + mock_nuage = MagicMock() + mock_nuage.start_workflow = AsyncMock(return_value="exec-123") + mock_nuage.send_github_token = AsyncMock() + service._nuage = mock_nuage + + session = TeleportSession() + gen = service.execute(None, session) + async for _ in gen: + pass + + call_args = mock_nuage.start_workflow.call_args + assert "continue where you left off" in call_args[0][0].prompt + + +class TestTeleportServiceContextManager: + @pytest.mark.asyncio + async def test_creates_client_on_enter(self, tmp_path: Path) -> None: + mock_session_logger = MagicMock() + service = TeleportService( + session_logger=mock_session_logger, + nuage_base_url="https://api.example.com", + nuage_workflow_id="workflow-id", + nuage_api_key="api-key", + workdir=tmp_path, + ) + assert service._client is None + async with service: + assert service._client is not None + assert service._github_auth is not None + assert service._nuage is not None + assert service._client is None + + +class TestTeleportAvailability: + def test_teleport_available_is_false_when_git_not_installed(self) -> None: + with patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": "/nonexistent/git"}): + agent_loop = _reimport_agent_loop() + assert agent_loop._TELEPORT_AVAILABLE is False + + def test_teleport_service_raises_error_when_git_not_available(self) -> None: + with patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": "/nonexistent/git"}): + agent_loop = _reimport_agent_loop() + with pytest.raises(agent_loop.TeleportError, match="git to be installed"): + agent_loop.AgentLoop.teleport_service.fget( + MagicMock(_teleport_service=None) + ) + + def test_teleport_available_is_true_when_git_installed( + self, tmp_path: Path + ) -> None: + fake_git = tmp_path / "git" + fake_git.write_text("#!/bin/sh\necho 'git version 2.0.0'") + fake_git.chmod(0o755) + with patch.dict(os.environ, {"GIT_PYTHON_GIT_EXECUTABLE": str(fake_git)}): + agent_loop = _reimport_agent_loop() + assert agent_loop._TELEPORT_AVAILABLE is True diff --git a/tests/mock/utils.py b/tests/mock/utils.py index 70342e4..fb56453 100644 --- a/tests/mock/utils.py +++ b/tests/mock/utils.py @@ -51,7 +51,6 @@ def get_mocking_env(mock_chunks: list[LLMChunk] | None = None) -> dict[str, str] async def collect_result[T](gen: AsyncGenerator[ToolStreamEvent | T, None]) -> T: - """Collect the final result from an AsyncGenerator, filtering out stream events.""" result = None async for item in gen: if not isinstance(item, ToolStreamEvent): diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index 2c454dd..5e7025a 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -1,19 +1,14 @@ from __future__ import annotations from collections.abc import Callable -from pathlib import Path -import tomllib import pytest -from textual.events import Resize -from textual.geometry import Size from textual.pilot import Pilot from textual.widgets import Input -from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE +from vibe.core.paths.global_paths import GLOBAL_ENV_FILE from vibe.setup.onboarding import OnboardingApp from vibe.setup.onboarding.screens.api_key import ApiKeyScreen -from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen async def _wait_for( @@ -36,7 +31,7 @@ async def pass_welcome_screen(pilot: Pilot) -> None: lambda: not welcome_screen.query_one("#enter-hint").has_class("hidden"), pilot ) await pilot.press("enter") - await _wait_for(lambda: isinstance(pilot.app.screen, ThemeSelectionScreen), pilot) + await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot) @pytest.mark.asyncio @@ -47,8 +42,6 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None: async with app.run_test() as pilot: await pass_welcome_screen(pilot) - await pilot.press("enter") - await _wait_for(lambda: isinstance(app.screen, ApiKeyScreen), pilot) api_screen = app.screen input_widget = api_screen.query_one("#key", Input) await pilot.press(*api_key_value) @@ -63,40 +56,3 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None: env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8") assert "MISTRAL_API_KEY" in env_contents assert api_key_value in env_contents - - assert GLOBAL_CONFIG_FILE.path.is_file() - config_contents = GLOBAL_CONFIG_FILE.path.read_text(encoding="utf-8") - config_dict = tomllib.loads(config_contents) - assert config_dict.get("textual_theme") == app.theme - - -@pytest.mark.asyncio -async def test_ui_can_pick_a_theme_and_saves_selection(config_dir: Path) -> None: - app = OnboardingApp() - - async with app.run_test() as pilot: - await pass_welcome_screen(pilot) - - theme_screen = app.screen - assert isinstance(theme_screen, ThemeSelectionScreen) - app.post_message( - Resize(Size(40, 10), Size(40, 10)) - ) # trigger the resize event handler - preview = theme_screen.query_one("#preview") - assert preview.styles.max_height is not None - target_theme = "gruvbox" - # Use the screen's available themes which accounts for terminal theme availability - available_themes = theme_screen._available_themes - assert target_theme in available_themes - start_index = theme_screen._theme_index - target_index = available_themes.index(target_theme) - steps_down = (target_index - start_index) % len(available_themes) - await pilot.press(*["down"] * steps_down) - assert app.theme == target_theme - await pilot.press("enter") - await _wait_for(lambda: isinstance(app.screen, ApiKeyScreen), pilot) - - assert GLOBAL_CONFIG_FILE.path.is_file() - config_contents = GLOBAL_CONFIG_FILE.path.read_text(encoding="utf-8") - config_dict = tomllib.loads(config_contents) - assert config_dict.get("textual_theme") == target_theme diff --git a/tests/session/test_session_logger.py b/tests/session/test_session_logger.py index 376fa46..057493b 100644 --- a/tests/session/test_session_logger.py +++ b/tests/session/test_session_logger.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from tests.conftest import build_test_vibe_config from vibe.core.agents.models import AgentProfile, AgentSafety from vibe.core.config import SessionLoggingConfig, VibeConfig from vibe.core.session.session_logger import SessionLogger @@ -62,7 +63,7 @@ def mock_tool_manager() -> ToolManager: @pytest.fixture def mock_vibe_config() -> VibeConfig: """Create a mock vibe config for testing.""" - return VibeConfig(active_model="test-model", models=[], providers=[]) + return build_test_vibe_config(active_model="test-model", models=[], providers=[]) class TestSessionLoggerInitialization: @@ -171,7 +172,9 @@ class TestSessionLoggerSaveInteraction: result = await logger.save_interaction( messages=[], stats=AgentStats(), - base_config=VibeConfig(active_model="test", models=[], providers=[]), + base_config=build_test_vibe_config( + active_model="test", models=[], providers=[] + ), tool_manager=MagicMock(), agent_profile=AgentProfile( name="test", diff --git a/tests/skills/conftest.py b/tests/skills/conftest.py index c82b1b3..4bde6a3 100644 --- a/tests/skills/conftest.py +++ b/tests/skills/conftest.py @@ -5,7 +5,8 @@ from pathlib import Path import pytest import yaml -from vibe.core.config import SessionLoggingConfig, VibeConfig +from tests.conftest import build_test_vibe_config +from vibe.core.config import VibeConfig @pytest.fixture @@ -18,8 +19,7 @@ def skills_dir(tmp_path: Path) -> Path: @pytest.fixture def skill_config(skills_dir: Path) -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + return build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py index f05452a..68cf154 100644 --- a/tests/skills/test_manager.py +++ b/tests/skills/test_manager.py @@ -4,17 +4,16 @@ from pathlib import Path import pytest +from tests.conftest import build_test_vibe_config from tests.skills.conftest import create_skill -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.skills.manager import SkillManager @pytest.fixture def config() -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, + return build_test_vibe_config( + system_prompt_id="tests", include_project_context=False ) @@ -32,8 +31,7 @@ class TestSkillManagerDiscovery: def test_discovers_skill_from_skill_paths(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -48,8 +46,7 @@ class TestSkillManagerDiscovery: create_skill(skills_dir, "skill-two", "Second skill") create_skill(skills_dir, "skill-three", "Third skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -70,8 +67,7 @@ class TestSkillManagerDiscovery: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -90,8 +86,7 @@ class TestSkillManagerDiscovery: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -115,8 +110,7 @@ class TestSkillManagerParsing: allowed_tools="bash read_file", ) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -135,8 +129,7 @@ class TestSkillManagerParsing: def test_sets_correct_skill_path(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -157,8 +150,7 @@ class TestSkillManagerParsing: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -179,8 +171,7 @@ class TestSkillManagerParsing: # Create a valid skill create_skill(skills_dir, "valid-skill", "A valid skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -203,8 +194,7 @@ class TestSkillManagerSearchPaths: skills_dir_2.mkdir() create_skill(skills_dir_2, "skill-from-dir2", "Skill from directory 2") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir_1, skills_dir_2], @@ -226,8 +216,7 @@ class TestSkillManagerSearchPaths: skills_dir_2.mkdir() create_skill(skills_dir_2, "duplicate-skill", "Second version") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir_1, skills_dir_2], @@ -243,8 +232,7 @@ class TestSkillManagerSearchPaths: skills_dir.mkdir() create_skill(skills_dir, "valid-skill", "A valid skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir, tmp_path / "nonexistent"], @@ -259,8 +247,7 @@ class TestSkillManagerGetSkill: def test_returns_skill_by_name(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -281,8 +268,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "skill-b", "Skill B") create_skill(skills_dir, "skill-c", "Skill C") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -301,8 +287,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "skill-b", "Skill B") create_skill(skills_dir, "skill-c", "Skill C") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -322,8 +307,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "skill-a", "Skill A") create_skill(skills_dir, "skill-b", "Skill B") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -341,8 +325,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "search-docs", "Search docs") create_skill(skills_dir, "other-skill", "Other skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -361,8 +344,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "skill-v2", "Skill v2") create_skill(skills_dir, "other-skill", "Other skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -380,8 +362,7 @@ class TestSkillManagerFiltering: create_skill(skills_dir, "enabled-skill", "Enabled") create_skill(skills_dir, "disabled-skill", "Disabled") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -397,8 +378,7 @@ class TestSkillUserInvocable: def test_user_invocable_defaults_to_true(self, skills_dir: Path) -> None: create_skill(skills_dir, "default-skill", "A default skill") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -412,8 +392,7 @@ class TestSkillUserInvocable: def test_user_invocable_can_be_set_to_false(self, skills_dir: Path) -> None: create_skill(skills_dir, "hidden-skill", "A hidden skill", user_invocable=False) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -431,8 +410,7 @@ class TestSkillUserInvocable: skills_dir, "explicit-skill", "An explicit skill", user_invocable=True ) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], @@ -448,8 +426,7 @@ class TestSkillUserInvocable: create_skill(skills_dir, "hidden-skill", "Hidden", user_invocable=False) create_skill(skills_dir, "default-skill", "Default") - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, skill_paths=[skills_dir], diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg index 9b7d8c9..4f4b2d4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg @@ -33,13 +33,9 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #a6a087 } -.terminal-r5 { fill: #a9a9a9 } -.terminal-r6 { fill: #85a598;font-weight: bold } -.terminal-r7 { fill: #7e7e7e } -.terminal-r8 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #9a9b99 } @@ -113,28 +109,28 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + + + -3 answers received (ctrl+o to expand) + -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg index d6c6954..4407581 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg @@ -33,13 +33,9 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #a6a087 } -.terminal-r5 { fill: #a9a9a9 } -.terminal-r6 { fill: #85a598;font-weight: bold } -.terminal-r7 { fill: #7e7e7e } -.terminal-r8 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #9a9b99 } @@ -143,23 +139,23 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - -What programming language are you currently working with? -Rust -What type of project are you building? -Web Application -What editor or IDE do you prefer? -(Other) VS Code +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +What programming language are you currently working with? +Rust +What type of project are you building? +Web Application +What editor or IDE do you prefer? +(Other) VS Code + + + + + @@ -168,13 +164,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg index 8222082..47e2747 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg @@ -33,14 +33,10 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #85a598;font-weight: bold } -.terminal-r5 { fill: #fbf1c7;font-weight: bold } -.terminal-r6 { fill: #a9a9a9 } -.terminal-r7 { fill: #a6a087 } -.terminal-r8 { fill: #7e7e7e } -.terminal-r9 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } @@ -162,22 +158,22 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Hello there, who are you? + +I'm the Vibe agent and I'm ready to help. -Hello there, who are you? + -● I'm the Vibe agent and I'm ready to help. + @@ -193,13 +189,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir6% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir6% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg index da5f054..207a183 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg @@ -33,22 +33,14 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #feab67;text-decoration: underline; } -.terminal-r5 { fill: #ffffff } -.terminal-r6 { fill: #a7a7a7 } -.terminal-r7 { fill: #bec067 } -.terminal-r8 { fill: #d2d2d2 } -.terminal-r9 { fill: #181818 } -.terminal-r10 { fill: #242424 } -.terminal-r11 { fill: #43504b } -.terminal-r12 { fill: #f4a463 } -.terminal-r13 { fill: #a9a9a9 } -.terminal-r14 { fill: #a6a087 } -.terminal-r15 { fill: #85a598;font-weight: bold } -.terminal-r16 { fill: #7e7e7e } -.terminal-r17 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #9a9b99;font-style: italic; } +.terminal-r5 { fill: #98a84b } +.terminal-r6 { fill: #d2d2d2 } +.terminal-r7 { fill: #292929 } +.terminal-r8 { fill: #4b4e55 } +.terminal-r9 { fill: #9a9b99 } @@ -170,28 +162,28 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - -● Here's a very long print instruction: +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Here's a very long print instruction: + +(): +ery long line (Lorem Ipsum) +m ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna al +▋             - -um(): - very long line (Lorem Ipsum) -rem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magn +The print statement includes a very long line of Lorem Ipsum text to demonstrate a lengthy output. + + + - + -The print statement includes a very long line of Lorem Ipsum text to demonstrate a lengthy output. + @@ -201,13 +193,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg index 88d00f8..eca1c2c 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg @@ -33,12 +33,10 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #fd8019 } -.terminal-r5 { fill: #85a598;font-weight: bold } -.terminal-r6 { fill: #7e7e7e } -.terminal-r7 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #d0b344 } +.terminal-r5 { fill: #9a9b99 } @@ -160,16 +158,16 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + + + @@ -191,13 +189,13 @@ -⏵⏵ accept edits agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌─────────────────────────────────────────────────────────────────────────────────────────────────────── accept edits ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg index c2eb202..b3d7e29 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg @@ -33,12 +33,10 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #fa4934 } -.terminal-r5 { fill: #85a598;font-weight: bold } -.terminal-r6 { fill: #7e7e7e } -.terminal-r7 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #cc555a } +.terminal-r5 { fill: #9a9b99 } @@ -160,16 +158,16 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + + + @@ -191,13 +189,13 @@ -⏵⏵⏵ auto approve agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌─────────────────────────────────────────────────────────────────────────────────────────────────────── auto approve ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg index 0b1f5f5..2690429 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg @@ -33,12 +33,10 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #b7bb26 } -.terminal-r5 { fill: #85a598;font-weight: bold } -.terminal-r6 { fill: #7e7e7e } -.terminal-r7 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #98a84b } +.terminal-r5 { fill: #9a9b99 } @@ -160,16 +158,16 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + + + @@ -191,13 +189,13 @@ -⏸ plan agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────── plan ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg index b380245..a2c12d0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg @@ -33,13 +33,9 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #a9a9a9 } -.terminal-r5 { fill: #a6a087 } -.terminal-r6 { fill: #85a598;font-weight: bold } -.terminal-r7 { fill: #7e7e7e } -.terminal-r8 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #9a9b99 } @@ -161,16 +157,16 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + + + @@ -192,13 +188,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg index b380245..a2c12d0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg @@ -33,13 +33,9 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #a9a9a9 } -.terminal-r5 { fill: #a6a087 } -.terminal-r6 { fill: #85a598;font-weight: bold } -.terminal-r7 { fill: #7e7e7e } -.terminal-r8 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #9a9b99 } @@ -161,16 +157,16 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + + + @@ -192,13 +188,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_no_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_no_plan_offer.svg deleted file mode 100644 index 8c5588c..0000000 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_no_plan_offer.svg +++ /dev/null @@ -1,204 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SnapshotAppPlanOfferNone - - - - - - - - - - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - - - - - - - - - - - - - - - - - - - - -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - - - - diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_switch_key_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_switch_key_plan_offer.svg deleted file mode 100644 index d99022b..0000000 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_switch_key_plan_offer.svg +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SnapshotAppPlanOfferSwitchKey - - - - - - - - - - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - -Switch to your Pro API key - - - - - - - - - - - - - - - - - - - -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - - - - diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_upgrade_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_upgrade_plan_offer.svg deleted file mode 100644 index 0e15b93..0000000 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_upgrade_plan_offer.svg +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SnapshotAppPlanOfferUpgrade - - - - - - - - - - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - -Upgrade to Pro - - - - - - - - - - - - - - - - - - - -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - - - - diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_whats_new_and_plan_offer.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_whats_new_and_plan_offer.svg deleted file mode 100644 index 0fa2c1a..0000000 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_plan_offer/test_snapshot_shows_whats_new_and_plan_offer.svg +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SnapshotAppWhatsNewAndPlanOffer - - - - - - - - - - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - - -What's New - -• Feature 1 -• Feature 2 - - -Upgrade to Pro - - - - - - - - - - - -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - - - - diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg index 8ad566d..8d63062 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } -.terminal-r4 { fill: #0178d4;font-weight: bold } -.terminal-r5 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff } +.terminal-r4 { fill: #9d65ff;font-weight: bold } +.terminal-r5 { fill: #d9d9d9 } @@ -110,19 +110,19 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐  DB ✓   [Framework] Which framework? › 1. FastAPI   2. Django -  3. Type your answer... +  3. Type your answer... ←→ questions  ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg index 8ad566d..8d63062 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } -.terminal-r4 { fill: #0178d4;font-weight: bold } -.terminal-r5 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff } +.terminal-r4 { fill: #9d65ff;font-weight: bold } +.terminal-r5 { fill: #d9d9d9 } @@ -110,19 +110,19 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐  DB ✓   [Framework] Which framework? › 1. FastAPI   2. Django -  3. Type your answer... +  3. Type your answer... ←→ questions  ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg index 1ef74ba..cf35a14 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } -.terminal-r4 { fill: #0178d4;font-weight: bold } -.terminal-r5 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff } +.terminal-r4 { fill: #9d65ff;font-weight: bold } +.terminal-r5 { fill: #d9d9d9 } @@ -110,19 +110,19 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ [DB]   Framework  Which database? › 1. PostgreSQL   2. MongoDB -  3. Type your answer... +  3. Type your answer... ←→ questions  ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg index 469de27..646e09a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } -.terminal-r4 { fill: #0178d4;font-weight: bold } -.terminal-r5 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff } +.terminal-r4 { fill: #9d65ff;font-weight: bold } +.terminal-r5 { fill: #d9d9d9 } @@ -110,19 +110,19 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐  DB   [Framework] Which framework? › 1. FastAPI   2. Django -  3. Type your answer... +  3. Type your answer... ←→ questions  ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg index 469de27..646e09a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } -.terminal-r4 { fill: #0178d4;font-weight: bold } -.terminal-r5 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff } +.terminal-r4 { fill: #9d65ff;font-weight: bold } +.terminal-r5 { fill: #d9d9d9 } @@ -110,19 +110,19 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐  DB   [Framework] Which framework? › 1. FastAPI   2. Django -  3. Type your answer... +  3. Type your answer... ←→ questions  ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg index 1ef74ba..cf35a14 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } -.terminal-r4 { fill: #0178d4;font-weight: bold } -.terminal-r5 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff } +.terminal-r4 { fill: #9d65ff;font-weight: bold } +.terminal-r5 { fill: #d9d9d9 } @@ -110,19 +110,19 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ [DB]   Framework  Which database? › 1. PostgreSQL   2. MongoDB -  3. Type your answer... +  3. Type your answer... ←→ questions  ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg index e3eeb65..548b2c2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg @@ -32,10 +32,10 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } @@ -109,20 +109,20 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? › 1. [ ] Authentication - User login/logout   2. [ ] Caching - Redis caching layer   3. [ ] Logging - Structured logging -  4. [ ] Type your answer... +  4. [ ] Type your answer... -     Submit → +     Submit → ↑↓ navigate  Enter toggle  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg index 172fb71..429b0ca 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg @@ -32,12 +32,13 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #e0e0e0;font-weight: bold } -.terminal-r6 { fill: #121212 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } +.terminal-r5 { fill: #d9d9d9;font-weight: bold } +.terminal-r6 { fill: #e0e0e0 } +.terminal-r7 { fill: #121212 } @@ -113,18 +114,18 @@ - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable?   1. [x] Authentication - User login/logout   2. [ ] Caching - Redis caching layer   3. [x] Logging - Structured logging -› 4. [x] Extra +› 4. [x] Extra -     Submit → +     Submit → ↑↓ navigate  Enter toggle  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg index afe89ba..b98ed4c 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #8d8d8d;font-weight: bold } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } +.terminal-r5 { fill: #d9d9d9;font-weight: bold } @@ -110,20 +110,20 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable?   1. [ ] Authentication - User login/logout   2. [ ] Caching - Redis caching layer   3. [ ] Logging - Structured logging -  4. [ ] Type your answer... +  4. [ ] Type your answer... ›    Submit → ↑↓ navigate  Enter toggle  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg index a064076..d8e1c39 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg @@ -32,12 +32,13 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #e0e0e0;font-weight: bold } -.terminal-r6 { fill: #121212 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } +.terminal-r5 { fill: #d9d9d9;font-weight: bold } +.terminal-r6 { fill: #e0e0e0 } +.terminal-r7 { fill: #121212 } @@ -113,18 +114,18 @@ - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable?   1. [ ] Authentication - User login/logout   2. [ ] Caching - Redis caching layer   3. [ ] Logging - Structured logging -› 4. [x] Custom feature +› 4. [x] Custom feature -     Submit → +     Submit → ↑↓ navigate  Enter toggle  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg index b3fb453..063283e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg @@ -32,10 +32,10 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } @@ -109,20 +109,20 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? › 1. [x] Authentication - User login/logout   2. [ ] Caching - Redis caching layer   3. [ ] Logging - Structured logging -  4. [ ] Type your answer... +  4. [ ] Type your answer... -     Submit → +     Submit → ↑↓ navigate  Enter toggle  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg index 6ee8754..1b1fcbe 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg @@ -32,10 +32,10 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } @@ -109,20 +109,20 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable?   1. [x] Authentication - User login/logout   2. [ ] Caching - Redis caching layer › 3. [x] Logging - Structured logging -  4. [ ] Type your answer... +  4. [ ] Type your answer... -     Submit → +     Submit → ↑↓ navigate  Enter toggle  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg index e3eeb65..548b2c2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg @@ -32,10 +32,10 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } @@ -109,20 +109,20 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? › 1. [ ] Authentication - User login/logout   2. [ ] Caching - Redis caching layer   3. [ ] Logging - Structured logging -  4. [ ] Type your answer... +  4. [ ] Type your answer... -     Submit → +     Submit → ↑↓ navigate  Enter toggle  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg index 464defb..cef48fd 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg @@ -32,10 +32,10 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } @@ -109,18 +109,18 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project? › 1. PostgreSQL - Relational database   2. MongoDB - Document database   3. Redis - In-memory store -  4. Type your answer... +  4. Type your answer... ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg index f721210..8105a30 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg @@ -32,10 +32,10 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } @@ -109,18 +109,18 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project?   1. PostgreSQL - Relational database › 2. MongoDB - Document database   3. Redis - In-memory store -  4. Type your answer... +  4. Type your answer... ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg index a9a21e4..cf5cb0b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg @@ -32,13 +32,14 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #e0e0e0;font-weight: bold } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } +.terminal-r5 { fill: #d9d9d9;font-weight: bold } .terminal-r6 { fill: #121212 } .terminal-r7 { fill: #6c6c6c } +.terminal-r8 { fill: #e0e0e0 } @@ -114,7 +115,7 @@ - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project?   1. PostgreSQL - Relational database @@ -123,7 +124,7 @@ › 4. Type your answer... ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg index 312f57b..66c56ce 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg @@ -32,10 +32,10 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } @@ -109,18 +109,18 @@ - + - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project?   1. PostgreSQL - Relational database   2. MongoDB - Document database › 3. Redis - In-memory store -  4. Type your answer... +  4. Type your answer... ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg index a9a21e4..cf5cb0b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg @@ -32,13 +32,14 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #e0e0e0;font-weight: bold } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } +.terminal-r5 { fill: #d9d9d9;font-weight: bold } .terminal-r6 { fill: #121212 } .terminal-r7 { fill: #6c6c6c } +.terminal-r8 { fill: #e0e0e0 } @@ -114,7 +115,7 @@ - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project?   1. PostgreSQL - Relational database @@ -123,7 +124,7 @@ › 4. Type your answer... ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg index 8198c59..a375a86 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg @@ -32,12 +32,13 @@ font-family: arial; } - .terminal-r1 { fill: #8d8d8d } + .terminal-r1 { fill: #625e4c } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4;font-weight: bold } -.terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #e0e0e0;font-weight: bold } -.terminal-r6 { fill: #121212 } +.terminal-r3 { fill: #9d65ff;font-weight: bold } +.terminal-r4 { fill: #d9d9d9 } +.terminal-r5 { fill: #d9d9d9;font-weight: bold } +.terminal-r6 { fill: #e0e0e0 } +.terminal-r7 { fill: #121212 } @@ -113,16 +114,16 @@ - ╭──────────────────────────────────────────────────────────────────────────────╮ + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project?   1. PostgreSQL - Relational database   2. MongoDB - Document database   3. Redis - In-memory store -› 4. SQLite +› 4. SQLite ↑↓ navigate  Enter select  Esc cancel -╰──────────────────────────────────────────────────────────────────────────────╯ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg index 40825f3..b935e92 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg @@ -33,16 +33,12 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #85a598;font-weight: bold } -.terminal-r5 { fill: #fbf1c7;font-weight: bold } -.terminal-r6 { fill: #d0d26f } -.terminal-r7 { fill: #a9a9a9;font-style: italic; } -.terminal-r8 { fill: #a9a9a9 } -.terminal-r9 { fill: #a6a087 } -.terminal-r10 { fill: #7e7e7e } -.terminal-r11 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #98a84b } +.terminal-r6 { fill: #9a9b99;font-style: italic; } +.terminal-r7 { fill: #9a9b99 } @@ -164,24 +160,24 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Give me an answer + +Thought - -Give me an answer +Here is my carefully considered answer. I hope this helps! + -Thought + -● Here is my carefully considered answer. I hope this helps! + @@ -195,13 +191,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg index 68f7abf..335de97 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg @@ -33,16 +33,12 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #85a598;font-weight: bold } -.terminal-r5 { fill: #fbf1c7;font-weight: bold } -.terminal-r6 { fill: #d0d26f } -.terminal-r7 { fill: #a9a9a9;font-style: italic; } -.terminal-r8 { fill: #a9a9a9 } -.terminal-r9 { fill: #a6a087 } -.terminal-r10 { fill: #7e7e7e } -.terminal-r11 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #98a84b } +.terminal-r6 { fill: #9a9b99;font-style: italic; } +.terminal-r7 { fill: #9a9b99 } @@ -164,28 +160,28 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Explain this to me + +Thought - -Explain this to me - +Here's the first part of the answer. + +Thought -Thought +And here's the conclusion! -● Here's the first part of the answer. + -Thought + -● And here's the conclusion! + @@ -195,13 +191,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg index 801838a..b60a7cd 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg @@ -33,16 +33,12 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #85a598;font-weight: bold } -.terminal-r5 { fill: #fbf1c7;font-weight: bold } -.terminal-r6 { fill: #d0d26f } -.terminal-r7 { fill: #a9a9a9;font-style: italic; } -.terminal-r8 { fill: #a9a9a9 } -.terminal-r9 { fill: #a6a087 } -.terminal-r10 { fill: #7e7e7e } -.terminal-r11 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #98a84b } +.terminal-r6 { fill: #9a9b99;font-style: italic; } +.terminal-r7 { fill: #9a9b99 } @@ -164,24 +160,24 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +What is the answer? + +Thought - -What is the answer? +The answer to your question is 42. This is the ultimate answer. + -Thought + -● The answer to your question is 42. This is the ultimate answer. + @@ -195,13 +191,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg index 2e11cb6..84fcc37 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg @@ -33,16 +33,12 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #85a598;font-weight: bold } -.terminal-r5 { fill: #fbf1c7;font-weight: bold } -.terminal-r6 { fill: #d0d26f } -.terminal-r7 { fill: #a9a9a9;font-style: italic; } -.terminal-r8 { fill: #a9a9a9 } -.terminal-r9 { fill: #a6a087 } -.terminal-r10 { fill: #7e7e7e } -.terminal-r11 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #98a84b } +.terminal-r6 { fill: #9a9b99;font-style: italic; } +.terminal-r7 { fill: #9a9b99 } @@ -164,25 +160,25 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +What is the answer? + +Thought +Let me think about this step by step... First, I need to understand the question. Then I can formulate a response. -What is the answer? +The answer to your question is 42. This is the ultimate answer. -Thought -Let me think about this step by step... First, I need to understand the question. Then I can formulate a response. + + -● The answer to your question is 42. This is the ultimate answer. + @@ -195,13 +191,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg index 30006a2..4036afb 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg @@ -33,17 +33,13 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #fd8019 } -.terminal-r5 { fill: #85a598;font-weight: bold } -.terminal-r6 { fill: #aec3bb } -.terminal-r7 { fill: #b7bb26 } -.terminal-r8 { fill: #d0d26f;font-weight: bold } -.terminal-r9 { fill: #a9a9a9 } -.terminal-r10 { fill: #a6a087 } -.terminal-r11 { fill: #7e7e7e } -.terminal-r12 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #608ab1;font-weight: bold } +.terminal-r6 { fill: #98a84b } +.terminal-r7 { fill: #56ab56;font-weight: bold } +.terminal-r8 { fill: #9a9b99 } @@ -165,24 +161,24 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +What's New +• Feature 1 +• Feature 2 + - - -What's New - -• Feature 1 -• Feature 2 - + + + + + + + @@ -192,17 +188,17 @@ - -Update available -1.0.4 => 1000.2.0 -Please update mistral-vibe with your package manager -e) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +Update available +1.0.4 => 1000.2.0 +Please update mistral-vibe with your package manager + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg index 572238a..d836ccf 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg @@ -33,15 +33,11 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #85a598;font-weight: bold } -.terminal-r5 { fill: #fbf1c7;font-weight: bold } -.terminal-r6 { fill: #d0d26f } -.terminal-r7 { fill: #a6a087 } -.terminal-r8 { fill: #a9a9a9 } -.terminal-r9 { fill: #7e7e7e } -.terminal-r10 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #98a84b } +.terminal-r6 { fill: #9a9b99 } @@ -163,25 +159,25 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Hello, how are you? + +I'm doing well, thank you! Let me read that file for you. - -Hello, how are you? +read_file + -● I'm doing well, thank you! Let me read that file for you. + -read_file -read_file completed (ctrl+o to expand) + + @@ -194,13 +190,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_cancel_selected.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_cancel_selected.svg new file mode 100644 index 0000000..0cb4a50 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_cancel_selected.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportPushConfirmationMultipleCommitsApp + + + + + + + + + + ┌──────────────────────────────────────────────────────────────────────────────┐ +You have 5 unpushed commits. Push to continue? + +  1. Push and continue +› 2. Cancel + +↑↓ navigate  Enter select  Esc cancel +└──────────────────────────────────────────────────────────────────────────────┘ + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_multiple_commits.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_multiple_commits.svg new file mode 100644 index 0000000..33d1334 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_multiple_commits.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportPushConfirmationMultipleCommitsApp + + + + + + + + + + ┌──────────────────────────────────────────────────────────────────────────────┐ +You have 5 unpushed commits. Push to continue? + +› 1. Push and continue +  2. Cancel + +↑↓ navigate  Enter select  Esc cancel +└──────────────────────────────────────────────────────────────────────────────┘ + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_single_commit.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_single_commit.svg new file mode 100644 index 0000000..6757c58 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_single_commit.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportPushConfirmationSingleCommitApp + + + + + + + + + + ┌──────────────────────────────────────────────────────────────────────────────┐ +You have 1 unpushed commit. Push to continue? + +› 1. Push and continue +  2. Cancel + +↑↓ navigate  Enter select  Esc cancel +└──────────────────────────────────────────────────────────────────────────────┘ + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_auth_complete.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_auth_complete.svg new file mode 100644 index 0000000..2c03617 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_auth_complete.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessageAuthCompleteApp + + + + + + + + + + GitHub authenticated. + + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_auth_required.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_auth_required.svg new file mode 100644 index 0000000..0286c4a --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_auth_required.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessageAuthRequiredApp + + + + + + + + + + GitHub auth required. Code: ABCD-1234 (copied) +Open: https://github.com/login/device + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_checking_git.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_checking_git.svg new file mode 100644 index 0000000..0c713cd --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_checking_git.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessageCheckingGitApp + + + + + + + + + + Checking git status... + + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg new file mode 100644 index 0000000..d46b637 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessageCompleteApp + + + + + + + + + + Teleported to Nuage: https://chat.example.com + + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_error.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_error.svg new file mode 100644 index 0000000..5b34dec --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_error.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessageErrorApp + + + + + + + + + + Teleport failed: Git repository has uncommitted changes + + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_pushing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_pushing.svg new file mode 100644 index 0000000..052a717 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_pushing.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessagePushingApp + + + + + + + + + + Pushing to remote... + + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_sending_token.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_sending_token.svg new file mode 100644 index 0000000..66ec512 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_sending_token.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessageSendingTokenApp + + + + + + + + + + Sending encrypted GitHub token... + + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_starting_workflow.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_starting_workflow.svg new file mode 100644 index 0000000..b77bc80 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_starting_workflow.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportMessageStartingWorkflowApp + + + + + + + + + + Starting Nuage workflow... + + + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg new file mode 100644 index 0000000..ee2e31a --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithWhatsNewNoPlanCTA + + + + + + + + + +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +What's New +• Feature 1 +• Feature 2 +• Feature 3 + + + + + + + + + + + + + + + + + + + + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg new file mode 100644 index 0000000..48473d8 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithSwitchKeyCTA + + + + + + + + + +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +What's New + +• Feature 1 +• Feature 2 +• Feature 3 + +Switch to your Le Chat Pro API key + + + + + + + + + + + + + + + + + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg new file mode 100644 index 0000000..5569165 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithPlanUpgradeCTA + + + + + + + + + +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +What's New + +• Feature 1 +• Feature 2 +• Feature 3 + +Unlock more with Vibe - Upgrade to Le Chat Pro + + + + + + + + + + + + + + + + + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg index 114a6f5..ae1a597 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg @@ -33,15 +33,11 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #3c3836 } -.terminal-r3 { fill: #fbf1c7 } -.terminal-r4 { fill: #fd8019 } -.terminal-r5 { fill: #85a598;font-weight: bold } -.terminal-r6 { fill: #aec3bb } -.terminal-r7 { fill: #a9a9a9 } -.terminal-r8 { fill: #a6a087 } -.terminal-r9 { fill: #7e7e7e } -.terminal-r10 { fill: #85a598 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #608ab1;font-weight: bold } +.terminal-r6 { fill: #9a9b99 } @@ -163,25 +159,25 @@ - + - -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - - - - - -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +What's New +• Feature 1 +• Feature 2 +• Feature 3 - - -What's New - -• Feature 1 -• Feature 2 -• Feature 3 - + + + + + + + + @@ -194,13 +190,13 @@ -⏵ default agent (shift+tab to cycle) - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ->Ask anything... -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/base_snapshot_test_app.py b/tests/snapshots/base_snapshot_test_app.py index d7dc5df..448360d 100644 --- a/tests/snapshots/base_snapshot_test_app.py +++ b/tests/snapshots/base_snapshot_test_app.py @@ -4,28 +4,25 @@ from rich.style import Style from textual.widgets.text_area import TextAreaTheme from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway +from tests.conftest import build_test_agent_loop, build_test_vibe_config from tests.stubs.fake_backend import FakeBackend +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse from vibe.cli.textual_ui.app import VibeApp from vibe.cli.textual_ui.widgets.chat_input import ChatTextArea -from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config import VibeConfig def default_config() -> VibeConfig: """Default configuration for snapshot testing. Remove as much interference as possible from the snapshot comparison, in order to get a clean pixel-to-pixel comparison. - Injects a fake backend to prevent (or stub) LLM calls. - - Disables the welcome banner animation. + - Disables the banner animation. - Forces a value for the displayed workdir - Hides the chat input cursor (as the blinking animation is not deterministic). """ - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - textual_theme="gruvbox", - disable_welcome_banner_animation=True, - displayed_workdir="/test/workdir", - enable_update_checks=False, + return build_test_vibe_config( + disable_welcome_banner_animation=True, displayed_workdir="/test/workdir" ) @@ -39,16 +36,23 @@ class BaseSnapshotTestApp(VibeApp): backend: FakeBackend | None = None, **kwargs, ): - config = config or default_config() - - agent_loop = AgentLoop( - config, + agent_loop = build_test_agent_loop( + config=config or default_config(), agent_name=self._current_agent_name, - enable_streaming=kwargs.get("enable_streaming", False), + enable_streaming=bool(kwargs.get("enable_streaming", False)), backend=backend or FakeBackend(), ) - plan_offer_gateway = kwargs.pop("plan_offer_gateway", FakeWhoAmIGateway()) + plan_offer_gateway = kwargs.pop( + "plan_offer_gateway", + FakeWhoAmIGateway( + WhoAmIResponse( + is_pro_plan=True, + advertise_pro_plan=False, + prompt_switching_to_pro_plan=False, + ) + ), + ) super().__init__( agent_loop=agent_loop, plan_offer_gateway=plan_offer_gateway, **kwargs diff --git a/tests/snapshots/test_ui_snapshot_plan_offer.py b/tests/snapshots/test_ui_snapshot_plan_offer.py deleted file mode 100644 index 4e941ad..0000000 --- a/tests/snapshots/test_ui_snapshot_plan_offer.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -import time -from unittest.mock import patch - -from textual.pilot import Pilot - -from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway -from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config -from tests.snapshots.snap_compare import SnapCompare -from tests.update_notifier.adapters.fake_update_cache_repository import ( - FakeUpdateCacheRepository, -) -from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse -from vibe.cli.update_notifier import UpdateCache - - -class PlanOfferSnapshotApp(BaseSnapshotTestApp): - def __init__(self, gateway: FakeWhoAmIGateway): - self._previous_api_key = os.environ.get("MISTRAL_API_KEY") - os.environ["MISTRAL_API_KEY"] = "snapshot-api-key" - super().__init__( - config=default_config(), - plan_offer_gateway=gateway, - update_cache_repository=FakeUpdateCacheRepository(), - ) - - def on_unmount(self) -> None: - if self._previous_api_key is None: - os.environ.pop("MISTRAL_API_KEY", None) - else: - os.environ["MISTRAL_API_KEY"] = self._previous_api_key - return None - - -class SnapshotAppPlanOfferUpgrade(PlanOfferSnapshotApp): - def __init__(self) -> None: - gateway = FakeWhoAmIGateway( - WhoAmIResponse( - is_pro_plan=False, - advertise_pro_plan=True, - prompt_switching_to_pro_plan=False, - ) - ) - super().__init__(gateway=gateway) - - -class SnapshotAppPlanOfferSwitchKey(PlanOfferSnapshotApp): - def __init__(self) -> None: - gateway = FakeWhoAmIGateway( - WhoAmIResponse( - is_pro_plan=False, - advertise_pro_plan=False, - prompt_switching_to_pro_plan=True, - ) - ) - super().__init__(gateway=gateway) - - -class SnapshotAppPlanOfferNone(PlanOfferSnapshotApp): - def __init__(self) -> None: - gateway = FakeWhoAmIGateway( - WhoAmIResponse( - is_pro_plan=True, - advertise_pro_plan=False, - prompt_switching_to_pro_plan=False, - ) - ) - super().__init__(gateway=gateway) - - -class SnapshotAppWhatsNewAndPlanOffer(PlanOfferSnapshotApp): - def __init__(self) -> None: - gateway = FakeWhoAmIGateway( - WhoAmIResponse( - is_pro_plan=False, - advertise_pro_plan=True, - prompt_switching_to_pro_plan=False, - ) - ) - super().__init__(gateway=gateway) - cache = UpdateCache( - latest_version="1.0.0", - stored_at_timestamp=int(time.time()), - seen_whats_new_version=None, - ) - self._update_cache_repository = FakeUpdateCacheRepository(update_cache=cache) - self._current_version = "1.0.0" - - -async def _pause_for_plan_offer_task(pilot: Pilot) -> None: - await pilot.pause(0.1) - - -def test_snapshot_shows_upgrade_plan_offer(snap_compare: SnapCompare) -> None: - assert snap_compare( - "test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferUpgrade", - terminal_size=(120, 36), - run_before=_pause_for_plan_offer_task, - ) - - -def test_snapshot_shows_switch_key_plan_offer(snap_compare: SnapCompare) -> None: - assert snap_compare( - "test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferSwitchKey", - terminal_size=(120, 36), - run_before=_pause_for_plan_offer_task, - ) - - -def test_snapshot_shows_no_plan_offer(snap_compare: SnapCompare) -> None: - assert snap_compare( - "test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferNone", - terminal_size=(120, 36), - run_before=_pause_for_plan_offer_task, - ) - - -def test_snapshot_shows_whats_new_and_plan_offer( - snap_compare: SnapCompare, tmp_path: Path -) -> None: - whats_new_file = tmp_path / "whats_new.md" - whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2") - - with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): - assert snap_compare( - "test_ui_snapshot_plan_offer.py:SnapshotAppWhatsNewAndPlanOffer", - terminal_size=(120, 36), - run_before=_pause_for_plan_offer_task, - ) diff --git a/tests/snapshots/test_ui_snapshot_reasoning_content.py b/tests/snapshots/test_ui_snapshot_reasoning_content.py index 52b0396..34fab11 100644 --- a/tests/snapshots/test_ui_snapshot_reasoning_content.py +++ b/tests/snapshots/test_ui_snapshot_reasoning_content.py @@ -2,12 +2,12 @@ from __future__ import annotations from textual.pilot import Pilot +from tests.conftest import build_test_agent_loop from tests.mock.utils import mock_llm_chunk from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config from tests.snapshots.snap_compare import SnapCompare from tests.stubs.fake_backend import FakeBackend from vibe.cli.textual_ui.widgets.messages import ReasoningMessage -from vibe.core.agent_loop import AgentLoop class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp): @@ -31,8 +31,8 @@ class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp): ] ) super().__init__(config=config) - self.agent_loop = AgentLoop( - config, + self.agent_loop = build_test_agent_loop( + config=config, agent_name=self._current_agent_name, enable_streaming=True, backend=fake_backend, @@ -57,8 +57,8 @@ class SnapshotTestAppWithInterleavedReasoning(BaseSnapshotTestApp): ] ) super().__init__(config=config) - self.agent_loop = AgentLoop( - config, + self.agent_loop = build_test_agent_loop( + config=config, agent_name=self._current_agent_name, enable_streaming=True, backend=fake_backend, @@ -123,8 +123,8 @@ class SnapshotTestAppWithBufferedReasoningTransition(BaseSnapshotTestApp): ] ) super().__init__(config=config) - self.agent_loop = AgentLoop( - config, + self.agent_loop = build_test_agent_loop( + config=config, agent_name=self._current_agent_name, enable_streaming=True, backend=fake_backend, diff --git a/tests/snapshots/test_ui_snapshot_teleport.py b/tests/snapshots/test_ui_snapshot_teleport.py new file mode 100644 index 0000000..57bec5b --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_teleport.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.pilot import Pilot + +from tests.snapshots.snap_compare import SnapCompare +from vibe.cli.textual_ui.widgets.question_app import QuestionApp +from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage +from vibe.core.tools.builtins.ask_user_question import ( + AskUserQuestionArgs, + Choice, + Question, +) + + +class TeleportMessageTestApp(App): + CSS_PATH = "../../vibe/cli/textual_ui/app.tcss" + + def compose(self) -> ComposeResult: + with Container(): + yield TeleportMessage() + + +class TeleportMessageCheckingGitApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_status("Checking git status...") + if widget._spinner_timer: + widget._spinner_timer.stop() + + +class TeleportMessagePushingApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_status("Pushing to remote...") + if widget._spinner_timer: + widget._spinner_timer.stop() + + +class TeleportMessageAuthRequiredApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_status( + "GitHub auth required. Code: ABCD-1234 (copied)\nOpen: https://github.com/login/device" + ) + if widget._spinner_timer: + widget._spinner_timer.stop() + + +class TeleportMessageAuthCompleteApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_status("GitHub authenticated.") + if widget._spinner_timer: + widget._spinner_timer.stop() + + +class TeleportMessageStartingWorkflowApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_status("Starting Nuage workflow...") + if widget._spinner_timer: + widget._spinner_timer.stop() + + +class TeleportMessageSendingTokenApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_status("Sending encrypted GitHub token...") + if widget._spinner_timer: + widget._spinner_timer.stop() + + +class TeleportMessageCompleteApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_complete("https://chat.example.com") + + +class TeleportMessageErrorApp(TeleportMessageTestApp): + def on_mount(self) -> None: + widget = self.query_one(TeleportMessage) + widget.set_error("Git repository has uncommitted changes") + + +def _push_confirmation_args(count: int) -> AskUserQuestionArgs: + word = f"commit{'s' if count != 1 else ''}" + return AskUserQuestionArgs( + questions=[ + Question( + question=f"You have {count} unpushed {word}. Push to continue?", + header="Push", + options=[Choice(label="Push and continue"), Choice(label="Cancel")], + hide_other=True, + ) + ] + ) + + +class TeleportPushConfirmationTestApp(App): + CSS_PATH = "../../vibe/cli/textual_ui/app.tcss" + + def __init__(self, count: int = 3): + super().__init__() + self.count = count + + def compose(self) -> ComposeResult: + with Container(id="bottom-app-container"): + yield QuestionApp(args=_push_confirmation_args(self.count)) + + +class TeleportPushConfirmationSingleCommitApp(TeleportPushConfirmationTestApp): + def __init__(self): + super().__init__(count=1) + + +class TeleportPushConfirmationMultipleCommitsApp(TeleportPushConfirmationTestApp): + def __init__(self): + super().__init__(count=5) + + +def test_snapshot_teleport_status_checking_git(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessageCheckingGitApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_status_pushing(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessagePushingApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_status_auth_required(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessageAuthRequiredApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_status_auth_complete(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessageAuthCompleteApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_status_starting_workflow(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessageStartingWorkflowApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_status_sending_token(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessageSendingTokenApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_status_complete(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessageCompleteApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_status_error(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportMessageErrorApp", + terminal_size=(80, 10), + run_before=run_before, + ) + + +def test_snapshot_teleport_push_confirmation_single_commit( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportPushConfirmationSingleCommitApp", + terminal_size=(80, 12), + run_before=run_before, + ) + + +def test_snapshot_teleport_push_confirmation_multiple_commits( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportPushConfirmationMultipleCommitsApp", + terminal_size=(80, 12), + run_before=run_before, + ) + + +def test_snapshot_teleport_push_confirmation_cancel_selected( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.1) + await pilot.press("down") + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportPushConfirmationMultipleCommitsApp", + terminal_size=(80, 12), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_whats_new.py b/tests/snapshots/test_ui_snapshot_whats_new.py index cbff1ca..03ad6e7 100644 --- a/tests/snapshots/test_ui_snapshot_whats_new.py +++ b/tests/snapshots/test_ui_snapshot_whats_new.py @@ -1,22 +1,28 @@ from __future__ import annotations +import os from pathlib import Path import time from unittest.mock import patch from textual.pilot import Pilot +from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config from tests.snapshots.snap_compare import SnapCompare from tests.update_notifier.adapters.fake_update_cache_repository import ( FakeUpdateCacheRepository, ) from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse from vibe.cli.update_notifier import UpdateCache class SnapshotTestAppWithWhatsNew(BaseSnapshotTestApp): - def __init__(self): + def __init__(self, gateway: FakeWhoAmIGateway | None = None): + self._previous_api_key = os.environ.get("MISTRAL_API_KEY") + os.environ["MISTRAL_API_KEY"] = "snapshot-api-key" + config = default_config() config.enable_update_checks = False update_notifier = FakeUpdateGateway(update=None) @@ -31,8 +37,55 @@ class SnapshotTestAppWithWhatsNew(BaseSnapshotTestApp): update_notifier=update_notifier, update_cache_repository=update_cache_repository, current_version="1.0.0", + plan_offer_gateway=gateway, ) + def on_unmount(self) -> None: + if self._previous_api_key is None: + os.environ.pop("MISTRAL_API_KEY", None) + else: + os.environ["MISTRAL_API_KEY"] = self._previous_api_key + return None + + +class SnapshotTestAppWithPlanUpgradeCTA(SnapshotTestAppWithWhatsNew): + def __init__(self): + plan_offer_gateway = FakeWhoAmIGateway( + WhoAmIResponse( + is_pro_plan=False, + advertise_pro_plan=True, + prompt_switching_to_pro_plan=False, + ) + ) + + super().__init__(gateway=plan_offer_gateway) + + +class SnapshotTestAppWithSwitchKeyCTA(SnapshotTestAppWithWhatsNew): + def __init__(self): + plan_offer_gateway = FakeWhoAmIGateway( + WhoAmIResponse( + is_pro_plan=False, + advertise_pro_plan=False, + prompt_switching_to_pro_plan=True, + ) + ) + + super().__init__(gateway=plan_offer_gateway) + + +class SnapshotTestAppWithWhatsNewNoPlanCTA(SnapshotTestAppWithWhatsNew): + def __init__(self): + plan_offer_gateway = FakeWhoAmIGateway( + WhoAmIResponse( + is_pro_plan=True, + advertise_pro_plan=False, + prompt_switching_to_pro_plan=False, + ) + ) + + super().__init__(gateway=plan_offer_gateway) + def test_snapshot_shows_whats_new_message( snap_compare: SnapCompare, tmp_path: Path @@ -50,3 +103,54 @@ def test_snapshot_shows_whats_new_message( terminal_size=(120, 36), run_before=run_before, ) + + +def test_snapshot_shows_upgrade_message( + snap_compare: SnapCompare, tmp_path: Path +) -> None: + whats_new_file = tmp_path / "whats_new.md" + whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2\n- Feature 3") + + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.5) + + with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): + assert snap_compare( + "test_ui_snapshot_whats_new.py:SnapshotTestAppWithPlanUpgradeCTA", + terminal_size=(120, 36), + run_before=run_before, + ) + + +def test_snapshot_shows_switch_message( + snap_compare: SnapCompare, tmp_path: Path +) -> None: + whats_new_file = tmp_path / "whats_new.md" + whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2\n- Feature 3") + + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.5) + + with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): + assert snap_compare( + "test_ui_snapshot_whats_new.py:SnapshotTestAppWithSwitchKeyCTA", + terminal_size=(120, 36), + run_before=run_before, + ) + + +def test_snapshot_shows_no_plan_message( + snap_compare: SnapCompare, tmp_path: Path +) -> None: + whats_new_file = tmp_path / "whats_new.md" + whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2\n- Feature 3") + + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.5) + + with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): + assert snap_compare( + "test_ui_snapshot_whats_new.py:SnapshotTestAppWithWhatsNewNoPlanCTA", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/stubs/fake_client.py b/tests/stubs/fake_client.py index a93e29a..ef6294b 100644 --- a/tests/stubs/fake_client.py +++ b/tests/stubs/fake_client.py @@ -5,12 +5,12 @@ from typing import Any from acp import ( Agent as AcpAgent, Client, + CreateTerminalResponse, KillTerminalCommandResponse, ReadTextFileResponse, ReleaseTerminalResponse, RequestPermissionResponse, SessionNotification, - TerminalHandle, TerminalOutputResponse, WaitForTerminalExitResponse, WriteTextFileResponse, @@ -20,6 +20,7 @@ from acp.schema import ( AgentPlanUpdate, AgentThoughtChunk, AvailableCommandsUpdate, + ConfigOptionUpdate, CurrentModeUpdate, EnvVariable, PermissionOption, @@ -27,6 +28,7 @@ from acp.schema import ( ToolCallProgress, ToolCallStart, ToolCallUpdate, + UsageUpdate, UserMessageChunk, ) @@ -48,7 +50,9 @@ class FakeClient(Client): | AgentPlanUpdate | AvailableCommandsUpdate | CurrentModeUpdate - | SessionInfoUpdate, + | SessionInfoUpdate + | ConfigOptionUpdate + | UsageUpdate, **kwargs: Any, ) -> None: self._session_updates.append( @@ -88,7 +92,7 @@ class FakeClient(Client): env: list[EnvVariable] | None = None, output_byte_limit: int | None = None, **kwargs: Any, - ) -> TerminalHandle: + ) -> CreateTerminalResponse: raise NotImplementedError() async def terminal_output( diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py index 9511d69..dabdfb2 100644 --- a/tests/test_agent_auto_compact.py +++ b/tests/test_agent_auto_compact.py @@ -2,10 +2,9 @@ from __future__ import annotations import pytest +from tests.conftest import build_test_agent_loop, build_test_vibe_config from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig from vibe.core.types import ( AssistantEvent, CompactEndEvent, @@ -27,10 +26,10 @@ async def test_auto_compact_triggers_and_batches_observer() -> None: [mock_llm_chunk(content="")], [mock_llm_chunk(content="")], ]) - cfg = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), auto_compact_threshold=1 + cfg = build_test_vibe_config(auto_compact_threshold=1) + agent = build_test_agent_loop( + config=cfg, message_observer=observer, backend=backend ) - agent = AgentLoop(cfg, message_observer=observer, backend=backend) agent.stats.context_tokens = 2 events = [ev async for ev in agent.act("Hello")] diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index aab8992..a41bf36 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -2,21 +2,16 @@ from __future__ import annotations import pytest +from tests.conftest import build_test_agent_loop from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig - - -@pytest.fixture -def vibe_config() -> VibeConfig: - return VibeConfig(session_logging=SessionLoggingConfig(enabled=False)) +from vibe.core.config import VibeConfig @pytest.mark.asyncio async def test_passes_x_affinity_header_when_asking_an_answer(vibe_config: VibeConfig): backend = FakeBackend([mock_llm_chunk(content="Response")]) - agent = AgentLoop(vibe_config, backend=backend) + agent = build_test_agent_loop(config=vibe_config, backend=backend) [_ async for _ in agent.act("Hello")] @@ -32,7 +27,9 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming( vibe_config: VibeConfig, ): backend = FakeBackend([mock_llm_chunk(content="Response")]) - agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=vibe_config, backend=backend, enable_streaming=True + ) [_ async for _ in agent.act("Hello")] @@ -47,7 +44,7 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming( async def test_updates_tokens_stats_based_on_backend_response(vibe_config: VibeConfig): chunk = mock_llm_chunk(content="Response", prompt_tokens=100, completion_tokens=50) backend = FakeBackend([chunk]) - agent = AgentLoop(vibe_config, backend=backend) + agent = build_test_agent_loop(config=vibe_config, backend=backend) [_ async for _ in agent.act("Hello")] @@ -62,7 +59,9 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming( content="Complete", prompt_tokens=200, completion_tokens=75 ) backend = FakeBackend([final_chunk]) - agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=vibe_config, backend=backend, enable_streaming=True + ) [_ async for _ in agent.act("Hello")] diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 262f04c..3cf0aff 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -8,11 +8,11 @@ from unittest.mock import AsyncMock import httpx import pytest +from tests.conftest import build_test_agent_loop, build_test_vibe_config from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend -from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.llm.exceptions import BackendErrorBuilder from vibe.core.middleware import ( ConversationContext, @@ -57,12 +57,10 @@ class InjectBeforeMiddleware: def make_config( *, - disable_logging: bool = True, enabled_tools: list[str] | None = None, tools: dict[str, BaseToolConfig] | None = None, ) -> VibeConfig: - cfg = VibeConfig( - session_logging=SessionLoggingConfig(enabled=not disable_logging), + return build_test_vibe_config( auto_compact_threshold=0, system_prompt_id="tests", include_project_context=False, @@ -72,7 +70,6 @@ def make_config( enabled_tools=enabled_tools or [], tools=tools or {}, ) - return cfg @pytest.fixture @@ -94,7 +91,9 @@ async def test_act_flushes_batched_messages_with_injection_middleware( observed, observer = observer_capture backend = FakeBackend([mock_llm_chunk(content="I can write very efficient code.")]) - agent = AgentLoop(make_config(), message_observer=observer, backend=backend) + agent = build_test_agent_loop( + config=make_config(), message_observer=observer, backend=backend + ) agent.middleware_pipeline.add(InjectBeforeMiddleware()) async for _ in agent.act("How can you help?"): @@ -119,8 +118,8 @@ async def test_stop_action_flushes_user_msg_before_returning(observer_capture) - backend = FakeBackend([ mock_llm_chunk(content="My response will never reach you...") ]) - agent = AgentLoop( - make_config(), message_observer=observer, max_turns=0, backend=backend + agent = build_test_agent_loop( + config=make_config(), message_observer=observer, max_turns=0, backend=backend ) async for _ in agent.act("Greet."): @@ -138,7 +137,9 @@ async def test_act_emits_user_and_assistant_msgs(observer_capture) -> None: observed, observer = observer_capture backend = FakeBackend([mock_llm_chunk(content="Pong!")]) - agent = AgentLoop(make_config(), message_observer=observer, backend=backend) + agent = build_test_agent_loop( + config=make_config(), message_observer=observer, backend=backend + ) async for _ in agent.act("Ping?"): pass @@ -160,7 +161,9 @@ async def test_act_streams_batched_chunks_in_order() -> None: mock_llm_chunk(content=" and"), mock_llm_chunk(content=" end"), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("Stream, please.")] @@ -188,8 +191,8 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None ], [mock_llm_chunk(content="Done reviewing todos.")], ]) - agent = AgentLoop( - make_config( + agent = build_test_agent_loop( + config=make_config( enabled_tools=["todo"], tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, ), @@ -232,8 +235,8 @@ async def test_act_handles_tool_call_chunk_with_content() -> None: mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]), mock_llm_chunk(content=" complete"), ]) - agent = AgentLoop( - make_config( + agent = build_test_agent_loop( + config=make_config( enabled_tools=["todo"], tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, ), @@ -276,8 +279,8 @@ async def test_act_merges_streamed_tool_call_arguments() -> None: mock_llm_chunk(content="", tool_calls=[tool_call_part_one]), mock_llm_chunk(content="", tool_calls=[tool_call_part_two]), ]) - agent = AgentLoop( - make_config( + agent = build_test_agent_loop( + config=make_config( enabled_tools=["todo"], tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, ), @@ -340,8 +343,8 @@ async def test_act_handles_user_cancellation_during_streaming() -> None: mock_llm_chunk(content="Preparing "), mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]), ]) - agent = AgentLoop( - make_config( + agent = build_test_agent_loop( + config=make_config( enabled_tools=["todo"], tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)}, ), @@ -380,8 +383,11 @@ async def test_act_handles_user_cancellation_during_streaming() -> None: async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> None: observed, observer = observer_capture backend = FakeBackend(exception_to_raise=RuntimeError("boom in streaming")) - agent = AgentLoop( - make_config(), backend=backend, message_observer=observer, enable_streaming=True + agent = build_test_agent_loop( + config=make_config(), + backend=backend, + message_observer=observer, + enable_streaming=True, ) agent.session_logger.save_interaction = AsyncMock(return_value=None) @@ -408,8 +414,11 @@ async def test_rate_limit(observer_capture) -> None: tool_choice=None, ) backend = FakeBackend(exception_to_raise=backend_error) - agent = AgentLoop( - make_config(), backend=backend, message_observer=observer, enable_streaming=True + agent = build_test_agent_loop( + config=make_config(), + backend=backend, + message_observer=observer, + enable_streaming=True, ) agent.session_logger.save_interaction = AsyncMock(return_value=None) @@ -436,7 +445,9 @@ async def test_reasoning_buffer_yields_before_content_on_transition() -> None: mock_llm_chunk(content="", reasoning_content=" problem..."), mock_llm_chunk(content="The answer is 42."), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("What's the answer?")] @@ -458,7 +469,9 @@ async def test_reasoning_buffer_yields_before_content_with_batching() -> None: mock_llm_chunk(content="", reasoning_content=", Final"), mock_llm_chunk(content="Done thinking!"), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("Think step by step")] @@ -479,7 +492,9 @@ async def test_content_buffer_yields_before_reasoning_on_transition() -> None: mock_llm_chunk(content="", reasoning_content=" this approach..."), mock_llm_chunk(content="Actually, the final answer."), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("Give me an answer")] @@ -500,7 +515,9 @@ async def test_interleaved_reasoning_content_preserves_order() -> None: mock_llm_chunk(content="", reasoning_content="Think 3"), mock_llm_chunk(content="Answer 3"), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("Interleaved test")] @@ -524,7 +541,9 @@ async def test_only_reasoning_chunks_yields_reasoning_event() -> None: mock_llm_chunk(content="", reasoning_content="Just thinking..."), mock_llm_chunk(content="", reasoning_content=" nothing to say yet."), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("Silent thinking")] @@ -539,7 +558,9 @@ async def test_final_buffers_flush_in_correct_order() -> None: mock_llm_chunk(content="", reasoning_content="Final thought"), mock_llm_chunk(content="Final words"), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("End buffers test")] @@ -557,7 +578,9 @@ async def test_empty_content_chunks_do_not_trigger_false_yields() -> None: mock_llm_chunk(content="", reasoning_content=" more reasoning"), mock_llm_chunk(content="Actual content"), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("Empty content test")] diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py index e9ca8e4..bf9abd6 100644 --- a/tests/test_agent_stats.py +++ b/tests/test_agent_stats.py @@ -4,9 +4,9 @@ from collections.abc import Callable import pytest +from tests.conftest import build_test_agent_loop, build_test_vibe_config from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend -from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ( Backend, @@ -79,7 +79,7 @@ def make_config( backend=Backend.MISTRAL, ), ] - return VibeConfig( + return build_test_vibe_config( session_logging=SessionLoggingConfig(enabled=not disable_logging), auto_compact_threshold=auto_compact_threshold, system_prompt_id=system_prompt_id, @@ -161,7 +161,7 @@ class TestReloadPreservesStats: @pytest.mark.asyncio async def test_reload_preserves_session_tokens(self) -> None: backend = FakeBackend(mock_llm_chunk(content="First response")) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("Hello"): pass @@ -194,8 +194,8 @@ class TestReloadPreservesStats: mock_llm_chunk(content="Done"), ]) config = make_config(enabled_tools=["todo"]) - agent = AgentLoop( - config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend + agent = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend ) async for _ in agent.act("Check todos"): @@ -215,7 +215,7 @@ class TestReloadPreservesStats: [mock_llm_chunk(content="R1")], [mock_llm_chunk(content="R2")], ]) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("First"): pass @@ -234,7 +234,7 @@ class TestReloadPreservesStats: self, ) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) [_ async for _ in agent.act("Hello")] assert agent.stats.context_tokens > 0 initial_context_tokens = agent.stats.context_tokens @@ -248,7 +248,7 @@ class TestReloadPreservesStats: @pytest.mark.asyncio async def test_reload_resets_context_tokens_when_no_messages(self) -> None: backend = FakeBackend([]) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) assert len(agent.messages) == 1 assert agent.stats.context_tokens == 0 @@ -264,7 +264,7 @@ class TestReloadPreservesStats: backend = FakeBackend(mock_llm_chunk(content="Response")) config1 = make_config(system_prompt_id="tests") config2 = make_config(system_prompt_id="cli") - agent = AgentLoop(config1, backend=backend) + agent = build_test_agent_loop(config=config1, backend=backend) [_ async for _ in agent.act("Hello")] original_context_tokens = agent.stats.context_tokens assert original_context_tokens > 0 @@ -281,7 +281,7 @@ class TestReloadPreservesStats: backend = FakeBackend(mock_llm_chunk(content="Response")) config_mistral = make_config(active_model="devstral-latest") - agent = AgentLoop(config_mistral, backend=backend) + agent = build_test_agent_loop(config=config_mistral, backend=backend) async for _ in agent.act("Hello"): pass @@ -304,7 +304,7 @@ class TestReloadPreservesStats: [mock_llm_chunk(content="After reload")], ]) config1 = make_config(active_model="devstral-latest") - agent = AgentLoop(config1, backend=backend) + agent = build_test_agent_loop(config=config1, backend=backend) async for _ in agent.act("Hello"): pass @@ -329,7 +329,7 @@ class TestReloadPreservesMessages: @pytest.mark.asyncio async def test_reload_preserves_conversation_messages(self) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("Hello"): pass @@ -351,7 +351,7 @@ class TestReloadPreservesMessages: async def test_reload_updates_system_prompt_preserves_rest(self) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) config1 = make_config(system_prompt_id="tests") - agent = AgentLoop(config1, backend=backend) + agent = build_test_agent_loop(config=config1, backend=backend) async for _ in agent.act("Hello"): pass @@ -368,7 +368,7 @@ class TestReloadPreservesMessages: @pytest.mark.asyncio async def test_reload_with_no_messages_stays_empty(self) -> None: backend = FakeBackend([]) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) assert len(agent.messages) == 1 @@ -383,7 +383,9 @@ class TestReloadPreservesMessages: ) -> None: observed, observer = observer_capture backend = FakeBackend(mock_llm_chunk(content="Response")) - agent = AgentLoop(make_config(), message_observer=observer, backend=backend) + agent = build_test_agent_loop( + config=make_config(), message_observer=observer, backend=backend + ) async for _ in agent.act("Hello"): pass @@ -405,7 +407,7 @@ class TestCompactStatsHandling: [mock_llm_chunk(content="First response")], [mock_llm_chunk(content="")], ]) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("Build something"): pass @@ -427,7 +429,7 @@ class TestCompactStatsHandling: [mock_llm_chunk(content="Long response " * 100)], [mock_llm_chunk(content="")], ]) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("Do something complex"): pass @@ -459,8 +461,8 @@ class TestCompactStatsHandling: [mock_llm_chunk(content="")], ]) config = make_config(enabled_tools=["todo"]) - agent = AgentLoop( - config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend + agent = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend ) async for _ in agent.act("Check todos"): @@ -478,7 +480,9 @@ class TestCompactStatsHandling: [mock_llm_chunk(content="Long response " * 100)], [mock_llm_chunk(content="")], ]) - agent = AgentLoop(make_config(disable_logging=False), backend=backend) + agent = build_test_agent_loop( + config=make_config(disable_logging=False), backend=backend + ) original_session_id = agent.session_id original_logger_session_id = agent.session_logger.session_id @@ -506,11 +510,10 @@ class TestAutoCompactIntegration: [mock_llm_chunk(content="")], [mock_llm_chunk(content="")], ]) - cfg = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - auto_compact_threshold=1, + cfg = build_test_vibe_config(auto_compact_threshold=1) + agent = build_test_agent_loop( + config=cfg, message_observer=observer, backend=backend ) - agent = AgentLoop(cfg, message_observer=observer, backend=backend) agent.stats.context_tokens = 2 events = [ev async for ev in agent.act("Hello")] @@ -540,7 +543,7 @@ class TestClearHistoryFullReset: @pytest.mark.asyncio async def test_clear_history_fully_resets_stats(self) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("Hello"): pass @@ -558,7 +561,7 @@ class TestClearHistoryFullReset: async def test_clear_history_preserves_pricing(self) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) config = make_config(input_price=0.4, output_price=2.0) - agent = AgentLoop(config, backend=backend) + agent = build_test_agent_loop(config=config, backend=backend) async for _ in agent.act("Hello"): pass @@ -571,7 +574,7 @@ class TestClearHistoryFullReset: @pytest.mark.asyncio async def test_clear_history_removes_messages(self) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("Hello"): pass @@ -586,7 +589,9 @@ class TestClearHistoryFullReset: @pytest.mark.asyncio async def test_clear_history_resets_session_id(self) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) - agent = AgentLoop(make_config(disable_logging=False), backend=backend) + agent = build_test_agent_loop( + config=make_config(disable_logging=False), backend=backend + ) original_session_id = agent.session_id original_logger_session_id = agent.session_logger.session_id @@ -611,7 +616,7 @@ class TestStatsEdgeCases: backend = FakeBackend(mock_llm_chunk(content="Response")) config1 = make_config(active_model="devstral-latest") - agent = AgentLoop(config1, backend=backend) + agent = build_test_agent_loop(config=config1, backend=backend) async for _ in agent.act("Hello"): pass @@ -632,7 +637,7 @@ class TestStatsEdgeCases: [mock_llm_chunk(content="R2")], [mock_llm_chunk(content="R3")], ]) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("First"): pass @@ -657,7 +662,7 @@ class TestStatsEdgeCases: [mock_llm_chunk(content="")], [mock_llm_chunk(content="After reload")], ]) - agent = AgentLoop(make_config(), backend=backend) + agent = build_test_agent_loop(config=make_config(), backend=backend) async for _ in agent.act("Build something"): pass @@ -678,7 +683,7 @@ class TestStatsEdgeCases: async def test_reload_without_config_preserves_current(self) -> None: backend = FakeBackend([]) original_config = make_config(active_model="devstral-latest") - agent = AgentLoop(original_config, backend=backend) + agent = build_test_agent_loop(config=original_config, backend=backend) await agent.reload_with_initial_messages(base_config=None) @@ -688,7 +693,7 @@ class TestStatsEdgeCases: async def test_reload_with_new_config_updates_it(self) -> None: backend = FakeBackend([]) original_config = make_config(active_model="devstral-latest") - agent = AgentLoop(original_config, backend=backend) + agent = build_test_agent_loop(config=original_config, backend=backend) new_config = make_config(active_model="devstral-small") await agent.reload_with_initial_messages(base_config=new_config) diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py index 223401a..0749fba 100644 --- a/tests/test_agent_tool_call.py +++ b/tests/test_agent_tool_call.py @@ -6,12 +6,13 @@ import json from pydantic import BaseModel import pytest +from tests.conftest import build_test_agent_loop, build_test_vibe_config from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_tool import FakeTool from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.builtins.todo import TodoItem from vibe.core.types import ( @@ -34,8 +35,7 @@ async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[Bas def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + return build_test_vibe_config( auto_compact_threshold=0, enabled_tools=["todo"], tools={"todo": BaseToolConfig(permission=todo_permission)}, @@ -64,8 +64,8 @@ def make_agent_loop( agent_name = ( BuiltinAgentName.AUTO_APPROVE if auto_approve else BuiltinAgentName.DEFAULT ) - agent_loop = AgentLoop( - make_config(todo_permission=todo_permission), + agent_loop = build_test_agent_loop( + config=make_config(todo_permission=todo_permission), agent_name=agent_name, backend=backend, ) @@ -410,13 +410,11 @@ async def test_tool_call_can_be_interrupted() -> None: tool_call = ToolCall( id="call_8", index=0, function=FunctionCall(name="stub_tool", arguments="{}") ) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - auto_compact_threshold=0, - enabled_tools=["stub_tool"], + config = build_test_vibe_config( + auto_compact_threshold=0, enabled_tools=["stub_tool"] ) - agent_loop = AgentLoop( - config, + agent_loop = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=FakeBackend([ [mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call])], @@ -444,8 +442,8 @@ async def test_tool_call_can_be_interrupted() -> None: @pytest.mark.asyncio async def test_fill_missing_tool_responses_inserts_placeholders() -> None: - agent_loop = AgentLoop( - make_config(), + agent_loop = build_test_agent_loop( + config=make_config(), agent_name=BuiltinAgentName.AUTO_APPROVE, backend=FakeBackend(mock_llm_chunk(content="ok")), ) @@ -480,8 +478,8 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None: @pytest.mark.asyncio async def test_ensure_assistant_after_tool_appends_understood() -> None: - agent_loop = AgentLoop( - make_config(), + agent_loop = build_test_agent_loop( + config=make_config(), agent_name=BuiltinAgentName.AUTO_APPROVE, backend=FakeBackend(mock_llm_chunk(content="ok")), ) diff --git a/tests/test_agents.py b/tests/test_agents.py index bca302f..d007dd1 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -4,9 +4,9 @@ from pathlib import Path import pytest +from tests.conftest import build_test_agent_loop, build_test_vibe_config from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend -from vibe.core.agent_loop import AgentLoop from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import ( BUILTIN_AGENTS, @@ -17,7 +17,7 @@ from vibe.core.agents.models import ( BuiltinAgentName, _deep_merge, ) -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.paths.config_paths import ConfigPath from vibe.core.paths.global_paths import GlobalPath from vibe.core.tools.base import ToolPermission @@ -231,8 +231,7 @@ class TestAgentProfileOverrides: class TestAgentManagerCycling: @pytest.fixture def base_config(self) -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + return build_test_vibe_config( auto_compact_threshold=0, include_project_context=False, include_prompt_detail=False, @@ -250,8 +249,8 @@ class TestAgentManagerCycling: def test_get_agent_order_includes_primary_agents( self, base_config: VibeConfig, backend: FakeBackend ) -> None: - agent = AgentLoop( - base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + agent = build_test_agent_loop( + config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) order = agent.agent_manager.get_agent_order() assert len(order) == 4 @@ -263,8 +262,8 @@ class TestAgentManagerCycling: def test_next_agent_cycles_through_all( self, base_config: VibeConfig, backend: FakeBackend ) -> None: - agent = AgentLoop( - base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + agent = build_test_agent_loop( + config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) order = agent.agent_manager.get_agent_order() current = agent.agent_manager.active_profile @@ -277,8 +276,8 @@ class TestAgentManagerCycling: def test_next_agent_wraps_around( self, base_config: VibeConfig, backend: FakeBackend ) -> None: - agent = AgentLoop( - base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + agent = build_test_agent_loop( + config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) order = agent.agent_manager.get_agent_order() last_profile = agent.agent_manager.get_agent(order[-1]) @@ -301,8 +300,7 @@ class TestAgentProfileConfig: class TestAgentSwitchAgent: @pytest.fixture def base_config(self) -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + return build_test_vibe_config( auto_compact_threshold=0, include_project_context=False, include_prompt_detail=False, @@ -321,8 +319,8 @@ class TestAgentSwitchAgent: async def test_switch_to_plan_agent_restricts_tools( self, base_config: VibeConfig, backend: FakeBackend ) -> None: - agent = AgentLoop( - base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + agent = build_test_agent_loop( + config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) initial_tool_names = set(agent.tool_manager.available_tools.keys()) assert len(initial_tool_names) > len(PLAN_AGENT_TOOLS) @@ -337,8 +335,8 @@ class TestAgentSwitchAgent: async def test_switch_from_plan_to_default_restores_tools( self, base_config: VibeConfig, backend: FakeBackend ) -> None: - agent = AgentLoop( - base_config, agent_name=BuiltinAgentName.PLAN, backend=backend + agent = build_test_agent_loop( + config=base_config, agent_name=BuiltinAgentName.PLAN, backend=backend ) assert len(agent.tool_manager.available_tools) == len(PLAN_AGENT_TOOLS) @@ -351,8 +349,8 @@ class TestAgentSwitchAgent: async def test_switch_agent_preserves_conversation_history( self, base_config: VibeConfig, backend: FakeBackend ) -> None: - agent = AgentLoop( - base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + agent = build_test_agent_loop( + config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) user_msg = LLMMessage(role=Role.user, content="Hello") assistant_msg = LLMMessage(role=Role.assistant, content="Hi there") @@ -369,8 +367,8 @@ class TestAgentSwitchAgent: async def test_switch_to_same_agent_is_noop( self, base_config: VibeConfig, backend: FakeBackend ) -> None: - agent = AgentLoop( - base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend + agent = build_test_agent_loop( + config=base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend ) original_config = agent.config @@ -393,13 +391,11 @@ class TestAcceptEditsAgent: async def test_accept_edits_agent_auto_approves_write_file(self) -> None: backend = FakeBackend([]) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - auto_compact_threshold=0, - enabled_tools=["write_file"], + config = build_test_vibe_config( + auto_compact_threshold=0, enabled_tools=["write_file"] ) - agent = AgentLoop( - config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend + agent = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend ) perm = agent.tool_manager.get_tool_config("write_file").permission @@ -409,13 +405,11 @@ class TestAcceptEditsAgent: async def test_accept_edits_agent_requires_approval_for_other_tools(self) -> None: backend = FakeBackend([]) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - auto_compact_threshold=0, - enabled_tools=["bash"], + config = build_test_vibe_config( + auto_compact_threshold=0, enabled_tools=["bash"] ) - agent = AgentLoop( - config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend + agent = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend ) perm = agent.tool_manager.get_tool_config("bash").permission @@ -431,11 +425,10 @@ class TestPlanAgentToolRestriction: usage=LLMUsage(prompt_tokens=10, completion_tokens=5), ) ]) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - auto_compact_threshold=0, + config = build_test_vibe_config(auto_compact_threshold=0) + agent = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.PLAN, backend=backend ) - agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend) tool_names = set(agent.tool_manager.available_tools.keys()) @@ -457,11 +450,10 @@ class TestPlanAgentToolRestriction: mock_llm_chunk(content="Tool not available"), ]) - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - auto_compact_threshold=0, + config = build_test_vibe_config(auto_compact_threshold=0) + agent = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.PLAN, backend=backend ) - agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend) events = [ev async for ev in agent.act("Run ls")] @@ -476,7 +468,7 @@ class TestPlanAgentToolRestriction: class TestAgentManagerFiltering: def test_enabled_agents_filters_to_only_enabled(self) -> None: - config = VibeConfig( + config = build_test_vibe_config( include_project_context=False, include_prompt_detail=False, enabled_agents=["default", "plan"], @@ -491,7 +483,7 @@ class TestAgentManagerFiltering: assert "accept-edits" not in agents def test_disabled_agents_excludes_disabled(self) -> None: - config = VibeConfig( + config = build_test_vibe_config( include_project_context=False, include_prompt_detail=False, disabled_agents=["auto-approve", "accept-edits"], @@ -506,7 +498,7 @@ class TestAgentManagerFiltering: assert "accept-edits" not in agents def test_enabled_agents_takes_precedence_over_disabled(self) -> None: - config = VibeConfig( + config = build_test_vibe_config( include_project_context=False, include_prompt_detail=False, enabled_agents=["default"], @@ -519,7 +511,7 @@ class TestAgentManagerFiltering: assert "default" in agents def test_glob_pattern_matching(self) -> None: - config = VibeConfig( + config = build_test_vibe_config( include_project_context=False, include_prompt_detail=False, disabled_agents=["auto-*", "accept-*"], @@ -533,7 +525,7 @@ class TestAgentManagerFiltering: assert "accept-edits" not in agents def test_regex_pattern_matching(self) -> None: - config = VibeConfig( + config = build_test_vibe_config( include_project_context=False, include_prompt_detail=False, enabled_agents=["re:^(default|plan)$"], @@ -546,7 +538,7 @@ class TestAgentManagerFiltering: assert "plan" in agents def test_empty_enabled_agents_returns_all(self) -> None: - config = VibeConfig( + config = build_test_vibe_config( include_project_context=False, include_prompt_detail=False, enabled_agents=[], @@ -560,7 +552,7 @@ class TestAgentManagerFiltering: assert "explore" in agents def test_get_subagents_respects_filtering(self) -> None: - config = VibeConfig( + config = build_test_vibe_config( include_project_context=False, include_prompt_detail=False, disabled_agents=["explore"], @@ -570,3 +562,57 @@ class TestAgentManagerFiltering: subagents = manager.get_subagents() names = [a.name for a in subagents] assert "explore" not in names + + +class TestAgentLoopInitialization: + def test_agent_system_prompt_id_is_applied_on_init( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + project_prompts = tmp_path / "project" / ".vibe" / "prompts" + project_prompts.mkdir(parents=True) + + global_prompts = tmp_path / "home" / ".vibe" / "prompts" + global_prompts.mkdir(parents=True) + custom_prompt_content = "CUSTOM_AGENT_PROMPT_MARKER" + (global_prompts / "custom_agent.md").write_text(custom_prompt_content) + + monkeypatch.setattr( + "vibe.core.config.PROMPTS_DIR", ConfigPath(lambda: project_prompts) + ) + monkeypatch.setattr( + "vibe.core.config.GLOBAL_PROMPTS_DIR", GlobalPath(lambda: global_prompts) + ) + + custom_agent = AgentProfile( + name="custom_test_agent", + display_name="Custom Test", + description="Test agent with custom system prompt", + safety=AgentSafety.NEUTRAL, + overrides={"system_prompt_id": "custom_agent"}, + ) + patched_agents = {**BUILTIN_AGENTS, "custom_test_agent": custom_agent} + monkeypatch.setattr("vibe.core.agents.models.BUILTIN_AGENTS", patched_agents) + monkeypatch.setattr("vibe.core.agents.manager.BUILTIN_AGENTS", patched_agents) + + config = build_test_vibe_config( + include_project_context=False, include_prompt_detail=False + ) + assert config.system_prompt_id == "cli", ( + "Base config should use default 'cli' prompt" + ) + + agent_loop = build_test_agent_loop( + config=config, agent_name="custom_test_agent" + ) + + assert agent_loop.config.system_prompt_id == "custom_agent", ( + "Merged config should have the agent's system_prompt_id override" + ) + + system_message = agent_loop.messages[0] + assert system_message.role == Role.system + assert system_message.content is not None + assert custom_prompt_content in system_message.content, ( + f"System message should contain custom prompt content. " + f"Expected '{custom_prompt_content}' to be in system message." + ) diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py index 99d6bfe..ad332bf 100644 --- a/tests/test_cli_programmatic_preload.py +++ b/tests/test_cli_programmatic_preload.py @@ -2,11 +2,12 @@ from __future__ import annotations import pytest +from tests.conftest import build_test_vibe_config from tests.mock.mock_backend_factory import mock_backend_factory from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.core import run_programmatic -from vibe.core.config import Backend, SessionLoggingConfig, VibeConfig +from vibe.core.config import Backend from vibe.core.types import LLMMessage, OutputFormat, Role @@ -40,8 +41,7 @@ def test_run_programmatic_preload_streaming_is_batched( ) ), ): - cfg = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + cfg = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, include_prompt_detail=False, @@ -101,8 +101,7 @@ def test_run_programmatic_ignores_system_messages_in_previous( Backend.MISTRAL, lambda provider, **kwargs: FakeBackend([mock_llm_chunk(content="Understood.")]), ): - cfg = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + cfg = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, include_prompt_detail=False, diff --git a/tests/test_middleware.py b/tests/test_middleware.py index eb84b04..f2a4ddc 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest from vibe.core.agents.models import BUILTIN_AGENTS, AgentProfile, BuiltinAgentName -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.middleware import ( PLAN_AGENT_REMINDER, ConversationContext, @@ -14,16 +14,17 @@ from vibe.core.middleware import ( from vibe.core.types import AgentStats -def make_context() -> ConversationContext: - config = VibeConfig(session_logging=SessionLoggingConfig(enabled=False)) - return ConversationContext(messages=[], stats=AgentStats(), config=config) +@pytest.fixture +def ctx(vibe_config: VibeConfig) -> ConversationContext: + return ConversationContext(messages=[], stats=AgentStats(), config=vibe_config) class TestPlanAgentMiddleware: @pytest.mark.asyncio - async def test_injects_reminder_when_plan_agent_active(self) -> None: + async def test_injects_reminder_when_plan_agent_active( + self, ctx: ConversationContext + ) -> None: middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]) - ctx = make_context() result = await middleware.before_turn(ctx) @@ -31,11 +32,12 @@ class TestPlanAgentMiddleware: assert result.message == PLAN_AGENT_REMINDER @pytest.mark.asyncio - async def test_does_not_inject_when_default_agent(self) -> None: + async def test_does_not_inject_when_default_agent( + self, ctx: ConversationContext + ) -> None: middleware = PlanAgentMiddleware( lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] ) - ctx = make_context() result = await middleware.before_turn(ctx) @@ -43,11 +45,12 @@ class TestPlanAgentMiddleware: assert result.message is None @pytest.mark.asyncio - async def test_does_not_inject_when_auto_approve_agent(self) -> None: + async def test_does_not_inject_when_auto_approve_agent( + self, ctx: ConversationContext + ) -> None: middleware = PlanAgentMiddleware( lambda: BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE] ) - ctx = make_context() result = await middleware.before_turn(ctx) @@ -55,11 +58,12 @@ class TestPlanAgentMiddleware: assert result.message is None @pytest.mark.asyncio - async def test_does_not_inject_when_accept_edits_agent(self) -> None: + async def test_does_not_inject_when_accept_edits_agent( + self, ctx: ConversationContext + ) -> None: middleware = PlanAgentMiddleware( lambda: BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS] ) - ctx = make_context() result = await middleware.before_turn(ctx) @@ -67,19 +71,17 @@ class TestPlanAgentMiddleware: assert result.message is None @pytest.mark.asyncio - async def test_after_turn_always_continues(self) -> None: + async def test_after_turn_always_continues(self, ctx: ConversationContext) -> None: middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]) - ctx = make_context() result = await middleware.after_turn(ctx) assert result.action == MiddlewareAction.CONTINUE @pytest.mark.asyncio - async def test_dynamically_checks_agent(self) -> None: + async def test_dynamically_checks_agent(self, ctx: ConversationContext) -> None: current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] middleware = PlanAgentMiddleware(lambda: current_profile) - ctx = make_context() result = await middleware.before_turn(ctx) assert result.action == MiddlewareAction.CONTINUE @@ -93,12 +95,11 @@ class TestPlanAgentMiddleware: assert result.action == MiddlewareAction.CONTINUE @pytest.mark.asyncio - async def test_custom_reminder(self) -> None: + async def test_custom_reminder(self, ctx: ConversationContext) -> None: custom_reminder = "Custom plan agent reminder" middleware = PlanAgentMiddleware( lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN], reminder=custom_reminder ) - ctx = make_context() result = await middleware.before_turn(ctx) @@ -111,10 +112,11 @@ class TestPlanAgentMiddleware: class TestMiddlewarePipelineWithPlanAgent: @pytest.mark.asyncio - async def test_pipeline_includes_plan_agent_injection(self) -> None: + async def test_pipeline_includes_plan_agent_injection( + self, ctx: ConversationContext + ) -> None: pipeline = MiddlewarePipeline() pipeline.add(PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN])) - ctx = make_context() result = await pipeline.run_before_turn(ctx) @@ -122,12 +124,13 @@ class TestMiddlewarePipelineWithPlanAgent: assert PLAN_AGENT_REMINDER in (result.message or "") @pytest.mark.asyncio - async def test_pipeline_skips_injection_when_not_plan_agent(self) -> None: + async def test_pipeline_skips_injection_when_not_plan_agent( + self, ctx: ConversationContext + ) -> None: pipeline = MiddlewarePipeline() pipeline.add( PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.DEFAULT]) ) - ctx = make_context() result = await pipeline.run_before_turn(ctx) diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index 3154b5f..59666bf 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -7,15 +7,10 @@ import mistralai import pytest import respx +from tests.conftest import build_test_agent_loop, build_test_vibe_config from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import ( - ModelConfig, - ProviderConfig, - SessionLoggingConfig, - VibeConfig, -) +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.llm.backend.generic import GenericBackend, OpenAIAdapter from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper, ParsedContent from vibe.core.llm.format import APIToolFormatHandler @@ -23,8 +18,7 @@ from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role def make_config() -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + return build_test_vibe_config( auto_compact_threshold=0, system_prompt_id="tests", include_project_context=False, @@ -302,7 +296,9 @@ class TestAgentLoopStreamingReasoningEvents: mock_llm_chunk(content="", reasoning_content="Second thought."), mock_llm_chunk(content="Final answer."), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) [_ async for _ in agent.act("Think and answer")] @@ -316,7 +312,9 @@ class TestAgentLoopStreamingReasoningEvents: mock_llm_chunk(content="Hello "), mock_llm_chunk(content="world!"), ]) - agent = AgentLoop(make_config(), backend=backend, enable_streaming=True) + agent = build_test_agent_loop( + config=make_config(), backend=backend, enable_streaming=True + ) events = [event async for event in agent.act("Say hello")] diff --git a/tests/test_system_prompt.py b/tests/test_system_prompt.py index 7dcc6ed..95cba21 100644 --- a/tests/test_system_prompt.py +++ b/tests/test_system_prompt.py @@ -4,8 +4,8 @@ import sys import pytest +from tests.conftest import build_test_vibe_config from vibe.core.agents import AgentManager -from vibe.core.config import VibeConfig from vibe.core.skills.manager import SkillManager from vibe.core.system_prompt import get_universal_system_prompt from vibe.core.tools.manager import ToolManager @@ -17,7 +17,7 @@ def test_get_universal_system_prompt_includes_windows_prompt_on_windows( monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setenv("COMSPEC", "C:\\Windows\\System32\\cmd.exe") - config = VibeConfig( + config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, include_prompt_detail=True, diff --git a/tests/test_ui_external_editor.py b/tests/test_ui_external_editor.py index 4d9a543..8718ea5 100644 --- a/tests/test_ui_external_editor.py +++ b/tests/test_ui_external_editor.py @@ -9,19 +9,6 @@ import pytest from vibe.cli.textual_ui.app import VibeApp from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig - - -@pytest.fixture -def vibe_config() -> VibeConfig: - return VibeConfig(session_logging=SessionLoggingConfig(enabled=False)) - - -@pytest.fixture -def vibe_app(vibe_config: VibeConfig) -> VibeApp: - agent_loop = AgentLoop(vibe_config) - return VibeApp(agent_loop=agent_loop) @contextmanager diff --git a/tests/test_ui_input_history.py b/tests/test_ui_input_history.py index cc2c4bb..3bea728 100644 --- a/tests/test_ui_input_history.py +++ b/tests/test_ui_input_history.py @@ -9,19 +9,6 @@ from vibe.cli.history_manager import HistoryManager from vibe.cli.textual_ui.app import VibeApp from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig - - -@pytest.fixture -def vibe_config() -> VibeConfig: - return VibeConfig(session_logging=SessionLoggingConfig(enabled=False)) - - -@pytest.fixture -def vibe_app(vibe_config: VibeConfig) -> VibeApp: - agent_loop = AgentLoop(vibe_config) - return VibeApp(agent_loop=agent_loop) @pytest.fixture diff --git a/tests/tools/test_manager_get_tool_config.py b/tests/tools/test_manager_get_tool_config.py index 2099b3a..172eabd 100644 --- a/tests/tools/test_manager_get_tool_config.py +++ b/tests/tools/test_manager_get_tool_config.py @@ -4,17 +4,15 @@ from pathlib import Path import pytest -from vibe.core.config import SessionLoggingConfig, VibeConfig +from tests.conftest import build_test_vibe_config from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.manager import ToolManager @pytest.fixture def config(): - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, + return build_test_vibe_config( + system_prompt_id="tests", include_project_context=False ) @@ -35,8 +33,7 @@ def test_returns_default_config_when_no_overrides(tool_manager): def test_merges_user_overrides_with_defaults(): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, tools={"bash": BaseToolConfig(permission=ToolPermission.ALWAYS)}, @@ -53,8 +50,7 @@ def test_merges_user_overrides_with_defaults(): def test_preserves_tool_specific_fields_from_overrides(): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, tools={"bash": BaseToolConfig(permission=ToolPermission.ASK)}, @@ -77,8 +73,7 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager): class TestToolManagerFiltering: def test_enabled_tools_filters_to_only_enabled(self): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, enabled_tools=["bash", "grep"], @@ -93,8 +88,7 @@ class TestToolManagerFiltering: assert "write_file" not in tools def test_disabled_tools_excludes_disabled(self): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, disabled_tools=["bash", "write_file"], @@ -109,8 +103,7 @@ class TestToolManagerFiltering: assert "read_file" in tools def test_enabled_tools_takes_precedence_over_disabled(self): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, enabled_tools=["bash"], @@ -123,8 +116,7 @@ class TestToolManagerFiltering: assert "bash" in tools def test_glob_pattern_matching(self): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, disabled_tools=["*_file"], # Matches read_file, write_file @@ -138,8 +130,7 @@ class TestToolManagerFiltering: assert "grep" in tools def test_regex_pattern_matching(self): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, enabled_tools=["re:^(bash|grep)$"], @@ -152,8 +143,7 @@ class TestToolManagerFiltering: assert "grep" in tools def test_case_insensitive_matching(self): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, enabled_tools=["BASH", "GREP"], @@ -165,11 +155,8 @@ class TestToolManagerFiltering: assert "grep" in tools def test_empty_enabled_tools_returns_all(self): - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, - enabled_tools=[], + vibe_config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False, enabled_tools=[] ) manager = ToolManager(lambda: vibe_config) @@ -228,8 +215,7 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt for k in to_remove: del sys.modules[k] - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), + vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, tool_paths=[tool_dir, file_tool], @@ -252,10 +238,8 @@ class TestToolManagerModuleReuse: def test_multiple_managers_share_tool_classes(self): """Tool classes should be identical across multiple ToolManager instances.""" - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, + vibe_config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False ) manager1 = ToolManager(lambda: vibe_config) @@ -272,10 +256,8 @@ class TestToolManagerModuleReuse: def test_tool_state_classes_are_identical(self): """Tool state classes should be identical across managers.""" - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, + vibe_config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False ) manager1 = ToolManager(lambda: vibe_config) @@ -291,10 +273,8 @@ class TestToolManagerModuleReuse: def test_tool_args_results_classes_are_identical(self): """Tool args and result classes should be identical across managers.""" - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, + vibe_config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False ) manager1 = ToolManager(lambda: vibe_config) @@ -315,10 +295,8 @@ class TestToolManagerModuleReuse: This ensures subagents have isolated state (e.g., separate todo lists) while still sharing class definitions for Pydantic validation. """ - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, + vibe_config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False ) manager1 = ToolManager(lambda: vibe_config) @@ -343,10 +321,8 @@ class TestToolManagerModuleReuse: def test_class_shared_but_instances_isolated(self): """Classes must be shared (for validation) but instances isolated (for state).""" - vibe_config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - system_prompt_id="tests", - include_project_context=False, + vibe_config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False ) manager1 = ToolManager(lambda: vibe_config) diff --git a/tests/tools/test_task.py b/tests/tools/test_task.py index 83abdfd..580f338 100644 --- a/tests/tools/test_task.py +++ b/tests/tools/test_task.py @@ -4,10 +4,10 @@ from unittest.mock import MagicMock, patch import pytest +from tests.conftest import build_test_vibe_config from tests.mock.utils import collect_result from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import BUILTIN_AGENTS, AgentType -from vibe.core.config import VibeConfig from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig from vibe.core.types import AssistantEvent, LLMMessage, Role @@ -32,7 +32,9 @@ class TestTaskArgs: class TestTaskToolValidation: @pytest.fixture def ctx(self) -> InvokeContext: - config = VibeConfig(include_project_context=False, include_prompt_detail=False) + config = build_test_vibe_config( + include_project_context=False, include_prompt_detail=False + ) manager = AgentManager(lambda: config) return InvokeContext(tool_call_id="test-call-id", agent_manager=manager) @@ -77,7 +79,9 @@ class TestTaskToolValidation: class TestTaskToolExecution: @pytest.fixture def ctx(self) -> InvokeContext: - config = VibeConfig(include_project_context=False, include_prompt_detail=False) + config = build_test_vibe_config( + include_project_context=False, include_prompt_detail=False + ) manager = AgentManager(lambda: config) return InvokeContext(tool_call_id="test-call-id", agent_manager=manager) diff --git a/tests/tools/test_ui_bash_execution.py b/tests/tools/test_ui_bash_execution.py index 53e09bd..d38c9fe 100644 --- a/tests/tools/test_ui_bash_execution.py +++ b/tests/tools/test_ui_bash_execution.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path import time import pytest @@ -9,20 +8,6 @@ from textual.widgets import Static from vibe.cli.textual_ui.app import VibeApp from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer from vibe.cli.textual_ui.widgets.messages import BashOutputMessage, ErrorMessage -from vibe.core.agent_loop import AgentLoop -from vibe.core.config import SessionLoggingConfig, VibeConfig - - -@pytest.fixture -def vibe_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> VibeConfig: - monkeypatch.chdir(tmp_path) - return VibeConfig(session_logging=SessionLoggingConfig(enabled=False)) - - -@pytest.fixture -def vibe_app(vibe_config: VibeConfig) -> VibeApp: - agent_loop = AgentLoop(vibe_config) - return VibeApp(agent_loop=agent_loop) async def _wait_for_bash_output_message( @@ -76,9 +61,8 @@ async def test_ui_shows_success_in_case_of_zero_code(vibe_app: VibeApp) -> None: await pilot.press("enter") message = await _wait_for_bash_output_message(vibe_app, pilot) - icon = message.query_one(".bash-exit-success", Static) - assert str(icon.render()) == "✓" - assert not list(message.query(".bash-exit-failure")) + assert message.has_class("bash-success") + assert not message.has_class("bash-error") @pytest.mark.asyncio @@ -89,11 +73,8 @@ async def test_ui_shows_failure_in_case_of_non_zero_code(vibe_app: VibeApp) -> N await pilot.press("enter") message = await _wait_for_bash_output_message(vibe_app, pilot) - icon = message.query_one(".bash-exit-failure", Static) - assert str(icon.render()) == "✗" - code = message.query_one(".bash-exit-code", Static) - assert "7" in str(code.render()) - assert not list(message.query(".bash-exit-success")) + assert message.has_class("bash-error") + assert not message.has_class("bash-success") @pytest.mark.asyncio @@ -122,7 +103,7 @@ async def test_ui_handles_utf8_output(vibe_app: VibeApp) -> None: await pilot.press("enter") message = await _wait_for_bash_output_message(vibe_app, pilot) output_widget = message.query_one(".bash-output", Static) - assert str(output_widget.render()) == "hello\n" + assert str(output_widget.render()) == "hello" assert_no_command_error(vibe_app) diff --git a/tests/update_notifier/test_ui_update_notification.py b/tests/update_notifier/test_ui_update_notification.py index 996bda4..e08b8b8 100644 --- a/tests/update_notifier/test_ui_update_notification.py +++ b/tests/update_notifier/test_ui_update_notification.py @@ -1,14 +1,15 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from pathlib import Path import time -from typing import Protocol from unittest.mock import patch import pytest from textual.app import Notification +from tests.conftest import build_test_vibe_app, build_test_vibe_config from tests.update_notifier.adapters.fake_update_cache_repository import ( FakeUpdateCacheRepository, ) @@ -18,12 +19,35 @@ from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage from vibe.cli.update_notifier import ( Update, UpdateCache, + UpdateCacheRepository, + UpdateGateway, UpdateGatewayCause, UpdateGatewayError, ) -from vibe.core.agent_loop import AgentLoop -from vibe.core.agents.models import BuiltinAgentName -from vibe.core.config import SessionLoggingConfig, VibeConfig +from vibe.core.config import VibeConfig + +TEST_CURRENT_VERSION = "0.1.0" + + +@pytest.fixture +def build_update_test_app( + vibe_config_with_update_checks_enabled: VibeConfig, +) -> Callable[..., VibeApp]: + def _build( + *, + update_notifier: UpdateGateway | None = None, + update_cache_repository: UpdateCacheRepository | None = None, + config: VibeConfig | None = None, + current_version: str = TEST_CURRENT_VERSION, + ) -> VibeApp: + return build_test_vibe_app( + update_notifier=update_notifier, + update_cache_repository=update_cache_repository, + config=config or vibe_config_with_update_checks_enabled, + current_version=current_version, + ) + + return _build async def _wait_for_notification( @@ -57,54 +81,15 @@ async def _assert_no_notifications( @pytest.fixture def vibe_config_with_update_checks_enabled() -> VibeConfig: - return VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=True - ) - - -class VibeAppFactory(Protocol): - def __call__( - self, - *, - notifier: FakeUpdateGateway, - update_cache_repository: FakeUpdateCacheRepository | None = None, - config: VibeConfig | None = None, - initial_agent_name: str = BuiltinAgentName.DEFAULT, - current_version: str = "0.1.0", - ) -> VibeApp: ... - - -@pytest.fixture -def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory: - update_cache_repository = FakeUpdateCacheRepository() - - def _make_app( - *, - notifier: FakeUpdateGateway, - update_cache_repository: FakeUpdateCacheRepository - | None = update_cache_repository, - config: VibeConfig | None = None, - initial_agent_name: str = BuiltinAgentName.DEFAULT, - current_version: str = "0.1.0", - ) -> VibeApp: - app_config = config or vibe_config_with_update_checks_enabled - agent_loop = AgentLoop( - app_config, agent_name=initial_agent_name, enable_streaming=False - ) - return VibeApp( - agent_loop=agent_loop, - update_notifier=notifier, - update_cache_repository=update_cache_repository, - current_version=current_version, - ) - - return _make_app + return build_test_vibe_config(enable_update_checks=True) @pytest.mark.asyncio -async def test_ui_displays_update_notification(make_vibe_app: VibeAppFactory) -> None: +async def test_ui_displays_update_notification( + build_update_test_app: Callable[..., VibeApp], +) -> None: notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) - app = make_vibe_app(notifier=notifier) + app = build_update_test_app(update_notifier=notifier) async with app.run_test() as pilot: notification = await _wait_for_notification(app, pilot, timeout=0.3) @@ -119,10 +104,10 @@ async def test_ui_displays_update_notification(make_vibe_app: VibeAppFactory) -> @pytest.mark.asyncio async def test_ui_does_not_display_update_notification_when_not_available( - make_vibe_app: VibeAppFactory, + build_update_test_app: Callable[..., VibeApp], ) -> None: notifier = FakeUpdateGateway(update=None) - app = make_vibe_app(notifier=notifier) + app = build_update_test_app(update_notifier=notifier) async with app.run_test() as pilot: await _assert_no_notifications(app, pilot, timeout=0.3) @@ -131,12 +116,12 @@ async def test_ui_does_not_display_update_notification_when_not_available( @pytest.mark.asyncio async def test_ui_displays_warning_toast_when_check_fails( - make_vibe_app: VibeAppFactory, + build_update_test_app: Callable[..., VibeApp], ) -> None: notifier = FakeUpdateGateway( error=UpdateGatewayError(cause=UpdateGatewayCause.FORBIDDEN) ) - app = make_vibe_app(notifier=notifier) + app = build_update_test_app(update_notifier=notifier) async with app.run_test() as pilot: await pilot.pause(0.3) @@ -150,12 +135,10 @@ async def test_ui_displays_warning_toast_when_check_fails( @pytest.mark.asyncio async def test_ui_does_not_invoke_gateway_nor_show_error_notification_when_update_checks_are_disabled( - vibe_config_with_update_checks_enabled: VibeConfig, make_vibe_app: VibeAppFactory + build_update_test_app: Callable[..., VibeApp], vibe_config: VibeConfig ) -> None: - config = vibe_config_with_update_checks_enabled - config.enable_update_checks = False notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) - app = make_vibe_app(notifier=notifier, config=config) + app = build_update_test_app(update_notifier=notifier, config=vibe_config) async with app.run_test() as pilot: await _assert_no_notifications(app, pilot, timeout=0.3) @@ -165,16 +148,16 @@ async def test_ui_does_not_invoke_gateway_nor_show_error_notification_when_updat @pytest.mark.asyncio async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_already( - make_vibe_app: VibeAppFactory, -) -> None: + build_update_test_app: Callable[..., VibeApp], +): timestamp_two_hours_ago = int(time.time()) - 2 * 60 * 60 notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) update_cache = UpdateCache( latest_version="0.2.0", stored_at_timestamp=timestamp_two_hours_ago ) update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache) - app = make_vibe_app( - notifier=notifier, update_cache_repository=update_cache_repository + app = build_update_test_app( + update_notifier=notifier, update_cache_repository=update_cache_repository ) async with app.run_test() as pilot: @@ -185,7 +168,7 @@ async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_alrea @pytest.mark.asyncio async def test_ui_does_show_toast_when_cache_entry_is_too_old( - make_vibe_app: VibeAppFactory, + build_update_test_app: Callable[..., VibeApp], ) -> None: timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60 notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) @@ -193,8 +176,8 @@ async def test_ui_does_show_toast_when_cache_entry_is_too_old( latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago ) update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache) - app = make_vibe_app( - notifier=notifier, update_cache_repository=update_cache_repository + app = build_update_test_app( + update_notifier=notifier, update_cache_repository=update_cache_repository ) async with app.run_test() as pilot: @@ -232,7 +215,7 @@ async def _wait_for_whats_new_message( @pytest.mark.asyncio async def test_ui_displays_whats_new_message_when_content_exists( - make_vibe_app: VibeAppFactory, tmp_path: Path + build_update_test_app: Callable[..., VibeApp], tmp_path: Path ) -> None: notifier = FakeUpdateGateway(update=None) cache = UpdateCache( @@ -241,8 +224,10 @@ async def test_ui_displays_whats_new_message_when_content_exists( seen_whats_new_version=None, ) repository = FakeUpdateCacheRepository(update_cache=cache) - app = make_vibe_app( - notifier=notifier, update_cache_repository=repository, current_version="1.0.0" + app = build_update_test_app( + update_notifier=notifier, + update_cache_repository=repository, + current_version="1.0.0", ) whats_new_content = "# What's New\n\n- Feature 1\n- Feature 2" @@ -262,7 +247,7 @@ async def test_ui_displays_whats_new_message_when_content_exists( @pytest.mark.asyncio async def test_ui_does_not_display_whats_new_when_seen_whats_new_version_matches( - make_vibe_app: VibeAppFactory, tmp_path: Path + build_update_test_app: Callable[..., VibeApp], tmp_path: Path ) -> None: notifier = FakeUpdateGateway(update=None) cache = UpdateCache( @@ -271,8 +256,10 @@ async def test_ui_does_not_display_whats_new_when_seen_whats_new_version_matches seen_whats_new_version="1.0.0", ) repository = FakeUpdateCacheRepository(update_cache=cache) - app = make_vibe_app( - notifier=notifier, update_cache_repository=repository, current_version="1.0.0" + app = build_update_test_app( + update_notifier=notifier, + update_cache_repository=repository, + current_version="1.0.0", ) with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): @@ -291,7 +278,7 @@ async def test_ui_does_not_display_whats_new_when_seen_whats_new_version_matches @pytest.mark.asyncio async def test_ui_does_not_display_whats_new_when_file_is_empty( - make_vibe_app: VibeAppFactory, tmp_path: Path + build_update_test_app: Callable[..., VibeApp], tmp_path: Path ) -> None: notifier = FakeUpdateGateway(update=None) cache = UpdateCache( @@ -300,8 +287,10 @@ async def test_ui_does_not_display_whats_new_when_file_is_empty( seen_whats_new_version=None, ) repository = FakeUpdateCacheRepository(update_cache=cache) - app = make_vibe_app( - notifier=notifier, update_cache_repository=repository, current_version="1.0.0" + app = build_update_test_app( + update_notifier=notifier, + update_cache_repository=repository, + current_version="1.0.0", ) with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): @@ -323,7 +312,7 @@ async def test_ui_does_not_display_whats_new_when_file_is_empty( @pytest.mark.asyncio async def test_ui_does_not_display_whats_new_when_file_does_not_exist( - make_vibe_app: VibeAppFactory, tmp_path: Path + build_update_test_app: Callable[..., VibeApp], tmp_path: Path ) -> None: notifier = FakeUpdateGateway(update=None) cache = UpdateCache( @@ -332,8 +321,10 @@ async def test_ui_does_not_display_whats_new_when_file_does_not_exist( seen_whats_new_version=None, ) repository = FakeUpdateCacheRepository(update_cache=cache) - app = make_vibe_app( - notifier=notifier, update_cache_repository=repository, current_version="1.0.0" + app = build_update_test_app( + update_notifier=notifier, + update_cache_repository=repository, + current_version="1.0.0", ) with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): @@ -352,17 +343,13 @@ async def test_ui_does_not_display_whats_new_when_file_does_not_exist( @pytest.mark.asyncio async def test_ui_displays_success_notification_when_auto_update_succeeds( - make_vibe_app: VibeAppFactory, + build_update_test_app: Callable[..., VibeApp], ) -> None: - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - enable_update_checks=True, - enable_auto_update=True, - ) + config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True) notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]): - app = make_vibe_app(notifier=notifier, config=config) + app = build_update_test_app(update_notifier=notifier, config=config) async with app.run_test() as pilot: await pilot.pause(0.3) @@ -380,17 +367,13 @@ async def test_ui_displays_success_notification_when_auto_update_succeeds( @pytest.mark.asyncio async def test_ui_displays_update_notification_when_auto_update_fails( - make_vibe_app: VibeAppFactory, + build_update_test_app: Callable[..., VibeApp], ) -> None: - config = VibeConfig( - session_logging=SessionLoggingConfig(enabled=False), - enable_update_checks=True, - enable_auto_update=True, - ) + config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True) notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["false"]): - app = make_vibe_app(notifier=notifier, config=config) + app = build_update_test_app(update_notifier=notifier, config=config) async with app.run_test() as pilot: await pilot.pause(0.3) diff --git a/uv.lock b/uv.lock index 033a4f6..e89a217 100644 --- a/uv.lock +++ b/uv.lock @@ -1,17 +1,21 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] [[package]] name = "agent-client-protocol" -version = "0.7.1" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/7c/12da39be4f73026fd9b02144df5f64d803488cf1439aa221b0edb7c305e3/agent_client_protocol-0.7.1.tar.gz", hash = "sha256:8d7031209e14c3f2f987e3b95e7d9c3286158e7b2af1bf43d6aae5b8a429249f", size = 66226, upload-time = "2025-12-28T13:58:57.012Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/a4/26698e0186933b4ab6e814626c99ee52b5522d039b5c94c983ecb3a66eed/agent_client_protocol-0.8.0.tar.gz", hash = "sha256:f9eade29167ff72a10fae7a0a0c1f27436909a790e159fb10265c2874e58d922", size = 68577, upload-time = "2026-02-07T17:08:46.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/48/48d2fb454f911147432cd779f548e188274e1700f1cbe0a258e78158331a/agent_client_protocol-0.7.1-py3-none-any.whl", hash = "sha256:4ffe999488f2b23db26f09becdfaa2aaae6529f0847a52bca61bc2c628001c0f", size = 53771, upload-time = "2025-12-28T13:58:55.967Z" }, + { url = "https://files.pythonhosted.org/packages/4b/04/e55a3c549c09c0023cb92c696c7b98d97bb657088f940e34f4bc47d1a49a/agent_client_protocol-0.8.0-py3-none-any.whl", hash = "sha256:2d5712b88b3249dbd6148b24d32c6eb8992e5663f224db6291524ac80cca8037", size = 54362, upload-time = "2026-02-07T17:08:45.575Z" }, ] [[package]] @@ -329,6 +333,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "giturlparse" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/7f25a604a406be7d7d0f849bfcbc1603df084e9e58fe6170980c231138e4/giturlparse-0.14.0.tar.gz", hash = "sha256:0a13208cb3f60e067ee3d09d28e01f9c936065986004fa2d5cd6db7758e9f6e6", size = 15637, upload-time = "2025-10-22T09:21:11.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f9/9ff5a301459f804a885f237453ba81564bc6ee54740e9f2676c2642043f6/giturlparse-0.14.0-py2.py3-none-any.whl", hash = "sha256:04fd9c262ca9a4db86043d2ef32b2b90bfcbcdefc4f6a260fd9402127880931d", size = 16299, upload-time = "2025-10-22T09:21:10.818Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -673,12 +710,16 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.0.2" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, { name = "anyio" }, + { name = "cryptography" }, + { name = "gitpython" }, + { name = "giturlparse" }, { name = "httpx" }, + { name = "keyring" }, { name = "mcp" }, { name = "mistralai" }, { name = "packaging" }, @@ -695,6 +736,7 @@ dependencies = [ { name = "tree-sitter" }, { name = "tree-sitter-bash" }, { name = "watchfiles" }, + { name = "zstandard" }, ] [package.dev-dependencies] @@ -719,9 +761,13 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", specifier = "==0.7.1" }, + { name = "agent-client-protocol", specifier = "==0.8.0" }, { name = "anyio", specifier = ">=4.12.0" }, + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "gitpython", specifier = ">=3.1.46" }, + { name = "giturlparse", specifier = ">=0.14.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "keyring", specifier = ">=25.6.0" }, { name = "mcp", specifier = ">=1.14.0" }, { name = "mistralai", specifier = "==1.9.11" }, { name = "packaging", specifier = ">=24.1" }, @@ -738,6 +784,7 @@ requires-dist = [ { name = "tree-sitter", specifier = ">=0.25.2" }, { name = "tree-sitter-bash", specifier = ">=0.25.1" }, { name = "watchfiles", specifier = ">=1.1.1" }, + { name = "zstandard", specifier = ">=0.25.0" }, ] [package.metadata.requires-dev] @@ -1493,6 +1540,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "sse-starlette" version = "3.0.3" @@ -1507,15 +1563,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "0.46.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] [[package]] @@ -1532,7 +1587,7 @@ wheels = [ [[package]] name = "textual" -version = "6.9.0" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, @@ -1542,9 +1597,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/8c/774b53a1256fe2649e708b634334d5ce68568d178e55cf3099265c639051/textual-6.9.0.tar.gz", hash = "sha256:49201129a21f65cc16003ce3855cd941a4de7d58eac9489d0e390ba501d712b6", size = 1582278, upload-time = "2025-12-14T17:15:43.03Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/8d/2fbd6b8652f4cabf9cb0852d7af1aa45b6cad32d0f50735856e8f9e41719/textual-7.4.0.tar.gz", hash = "sha256:1a9598e485492f9a8f033c7ec5e59528df3ab0742fda925681acf78b0fb210de", size = 1592252, upload-time = "2026-01-25T19:57:04.624Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/3c/76b607e07d5d63d32d4c4e2803f392913e8e07d5a489fa46f5c25e554ea9/textual-6.9.0-py3-none-any.whl", hash = "sha256:5ded2824fcfc7311b09ac440abb6fe248b3c1431d0403dc0d096795c4f18303c", size = 714791, upload-time = "2025-12-14T17:15:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/4169ccffed6d53f78e3175eae0cd649990071c6e24b6ad8830812ebab726/textual-7.4.0-py3-none-any.whl", hash = "sha256:41a066cae649654d4ecfe53b8316f5737c0042d1693ce50690b769a7840780ac", size = 717985, upload-time = "2026-01-25T19:57:02.966Z" }, ] [[package]] @@ -1845,3 +1900,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/vibe/__init__.py b/vibe/__init__.py index 55a122e..ce01ef9 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.0.2" +__version__ = "2.1.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 8c47cdd..db853ff 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -24,6 +24,7 @@ from acp.helpers import ContentBlock, SessionUpdate from acp.schema import ( AgentCapabilities, AgentMessageChunk, + AgentThoughtChunk, AllowedOutcome, AuthenticateResponse, AuthMethod, @@ -73,6 +74,7 @@ from vibe.core.types import ( AsyncApprovalCallback, CompactEndEvent, CompactStartEvent, + ReasoningEvent, ToolCallEvent, ToolResultEvent, ToolStreamEvent, @@ -173,7 +175,7 @@ class VibeAcpAgentLoop(AcpAgent): async def new_session( self, cwd: str, - mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio], + mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> NewSessionResponse: load_dotenv_values() @@ -295,8 +297,8 @@ class VibeAcpAgentLoop(AcpAgent): async def load_session( self, cwd: str, - mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio], session_id: str, + mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> LoadSessionResponse | None: raise NotImplementedError() @@ -464,6 +466,13 @@ class VibeAcpAgentLoop(AcpAgent): field_meta={"messageId": event.message_id}, ) + elif isinstance(event, ReasoningEvent): + yield AgentThoughtChunk( + session_update="agent_thought_chunk", + content=TextContentBlock(type="text", text=event.content), + field_meta={"messageId": event.message_id}, + ) + elif isinstance(event, ToolCallEvent): if issubclass(event.tool_class, BaseAcpTool): event.tool_class.update_tool_state( diff --git a/vibe/acp/tools/builtins/bash.py b/vibe/acp/tools/builtins/bash.py index d8c4475..90b71cc 100644 --- a/vibe/acp/tools/builtins/bash.py +++ b/vibe/acp/tools/builtins/bash.py @@ -40,7 +40,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]): max_bytes = self.config.max_output_bytes try: - terminal_handle = await client.create_terminal( + terminal = await client.create_terminal( session_id=session_id, command=args.command, cwd=str(Path.cwd()), @@ -49,7 +49,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]): except Exception as e: raise ToolError(f"Failed to create terminal: {e!r}") from e - terminal_id = terminal_handle.id + terminal_id = terminal.terminal_id await self._send_in_progress_session_update([ TerminalToolCallContent(type="terminal", terminal_id=terminal_id) diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index beff89e..23749b8 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -180,6 +180,7 @@ def run_cli(args: argparse.Namespace) -> None: run_textual_ui( agent_loop=agent_loop, initial_prompt=args.initial_prompt or stdin_prompt, + teleport_on_start=args.teleport, ) except (KeyboardInterrupt, EOFError): diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index 4136059..95832b2 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -1,13 +1,8 @@ from __future__ import annotations import base64 -from collections.abc import Callable import os -import platform -import shutil -import subprocess -import pyperclip from textual.app import App _PREVIEW_MAX_LENGTH = 40 @@ -24,33 +19,6 @@ def _copy_osc52(text: str) -> None: tty.flush() -def _copy_x11_clipboard(text: str) -> None: - subprocess.run( - ["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True - ) - - -def _copy_wayland_clipboard(text: str) -> None: - subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True) - - -def _has_cmd(cmd: str) -> bool: - return shutil.which(cmd) is not None - - -def _get_copy_fns(app: App) -> list[Callable[[str], None]]: - copy_fns: list[Callable[[str], None]] = [ - _copy_osc52, - pyperclip.copy, - app.copy_to_clipboard, - ] - if platform.system() == "Linux" and _has_cmd("wl-copy"): - copy_fns = [_copy_wayland_clipboard, *copy_fns] - if platform.system() == "Linux" and _has_cmd("xclip"): - copy_fns = [_copy_x11_clipboard, *copy_fns] - return copy_fns - - def _shorten_preview(texts: list[str]) -> str: dense_text = "⏎".join(texts).replace("\n", "⏎") if len(dense_text) > _PREVIEW_MAX_LENGTH: @@ -58,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str: return dense_text -def copy_selection_to_clipboard(app: App) -> None: +def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None: selected_texts = [] for widget in app.query("*"): @@ -84,27 +52,16 @@ def copy_selection_to_clipboard(app: App) -> None: combined_text = "\n".join(selected_texts) - success = False - copy_fns = _get_copy_fns(app) - - for copy_fn in copy_fns: - try: - copy_fn(combined_text) - except: - pass - else: - success = True - - if success: + try: + _copy_osc52(combined_text) + if show_toast: + app.notify( + f'"{_shorten_preview(selected_texts)}" copied to clipboard', + severity="information", + timeout=2, + markup=False, + ) + except Exception: app.notify( - f'"{_shorten_preview(selected_texts)}" copied to clipboard', - severity="information", - timeout=2, - markup=False, - ) - else: - app.notify( - "Failed to copy - no clipboard method available", - severity="warning", - timeout=3, + "Failed to copy - clipboard not available", severity="warning", timeout=3 ) diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index c14f114..34d8b86 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -22,7 +22,7 @@ class CommandRegistry: handler="_show_help", ), "config": Command( - aliases=frozenset(["/config", "/theme", "/model"]), + aliases=frozenset(["/config", "/model"]), description="Edit config settings", handler="_show_config", ), @@ -62,6 +62,11 @@ class CommandRegistry: description="Display agent statistics", handler="_show_status", ), + "teleport": Command( + aliases=frozenset(["/teleport"]), + description="Teleport session to Vibe Nuage", + handler="_teleport_command", + ), } for command in excluded_commands: @@ -86,7 +91,6 @@ class CommandRegistry: "- `Ctrl+C` Quit (or clear input if text present)", "- `Ctrl+G` Edit input in external editor", "- `Ctrl+O` Toggle tool output view", - "- `Ctrl+T` Toggle todo view", "- `Shift+Tab` Toggle auto-approve mode", "", "### Special Features", diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index 7568186..a656791 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -84,6 +84,9 @@ def parse_arguments() -> argparse.Namespace: help="Change to this directory before running", ) + # Feature flag for teleport, not exposed to the user yet + parser.add_argument("--teleport", action="store_true", help=argparse.SUPPRESS) + continuation_group = parser.add_mutually_exclusive_group() continuation_group.add_argument( "-c", diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py index 0895a27..da15cb5 100644 --- a/vibe/cli/plan_offer/decide_plan_offer.py +++ b/vibe/cli/plan_offer/decide_plan_offer.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum import logging +from os import getenv from vibe.cli.plan_offer.ports.whoami_gateway import ( WhoAmIGateway, @@ -9,6 +10,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import ( WhoAmIGatewayUnauthorized, WhoAmIResponse, ) +from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig logger = logging.getLogger(__name__) @@ -62,3 +64,24 @@ def _action_and_plan_from_response( return PlanOfferAction.UPGRADE, PlanType.FREE case _: return PlanOfferAction.NONE, PlanType.UNKNOWN + + +def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None: + api_env_key = DEFAULT_MISTRAL_API_ENV_KEY + + if provider.backend == Backend.MISTRAL: + api_env_key = provider.api_key_env_var + + return getenv(api_env_key) + + +def plan_offer_cta(action: PlanOfferAction) -> str | None: + if action is PlanOfferAction.NONE: + return + url = ACTION_TO_URL[action] + match action: + case PlanOfferAction.UPGRADE: + text = f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({url})" + case PlanOfferAction.SWITCH_TO_PRO_KEY: + text = f"### Switch to your [Le Chat Pro API key]({url})" + return text diff --git a/vibe/cli/terminal_setup.py b/vibe/cli/terminal_setup.py index c1730df..832fcd4 100644 --- a/vibe/cli/terminal_setup.py +++ b/vibe/cli/terminal_setup.py @@ -272,117 +272,29 @@ def _setup_iterm2() -> SetupResult: def _setup_wezterm() -> SetupResult: - wezterm_config = Path.home() / ".wezterm.lua" - - key_binding = """{ - key = "Enter", - mods = "SHIFT", - action = wezterm.action.SendString("\\x1b[13;2u"), - }""" - - try: - if wezterm_config.exists(): - content = wezterm_config.read_text() - - if 'mods = "SHIFT"' in content and 'key = "Enter"' in content: - return SetupResult( - success=True, - terminal=Terminal.WEZTERM, - message="Shift+Enter already configured in WezTerm", - ) - - if "keys = {" in content: - content = content.replace("keys = {", f"keys = {{\n {key_binding},") - else: - return SetupResult( - success=False, - terminal=Terminal.WEZTERM, - message="Please manually add the following to your .wezterm.lua:\n\n" - f" keys = {{\n {key_binding}\n }}", - ) - else: - content = f"""local wezterm = require 'wezterm' - -return {{ - keys = {{ - {key_binding} - }}, -}} -""" - - wezterm_config.write_text(content) - - return SetupResult( - success=True, - terminal=Terminal.WEZTERM, - message=f"Added Shift+Enter binding to {wezterm_config}", - requires_restart=True, - ) - - except Exception as e: - return SetupResult( - success=False, - terminal=Terminal.WEZTERM, - message=f"Failed to configure WezTerm: {e}", - ) + return SetupResult( + success=True, + terminal=Terminal.WEZTERM, + message="Please manually add the following to your .wezterm.lua:\n" + "local wezterm = require 'wezterm'\n" + "local config = wezterm.config_builder()\n\n" + "config.keys = {\n" + " {\n" + ' key = "Enter",\n' + ' mods = "SHIFT",\n' + ' action = wezterm.action.SendString("\\x1b[13;2u"),\n' + " }\n" + "}\n\n" + "return config", + ) def _setup_ghostty() -> SetupResult: - system = platform.system() - - if system == "Darwin": - config_path = ( - Path.home() - / "Library" - / "Application Support" - / "com.mitchellh.ghostty" - / "config" - ) - elif system == "Linux": - xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) - config_path = Path(xdg_config) / "ghostty" / "config" - else: - return SetupResult( - success=False, - terminal=Terminal.GHOSTTY, - message="Ghostty configuration path unknown for this OS", - ) - - keybind_line = "keybind = shift+enter=text:\\x1b[13;2u" - - try: - if config_path.exists(): - content = config_path.read_text() - - if "shift+enter" in content.lower(): - return SetupResult( - success=True, - terminal=Terminal.GHOSTTY, - message="Shift+Enter already configured in Ghostty", - ) - - if not content.endswith("\n"): - content += "\n" - content += keybind_line + "\n" - else: - config_path.parent.mkdir(parents=True, exist_ok=True) - content = keybind_line + "\n" - - config_path.write_text(content) - - return SetupResult( - success=True, - terminal=Terminal.GHOSTTY, - message=f"Added Shift+Enter binding to {config_path}", - requires_restart=True, - ) - - except Exception as e: - return SetupResult( - success=False, - terminal=Terminal.GHOSTTY, - message=f"Failed to configure Ghostty: {e}", - ) + return SetupResult( + success=True, + terminal=Terminal.GHOSTTY, + message="Shift+Enter is already configured in Ghostty", + ) def setup_terminal() -> SetupResult: diff --git a/vibe/cli/textual_ui/ansi_markdown.py b/vibe/cli/textual_ui/ansi_markdown.py new file mode 100644 index 0000000..b07c2ce --- /dev/null +++ b/vibe/cli/textual_ui/ansi_markdown.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pygments.token import Token +from textual.content import Content +from textual.highlight import HighlightTheme, highlight +from textual.widgets import Markdown +from textual.widgets._markdown import MarkdownFence + + +class AnsiHighlightTheme(HighlightTheme): + STYLES = { + Token.Comment: "ansi_bright_black italic", + Token.Error: "ansi_red", + Token.Generic.Strong: "bold", + Token.Generic.Emph: "italic", + Token.Generic.Error: "ansi_red", + Token.Generic.Heading: "ansi_blue underline", + Token.Generic.Subheading: "ansi_blue", + Token.Keyword: "ansi_magenta", + Token.Keyword.Constant: "ansi_cyan", + Token.Keyword.Namespace: "ansi_magenta", + Token.Keyword.Type: "ansi_cyan", + Token.Literal.Number: "ansi_yellow", + Token.Literal.String.Backtick: "ansi_bright_black", + Token.Literal.String: "ansi_green", + Token.Literal.String.Doc: "ansi_green italic", + Token.Literal.String.Double: "ansi_green", + Token.Name: "ansi_default", + Token.Name.Attribute: "ansi_yellow", + Token.Name.Builtin: "ansi_cyan", + Token.Name.Builtin.Pseudo: "italic", + Token.Name.Class: "ansi_yellow", + Token.Name.Constant: "ansi_red", + Token.Name.Decorator: "ansi_blue", + Token.Name.Function: "ansi_blue", + Token.Name.Function.Magic: "ansi_blue", + Token.Name.Tag: "ansi_blue", + Token.Name.Variable: "ansi_default", + Token.Number: "ansi_yellow", + Token.Operator: "ansi_default", + Token.Operator.Word: "ansi_magenta", + Token.String: "ansi_green", + Token.Whitespace: "", + } + + +class AnsiMarkdownFence(MarkdownFence): + @classmethod + def highlight(cls, code: str, language: str) -> Content: + return highlight(code, language=language or None, theme=AnsiHighlightTheme) + + +class AnsiMarkdown(Markdown): + BLOCKS = { + **Markdown.BLOCKS, + "fence": AnsiMarkdownFence, + "code_block": AnsiMarkdownFence, + } diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index e73b014..447d51c 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -2,16 +2,16 @@ from __future__ import annotations import asyncio from enum import StrEnum, auto -from os import getenv from pathlib import Path import subprocess import time from typing import Any, ClassVar, assert_never, cast +from weakref import WeakKeyDictionary from pydantic import BaseModel from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import Horizontal, VerticalScroll +from textual.containers import Horizontal, VerticalGroup, VerticalScroll from textual.events import AppBlur, AppFocus, MouseUp from textual.widget import Widget from textual.widgets import Static @@ -21,31 +21,27 @@ from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.commands import CommandRegistry from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway from vibe.cli.plan_offer.decide_plan_offer import ( - ACTION_TO_URL, - PlanOfferAction, PlanType, decide_plan_offer, + plan_offer_cta, + resolve_api_key_for_plan, ) from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway from vibe.cli.terminal_setup import setup_terminal from vibe.cli.textual_ui.handlers.event_handler import EventHandler -from vibe.cli.textual_ui.terminal_theme import ( - TERMINAL_THEME_NAME, - capture_terminal_theme, -) -from vibe.cli.textual_ui.widgets.agent_indicator import AgentIndicator from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp +from vibe.cli.textual_ui.widgets.banner.banner import Banner from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer from vibe.cli.textual_ui.widgets.compact import CompactMessage from vibe.cli.textual_ui.widgets.config_app import ConfigApp from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState +from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer from vibe.cli.textual_ui.widgets.messages import ( AssistantMessage, BashOutputMessage, ErrorMessage, InterruptMessage, - PlanOfferMessage, ReasoningMessage, StreamingMessageBase, UserCommandMessage, @@ -56,8 +52,19 @@ from vibe.cli.textual_ui.widgets.messages import ( from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.path_display import PathDisplay from vibe.cli.textual_ui.widgets.question_app import QuestionApp +from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage -from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner +from vibe.cli.textual_ui.windowing import ( + HISTORY_RESUME_TAIL_MESSAGES, + LOAD_MORE_BATCH_SIZE, + HistoryLoadMoreManager, + SessionWindowing, + build_history_widgets, + create_resume_plan, + non_system_history_messages, + should_resume_history, + sync_backfill_state, +) from vibe.cli.update_notifier import ( FileSystemUpdateCacheRepository, PyPIUpdateGateway, @@ -70,16 +77,29 @@ from vibe.cli.update_notifier import ( should_show_whats_new, ) from vibe.cli.update_notifier.update import do_update -from vibe.core.agent_loop import AgentLoop +from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.agents import AgentProfile from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.config import VibeConfig from vibe.core.paths.config_paths import HISTORY_FILE from vibe.core.session.session_loader import SessionLoader +from vibe.core.teleport.types import ( + TeleportAuthCompleteEvent, + TeleportAuthRequiredEvent, + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportPushResponseEvent, + TeleportSendingGithubTokenEvent, + TeleportStartingWorkflowEvent, +) from vibe.core.tools.base import ToolPermission from vibe.core.tools.builtins.ask_user_question import ( AskUserQuestionArgs, AskUserQuestionResult, + Choice, + Question, ) from vibe.core.types import ( AgentStats, @@ -110,6 +130,47 @@ class BottomApp(StrEnum): Question = auto() +class ChatScroll(VerticalScroll): + """Optimized scroll container that skips cascading style recalculations.""" + + def update_node_styles(self, animate: bool = True) -> None: + pass + + +PRUNE_LOW_MARK = 1000 +PRUNE_HIGH_MARK = 1500 + + +async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool: + """Remove older children to keep virtual height within bounds. + Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495 + """ + height = messages_area.virtual_size.height + if height <= high_mark: + return False + prune_children: list[Widget] = [] + bottom_margin = 0 + prune_height = 0 + for child in messages_area.children: + if not child.display: + prune_children.append(child) + continue + top, _, bottom, _ = child.styles.margin + child_height = child.outer_size.height + prune_height = ( + (prune_height - bottom_margin + max(bottom_margin, top)) + + bottom + + child_height + ) + bottom_margin = bottom + if height - prune_height <= low_mark: + break + prune_children.append(child) + if prune_children: + await messages_area.remove_children(prune_children) + return bool(prune_children) + + class VibeApp(App): # noqa: PLR0904 ENABLE_COMMAND_PALETTE = False CSS_PATH = "app.tcss" @@ -119,7 +180,8 @@ class VibeApp(App): # noqa: PLR0904 Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True), Binding("escape", "interrupt", "Interrupt", show=False, priority=True), Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False), - Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False), + Binding("ctrl+y", "copy_selection", "Copy", show=False, priority=True), + Binding("ctrl+shift+c", "copy_selection", "Copy", show=False, priority=True), Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True), Binding("shift+up", "scroll_chat_up", "Scroll Up", show=False, priority=True), Binding( @@ -131,6 +193,7 @@ class VibeApp(App): # noqa: PLR0904 self, agent_loop: AgentLoop, initial_prompt: str | None = None, + teleport_on_start: bool = False, update_notifier: UpdateGateway | None = None, update_cache_repository: UpdateCacheRepository | None = None, current_version: str = CORE_VERSION, @@ -148,42 +211,51 @@ class VibeApp(App): # noqa: PLR0904 self._pending_question: asyncio.Future | None = None self.event_handler: EventHandler | None = None - self.commands = CommandRegistry() + + excluded_commands = [] + if not self.config.nuage_enabled: + excluded_commands.append("teleport") + self.commands = CommandRegistry(excluded_commands=excluded_commands) self._chat_input_container: ChatInputContainer | None = None - self._agent_indicator: AgentIndicator | None = None self._current_bottom_app: BottomApp = BottomApp.Input self.history_file = HISTORY_FILE.path self._tools_collapsed = True - self._todos_collapsed = False self._current_streaming_message: AssistantMessage | None = None self._current_streaming_reasoning: ReasoningMessage | None = None + self._windowing = SessionWindowing(load_more_batch_size=LOAD_MORE_BATCH_SIZE) + self._load_more = HistoryLoadMoreManager() + self._tool_call_map: dict[str, str] | None = None + self._history_widget_indices: WeakKeyDictionary[Widget, int] = ( + WeakKeyDictionary() + ) self._update_notifier = update_notifier self._update_cache_repository = update_cache_repository self._current_version = current_version self._plan_offer_gateway = plan_offer_gateway - self._plan_offer_shown = False self._initial_prompt = initial_prompt + self._teleport_on_start = teleport_on_start and self.config.nuage_enabled self._auto_scroll = True self._last_escape_time: float | None = None - self._terminal_theme = capture_terminal_theme() + self._banner: Banner | None = None + self._cached_messages_area: Widget | None = None + self._cached_chat: ChatScroll | None = None + self._cached_loading_area: Widget | None = None @property def config(self) -> VibeConfig: return self.agent_loop.config def compose(self) -> ComposeResult: - with VerticalScroll(id="chat"): - yield WelcomeBanner(self.config) - yield Static(id="messages") + with ChatScroll(id="chat"): + self._banner = Banner(self.config, self.agent_loop.skill_manager) + yield self._banner + yield VerticalGroup(id="messages") with Horizontal(id="loading-area"): yield Static(id="loading-area-content") - yield AgentIndicator(profile=self.agent_loop.agent_profile) - - yield Static(id="todo-area") with Static(id="bottom-app-container"): yield ChatInputContainer( @@ -191,7 +263,9 @@ class VibeApp(App): # noqa: PLR0904 command_registry=self.commands, id="input-container", safety=self.agent_loop.agent_profile.safety, + agent_name=self.agent_loop.agent_profile.display_name.lower(), skill_entries_getter=self._get_skill_entries, + nuage_enabled=self.config.nuage_enabled, ) with Horizontal(id="bottom-bar"): @@ -200,25 +274,19 @@ class VibeApp(App): # noqa: PLR0904 yield ContextProgress() async def on_mount(self) -> None: - if self._terminal_theme: - self.register_theme(self._terminal_theme) + self.theme = "textual-ansi" - if self.config.textual_theme == TERMINAL_THEME_NAME: - if self._terminal_theme: - self.theme = TERMINAL_THEME_NAME - else: - self.theme = self.config.textual_theme + self._cached_messages_area = self.query_one("#messages") + self._cached_chat = self.query_one("#chat", ChatScroll) + self._cached_loading_area = self.query_one("#loading-area-content") self.event_handler = EventHandler( mount_callback=self._mount_and_scroll, scroll_callback=self._scroll_to_bottom_deferred, - todo_area_callback=lambda: self.query_one("#todo-area"), get_tools_collapsed=lambda: self._tools_collapsed, - get_todos_collapsed=lambda: self._todos_collapsed, ) self._chat_input_container = self.query_one(ChatInputContainer) - self._agent_indicator = self.query_one(AgentIndicator) context_progress = self.query_one(ContextProgress) def update_context_progress(stats: AgentStats) -> None: @@ -227,7 +295,7 @@ class VibeApp(App): # noqa: PLR0904 current_tokens=stats.context_tokens, ) - AgentStats.add_listener("context_tokens", update_context_progress) + self.agent_loop.stats.add_listener("context_tokens", update_context_progress) self.agent_loop.stats.trigger_listeners() self.agent_loop.set_approval_callback(self._approval_callback) @@ -237,17 +305,19 @@ class VibeApp(App): # noqa: PLR0904 chat_input_container = self.query_one(ChatInputContainer) chat_input_container.focus_input() await self._show_dangerous_directory_warning() + await self._resume_history_from_messages() await self._check_and_show_whats_new() - await self._maybe_show_plan_offer() self._schedule_update_notification() - await self._rebuild_history_from_messages() - - if self._initial_prompt: + if self._initial_prompt or self._teleport_on_start: self.call_after_refresh(self._process_initial_prompt) def _process_initial_prompt(self) -> None: - if self._initial_prompt: + if self._teleport_on_start: + self.run_worker( + self._handle_teleport_command(self._initial_prompt), exclusive=False + ) + elif self._initial_prompt: self.run_worker( self._handle_user_message(self._initial_prompt), exclusive=False ) @@ -255,6 +325,9 @@ class VibeApp(App): # noqa: PLR0904 async def on_chat_input_container_submitted( self, event: ChatInputContainer.Submitted ) -> None: + if self._banner: + self._banner.freeze_animation() + value = event.value.strip() if not value: return @@ -269,6 +342,11 @@ class VibeApp(App): # noqa: PLR0904 await self._handle_bash_command(value[1:]) return + if value.startswith("&"): + if self.config.nuage_enabled: + await self._handle_teleport_command(value[1:]) + return + if await self._handle_command(value): return @@ -330,29 +408,6 @@ class VibeApp(App): # noqa: PLR0904 if self._loading_widget and self._loading_widget.parent: await self._loading_widget.remove() self._loading_widget = None - self._hide_todo_area() - - def _show_todo_area(self) -> None: - try: - todo_area = self.query_one("#todo-area") - todo_area.add_class("loading-active") - except Exception: - pass - - def _hide_todo_area(self) -> None: - try: - todo_area = self.query_one("#todo-area") - todo_area.remove_class("loading-active") - except Exception: - pass - - def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None: - if message.key == "textual_theme": - if message.value == TERMINAL_THEME_NAME: - if self._terminal_theme: - self.theme = TERMINAL_THEME_NAME - else: - self.theme = message.value async def on_config_app_config_closed( self, message: ConfigApp.ConfigClosed @@ -370,7 +425,7 @@ class VibeApp(App): # noqa: PLR0904 async def on_compact_message_completed( self, message: CompactMessage.Completed ) -> None: - messages_area = self.query_one("#messages") + messages_area = self._cached_messages_area or self.query_one("#messages") children = list(messages_area.children) try: @@ -483,62 +538,62 @@ class VibeApp(App): # noqa: PLR0904 self._handle_agent_loop_turn(message) ) - async def _rebuild_history_from_messages(self) -> None: - if all(msg.role == Role.system for msg in self.agent_loop.messages): + async def _resume_history_from_messages(self) -> None: + messages_area = self._cached_messages_area or self.query_one("#messages") + if not should_resume_history(list(messages_area.children)): return - messages_area = self.query_one("#messages") - # Don't rebuild if messages are already displayed - if messages_area.children: + self._windowing.reset() + history_messages = non_system_history_messages(self.agent_loop.messages) + if ( + plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES) + ) is None: return + await self._mount_history_batch( + plan.tail_messages, + messages_area, + plan.tool_call_map, + start_index=plan.tail_start_index, + ) + self.call_after_refresh( + lambda: self._align_chat_after_history_rebuild(plan.has_backfill) + ) + self._tool_call_map = plan.tool_call_map + self._windowing.set_backfill(plan.backfill_messages) + await self._load_more.set_visible( + messages_area, + visible=self._windowing.has_backfill, + remaining=self._windowing.remaining, + ) - tool_call_map: dict[str, str] = {} + async def _mount_history_batch( + self, + batch: list[LLMMessage], + messages_area: Widget, + tool_call_map: dict[str, str], + *, + start_index: int, + before: Widget | int | None = None, + after: Widget | None = None, + ) -> None: + widgets = build_history_widgets( + batch=batch, + tool_call_map=tool_call_map, + start_index=start_index, + tools_collapsed=self._tools_collapsed, + history_widget_indices=self._history_widget_indices, + ) with self.batch_update(): - for msg in self.agent_loop.messages: - if msg.role == Role.system: - continue - - match msg.role: - case Role.user: - if msg.content: - await messages_area.mount(UserMessage(msg.content)) - - case Role.assistant: - await self._mount_history_assistant_message( - msg, messages_area, tool_call_map - ) - - case Role.tool: - tool_name = msg.name or tool_call_map.get( - msg.tool_call_id or "", "tool" - ) - await messages_area.mount( - ToolResultMessage( - tool_name=tool_name, - content=msg.content, - collapsed=self._tools_collapsed, - ) - ) - - async def _mount_history_assistant_message( - self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str] - ) -> None: - if msg.content: - widget = AssistantMessage(msg.content) - await messages_area.mount(widget) - await widget.write_initial_content() - await widget.stop_stream() - - if not msg.tool_calls: - return - - for tool_call in msg.tool_calls: - tool_name = tool_call.function.name or "unknown" - if tool_call.id: - tool_call_map[tool_call.id] = tool_name - - await messages_area.mount(ToolCallMessage(tool_name=tool_name)) + if not widgets: + return + if before is not None: + await messages_area.mount_all(widgets, before=before) + return + if after is not None: + await messages_area.mount_all(widgets, after=after) + return + await messages_area.mount_all(widgets) def _is_tool_enabled_in_main_agent(self, tool: str) -> bool: return tool in self.agent_loop.tool_manager.available_tools @@ -574,12 +629,13 @@ class VibeApp(App): # noqa: PLR0904 async def _handle_agent_loop_turn(self, prompt: str) -> None: self._agent_running = True - loading_area = self.query_one("#loading-area-content") + loading_area = self._cached_loading_area or self.query_one( + "#loading-area-content" + ) loading = LoadingWidget() self._loading_widget = loading await loading_area.mount(loading) - self._show_todo_area() try: rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) @@ -595,13 +651,13 @@ class VibeApp(App): # noqa: PLR0904 if self._loading_widget and self._loading_widget.parent: await self._loading_widget.remove() if self.event_handler: - self.event_handler.stop_current_tool_call() + self.event_handler.stop_current_tool_call(success=False) raise except Exception as e: if self._loading_widget and self._loading_widget.parent: await self._loading_widget.remove() if self.event_handler: - self.event_handler.stop_current_tool_call() + self.event_handler.stop_current_tool_call(success=False) message = str(e) if isinstance(e, RateLimitError): @@ -620,8 +676,100 @@ class VibeApp(App): # noqa: PLR0904 if self._loading_widget: await self._loading_widget.remove() self._loading_widget = None - self._hide_todo_area() await self._finalize_current_streaming_message() + await self._refresh_windowing_from_history() + + async def _teleport_command(self) -> None: + await self._handle_teleport_command(show_message=False) + + async def _handle_teleport_command( + self, value: str | None = None, show_message: bool = True + ) -> None: + has_history = any(msg.role != Role.system for msg in self.agent_loop.messages) + if not value: + if show_message: + await self._mount_and_scroll(UserMessage("/teleport")) + if not has_history: + await self._mount_and_scroll( + ErrorMessage( + "No conversation history to teleport.", + collapsed=self._tools_collapsed, + ) + ) + return + elif show_message: + await self._mount_and_scroll(UserMessage(value)) + self.run_worker(self._teleport(value), exclusive=False) + + async def _teleport(self, prompt: str | None = None) -> None: + loading_area = self._cached_loading_area or self.query_one( + "#loading-area-content" + ) + loading = LoadingWidget() + await loading_area.mount(loading) + + teleport_msg = TeleportMessage() + await self._mount_and_scroll(teleport_msg) + + try: + gen = self.agent_loop.teleport_to_vibe_nuage(prompt) + async for event in gen: + match event: + case TeleportCheckingGitEvent(): + teleport_msg.set_status("Checking git status...") + case TeleportPushRequiredEvent(unpushed_count=count): + await loading.remove() + response = await self._ask_push_approval(count) + await loading_area.mount(loading) + teleport_msg.set_status("Teleporting...") + await gen.asend(response) + case TeleportPushingEvent(): + teleport_msg.set_status("Pushing to remote...") + case TeleportAuthRequiredEvent( + user_code=code, verification_uri=uri + ): + teleport_msg.set_status( + f"GitHub auth required. Code: {code} (copied)\nOpen: {uri}" + ) + case TeleportAuthCompleteEvent(): + teleport_msg.set_status("GitHub authenticated.") + case TeleportStartingWorkflowEvent(): + teleport_msg.set_status("Starting Nuage workflow...") + case TeleportSendingGithubTokenEvent(): + teleport_msg.set_status("Sending encrypted GitHub token...") + case TeleportCompleteEvent(url=url): + teleport_msg.set_complete(url) + except TeleportError as e: + await teleport_msg.remove() + await self._mount_and_scroll( + ErrorMessage(str(e), collapsed=self._tools_collapsed) + ) + finally: + if loading.parent: + await loading.remove() + + async def _ask_push_approval(self, count: int) -> TeleportPushResponseEvent: + word = f"commit{'s' if count != 1 else ''}" + push_label = "Push and continue" + result = await self._user_input_callback( + AskUserQuestionArgs( + questions=[ + Question( + question=f"You have {count} unpushed {word}. Push to continue?", + header="Push", + options=[Choice(label=push_label), Choice(label="Cancel")], + hide_other=True, + ) + ] + ) + ) + ok = ( + isinstance(result, AskUserQuestionResult) + and not result.cancelled + and bool(result.answers) + and result.answers[0].answer == push_label + ) + return TeleportPushResponseEvent(approved=ok) async def _interrupt_agent_loop(self) -> None: if not self._agent_running or self._interrupt_requested: @@ -637,14 +785,15 @@ class VibeApp(App): # noqa: PLR0904 pass if self.event_handler: - self.event_handler.stop_current_tool_call() + self.event_handler.stop_current_tool_call(success=False) self.event_handler.stop_current_compact() self._agent_running = False - loading_area = self.query_one("#loading-area-content") + loading_area = self._cached_loading_area or self.query_one( + "#loading-area-content" + ) await loading_area.remove_children() self._loading_widget = None - self._hide_todo_area() await self._finalize_current_streaming_message() await self._mount_and_scroll(InterruptMessage()) @@ -676,10 +825,16 @@ class VibeApp(App): # noqa: PLR0904 async def _reload_config(self) -> None: try: + self._windowing.reset() + self._tool_call_map = None + self._history_widget_indices = WeakKeyDictionary() + await self._load_more.hide() base_config = VibeConfig.load() await self.agent_loop.reload_with_initial_messages(base_config=base_config) + if self._banner: + self._banner.set_state(base_config, self.agent_loop.skill_manager) await self._mount_and_scroll(UserCommandMessage("Configuration reloaded.")) except Exception as e: await self._mount_and_scroll( @@ -690,18 +845,19 @@ class VibeApp(App): # noqa: PLR0904 async def _clear_history(self) -> None: try: + self._windowing.reset() + self._tool_call_map = None + self._history_widget_indices = WeakKeyDictionary() await self.agent_loop.clear_history() await self._finalize_current_streaming_message() - messages_area = self.query_one("#messages") + messages_area = self._cached_messages_area or self.query_one("#messages") await messages_area.remove_children() - todo_area = self.query_one("#todo-area") - await todo_area.remove_children() await messages_area.mount(UserMessage("/clear")) await self._mount_and_scroll( UserCommandMessage("Conversation history cleared!") ) - chat = self.query_one("#chat", VerticalScroll) + chat = self._cached_chat or self.query_one("#chat", ChatScroll) chat.scroll_home(animate=False) except Exception as e: @@ -805,16 +961,14 @@ class VibeApp(App): # noqa: PLR0904 if result.success: if result.requires_restart: + message = f"{result.message or 'Set up Shift+Enter keybind'} (You may need to restart your terminal.)" await self._mount_and_scroll( - UserCommandMessage( - f"{result.terminal.value}: Set up Shift+Enter keybind (You may need to restart your terminal.)" - ) + UserCommandMessage(f"{result.terminal.value}: {message}") ) else: + message = result.message or "Shift+Enter keybind already set up" await self._mount_and_scroll( - WarningMessage( - f"{result.terminal.value}: Shift+Enter keybind already set up" - ) + WarningMessage(f"{result.terminal.value}: {message}") ) else: await self._mount_and_scroll( @@ -828,9 +982,6 @@ class VibeApp(App): # noqa: PLR0904 self._chat_input_container.display = False self._chat_input_container.disabled = True - if self._agent_indicator: - self._agent_indicator.display = False - self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")] await bottom_container.mount(widget) @@ -843,9 +994,7 @@ class VibeApp(App): # noqa: PLR0904 return await self._mount_and_scroll(UserCommandMessage("Configuration opened...")) - await self._switch_from_input( - ConfigApp(self.config, has_terminal_theme=self._terminal_theme is not None) - ) + await self._switch_from_input(ConfigApp(self.config)) async def _switch_to_approval_app( self, tool_name: str, tool_args: BaseModel @@ -866,9 +1015,6 @@ class VibeApp(App): # noqa: PLR0904 except Exception: pass - if self._agent_indicator: - self._agent_indicator.display = True - if self._chat_input_container: self._chat_input_container.disabled = False self._chat_input_container.display = True @@ -942,12 +1088,44 @@ class VibeApp(App): # noqa: PLR0904 self._scroll_to_bottom() self._focus_current_bottom_app() + async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None: + self._load_more.set_enabled(False) + try: + if not self._windowing.has_backfill: + await self._load_more.hide() + return + if (batch := self._windowing.next_load_more_batch()) is None: + await self._load_more.hide() + return + messages_area = self._cached_messages_area or self.query_one("#messages") + if self._tool_call_map is None: + self._tool_call_map = {} + if self._load_more.widget: + before: Widget | int | None = None + after: Widget | None = self._load_more.widget + else: + before = 0 + after = None + await self._mount_history_batch( + batch.messages, + messages_area, + self._tool_call_map, + start_index=batch.start_index, + before=before, + after=after, + ) + if not self._windowing.has_backfill: + await self._load_more.hide() + else: + await self._load_more.show(messages_area, self._windowing.remaining) + finally: + self._load_more.set_enabled(True) + async def action_toggle_tool(self) -> None: self._tools_collapsed = not self._tools_collapsed for result in self.query(ToolResultMessage): - if result.tool_name != "todo": - await result.set_collapsed(self._tools_collapsed) + await result.set_collapsed(self._tools_collapsed) try: for error_msg in self.query(ErrorMessage): @@ -955,13 +1133,6 @@ class VibeApp(App): # noqa: PLR0904 except Exception: pass - async def action_toggle_todo(self) -> None: - self._todos_collapsed = not self._todos_collapsed - - for result in self.query(ToolResultMessage): - if result.tool_name == "todo": - await result.set_collapsed(self._todos_collapsed) - def action_cycle_mode(self) -> None: if self._current_bottom_app != BottomApp.Input: return @@ -973,10 +1144,9 @@ class VibeApp(App): # noqa: PLR0904 self._update_profile_widgets(self.agent_loop.agent_profile) def _update_profile_widgets(self, profile: AgentProfile) -> None: - if self._agent_indicator: - self._agent_indicator.set_profile(profile) if self._chat_input_container: self._chat_input_container.set_safety(profile.safety) + self._chat_input_container.set_agent_name(profile.display_name.lower()) async def _cycle_agent(self) -> None: new_profile = self.agent_loop.agent_manager.next_agent( @@ -1005,7 +1175,7 @@ class VibeApp(App): # noqa: PLR0904 def action_scroll_chat_up(self) -> None: try: - chat = self.query_one("#chat", VerticalScroll) + chat = self._cached_chat or self.query_one("#chat", ChatScroll) chat.scroll_relative(y=-5, animate=False) self._auto_scroll = False except Exception: @@ -1013,7 +1183,7 @@ class VibeApp(App): # noqa: PLR0904 def action_scroll_chat_down(self) -> None: try: - chat = self.query_one("#chat", VerticalScroll) + chat = self._cached_chat or self.query_one("#chat", ChatScroll) chat.scroll_relative(y=5, animate=False) if self._is_scrolled_to_bottom(chat): self._auto_scroll = True @@ -1039,42 +1209,37 @@ class VibeApp(App): # noqa: PLR0904 content = load_whats_new_content() if content is not None: - await self._mount_and_scroll(WhatsNewMessage(content)) + whats_new_message = WhatsNewMessage(content) + plan_offer = await self._plan_offer_cta() + if plan_offer is not None: + whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}") + if self._history_widget_indices: + whats_new_message.add_class("after-history") + await self._mount_and_scroll(whats_new_message) await mark_version_as_seen(self._current_version, self._update_cache_repository) - async def _maybe_show_plan_offer(self) -> None: - if self._plan_offer_shown: - return - action, plan_type = await self._resolve_plan_offer_action() - self.plan_type = plan_type - if action is PlanOfferAction.NONE: - return - url = ACTION_TO_URL[action] - match action: - case PlanOfferAction.UPGRADE: - text = f"Upgrade to [Pro]({url})" - case PlanOfferAction.SWITCH_TO_PRO_KEY: - text = f"Switch to your [Pro API key]({url})" - await self._mount_and_scroll(PlanOfferMessage(text)) - self._plan_offer_shown = True + async def _plan_offer_cta(self) -> str | None: + self.plan_type = PlanType.UNKNOWN + + if self._plan_offer_gateway is None: + return - async def _resolve_plan_offer_action(self) -> tuple[PlanOfferAction, PlanType]: try: active_model = self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) - except ValueError: - return PlanOfferAction.NONE, PlanType.UNKNOWN - api_key_env = provider.api_key_env_var - api_key = getenv(api_key_env) if api_key_env else None - gateway = self._plan_offer_gateway or HttpWhoAmIGateway() - try: - return await decide_plan_offer(api_key, gateway) + api_key = resolve_api_key_for_plan(provider) + action, plan_type = await decide_plan_offer( + api_key, self._plan_offer_gateway + ) + + self.plan_type = plan_type + return plan_offer_cta(action) except Exception as exc: logger.warning( "Plan-offer check failed (%s).", type(exc).__name__, exc_info=True ) - return PlanOfferAction.NONE, PlanType.UNKNOWN + return async def _finalize_current_streaming_message(self) -> None: if self._current_streaming_reasoning is not None: @@ -1108,9 +1273,10 @@ class VibeApp(App): # noqa: PLR0904 return widget async def _mount_and_scroll(self, widget: Widget) -> None: - messages_area = self.query_one("#messages") - chat = self.query_one("#chat", VerticalScroll) + messages_area = self._cached_messages_area or self.query_one("#messages") + chat = self._cached_chat or self.query_one("#chat", ChatScroll) was_at_bottom = self._is_scrolled_to_bottom(chat) + result: Widget | None = None if was_at_bottom: self._auto_scroll = True @@ -1140,12 +1306,15 @@ class VibeApp(App): # noqa: PLR0904 else: await self._finalize_current_streaming_message() await messages_area.mount(widget) + result = widget is_tool_message = isinstance(widget, (ToolCallMessage, ToolResultMessage)) if not is_tool_message: self.call_after_refresh(self._scroll_to_bottom) + if result is not None: + self.call_after_refresh(self._try_prune) if was_at_bottom: self.call_after_refresh(self._anchor_if_scrollable) @@ -1158,7 +1327,7 @@ class VibeApp(App): # noqa: PLR0904 def _scroll_to_bottom(self) -> None: try: - chat = self.query_one("#chat") + chat = self._cached_chat or self.query_one("#chat", ChatScroll) chat.scroll_end(animate=False) except Exception: pass @@ -1166,17 +1335,50 @@ class VibeApp(App): # noqa: PLR0904 def _scroll_to_bottom_deferred(self) -> None: self.call_after_refresh(self._scroll_to_bottom) + async def _try_prune(self) -> None: + messages_area = self._cached_messages_area or self.query_one("#messages") + await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK) + if self._load_more.widget and not self._load_more.widget.parent: + self._load_more.widget = None + + async def _refresh_windowing_from_history(self) -> None: + if self._load_more.widget is None: + return + messages_area = self._cached_messages_area or self.query_one("#messages") + has_backfill, tool_call_map = sync_backfill_state( + history_messages=non_system_history_messages(self.agent_loop.messages), + messages_children=list(messages_area.children), + history_widget_indices=self._history_widget_indices, + windowing=self._windowing, + ) + self._tool_call_map = tool_call_map + await self._load_more.set_visible( + messages_area, visible=has_backfill, remaining=self._windowing.remaining + ) + def _anchor_if_scrollable(self) -> None: if not self._auto_scroll: return try: - chat = self.query_one("#chat", VerticalScroll) + chat = self._cached_chat or self.query_one("#chat", ChatScroll) if chat.max_scroll_y == 0: return chat.anchor() except Exception: pass + def _align_chat_after_history_rebuild(self, has_backfill: bool) -> None: + try: + chat = self._cached_chat or self.query_one("#chat", ChatScroll) + if has_backfill and chat.max_scroll_y > 0: + chat.anchor(True) + chat.scroll_end(animate=False) + chat.anchor(False) + return + chat.scroll_end(animate=False) + except Exception: + pass + def _schedule_update_notification(self) -> None: if self._update_notifier is None or not self.config.enable_update_checks: return @@ -1227,8 +1429,12 @@ class VibeApp(App): # noqa: PLR0904 message, title="Update available", severity="information", timeout=10 ) + def action_copy_selection(self) -> None: + copy_selection_to_clipboard(self, show_toast=False) + def on_mouse_up(self, event: MouseUp) -> None: - copy_selection_to_clipboard(self) + if self.config.autocopy_to_clipboard: + copy_selection_to_clipboard(self, show_toast=True) def on_app_blur(self, event: AppBlur) -> None: if self._chat_input_container and self._chat_input_container.input_widget: @@ -1248,14 +1454,21 @@ def _print_session_resume_message(session_id: str | None) -> None: print(f"Or: vibe --resume {session_id}") -def run_textual_ui(agent_loop: AgentLoop, initial_prompt: str | None = None) -> None: +def run_textual_ui( + agent_loop: AgentLoop, + initial_prompt: str | None = None, + teleport_on_start: bool = False, +) -> None: update_notifier = PyPIUpdateGateway(project_name="mistral-vibe") update_cache_repository = FileSystemUpdateCacheRepository() + plan_offer_gateway = HttpWhoAmIGateway() app = VibeApp( agent_loop=agent_loop, initial_prompt=initial_prompt, + teleport_on_start=teleport_on_start, update_notifier=update_notifier, update_cache_repository=update_cache_repository, + plan_offer_gateway=plan_offer_gateway, ) session_id = app.run() _print_session_resume_message(session_id) diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 066c542..dc57a97 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -1,18 +1,40 @@ +$mistral_orange: #FF8205; + +* { + scrollbar-color: ansi_black; + scrollbar-color-hover: ansi_bright_black; + scrollbar-color-active: ansi_bright_black; + scrollbar-background: transparent; + scrollbar-background-hover: transparent; + scrollbar-background-active: transparent; + scrollbar-size: 1 1; +} + Screen { - background: $background; + background: transparent; +} + +Horizontal { + width: 100%; + height: auto; +} + +TextArea > .text-area--cursor { + color: ansi_default; } #chat { + layout: stream; height: 1fr; width: 100%; - background: $background; + background: transparent; padding: 0; } #loading-area { height: auto; width: 100%; - background: $background; + background: transparent; padding: 1 0 0 0; layout: horizontal; align: left middle; @@ -24,43 +46,18 @@ Screen { align: left middle; } -#todo-area { - height: auto; - max-height: 10; - width: 100%; - background: $background; - margin: 0; - padding: 0; - overflow: hidden; - text-align: left; - margin-top: 0; - - .tool-result-border { - display: none; - } - - &.loading-active .tool-result-border { - display: block; - } - - .tool-result-content { - max-height: 9; - overflow-y: auto; - scrollbar-visibility: hidden; - } -} - -#bottom-app-container { +#bottom-app-container, +#input-container { height: auto; width: 100%; - background: $background; + background: transparent; } #bottom-bar { height: auto; width: 100%; - background: $background; - padding: 0 0 1 0; + background: transparent; + padding: 0; align: left middle; layout: horizontal; } @@ -81,40 +78,38 @@ Screen { } #input-container { - height: auto; - width: 100%; - background: $background; padding: 0; margin: 0; } #completion-popup { width: 100%; - padding: 1 1 1 1; - color: $text; + padding: 1; + color: ansi_default; } #input-box { height: auto; width: 100%; - background: $background; - border-top: solid $foreground-muted; - border-bottom: solid $foreground-muted; + background: transparent; + border: solid ansi_bright_black; + border-title-align: right; + border-title-color: ansi_bright_black; padding: 0 1; &.border-warning { - border-top: solid $warning; - border-bottom: solid $warning; + border: solid ansi_yellow; + border-title-color: ansi_yellow; } &.border-safe { - border-top: solid $success; - border-bottom: solid $success; + border: solid ansi_green; + border-title-color: ansi_green; } &.border-error { - border-top: solid $error; - border-bottom: solid $error; + border: solid ansi_red; + border-title-color: ansi_red; } } @@ -125,7 +120,7 @@ Screen { #prompt { width: auto; background: transparent; - color: $primary; + color: $mistral_orange; text-style: bold; padding: 0 1 0 0; } @@ -133,9 +128,10 @@ Screen { #input { width: 1fr; height: auto; + min-height: 3; max-height: 50vh; background: transparent; - color: $text; + color: ansi_default; border: none; padding: 0; scrollbar-visibility: hidden; @@ -147,22 +143,45 @@ ToastRack { margin: 0 0 6 0; } -Markdown MarkdownFence { - overflow-x: auto; - scrollbar-size-horizontal: 1; - max-width: 95%; +Markdown { + color: ansi_default; + + .code_inline { + color: ansi_default; + background: ansi_bright_black; + } + + MarkdownFence { + overflow-x: auto; + scrollbar-size-horizontal: 1; + max-width: 95%; + background: transparent; + + & > Label { + padding: 0; + } + } + + MarkdownTable { + background: transparent; + } + + MarkdownBlockQuote { + background: transparent; + border-left: outer ansi_bright_black; + } + + MarkdownBullet, + MarkdownBulletList, + MarkdownOrderedList { + color: ansi_default; + } } .user-message { margin-top: 1; - padding: 1 0; width: 100%; height: auto; - background: $surface; - - &:first-child { - margin-top: 0; - } &.pending { .user-message-prompt, @@ -180,51 +199,35 @@ Markdown MarkdownFence { .user-message-prompt { width: auto; - height: auto; - color: $primary; + height: 100%; text-style: bold; } .user-message-content { width: 1fr; height: auto; - color: $foreground; text-style: bold; + padding-left: 1; + border-left: heavy $mistral_orange; + color: $mistral_orange; } .assistant-message { + layout: stream; margin-top: 1; width: 100%; height: auto; -} - -.assistant-message-container { - width: 100%; - height: auto; - align: left top; -} - -.assistant-message-dot { - width: auto; - height: auto; - color: $foreground; -} - -.assistant-message-content { - width: 1fr; - height: auto; - padding: 0; Markdown { width: 100%; height: auto; padding: 0; margin: 0; - } -} -.assistant-message-content Markdown > *:last-child { - margin-bottom: 0; + & > *:last-child { + margin-bottom: 0; + } + } } .reasoning-message { @@ -233,11 +236,7 @@ Markdown MarkdownFence { height: auto; } -.reasoning-message-wrapper { - width: 100%; - height: auto; -} - +.reasoning-message-wrapper, .reasoning-message-header { width: 100%; height: auto; @@ -246,133 +245,48 @@ Markdown MarkdownFence { .reasoning-indicator { width: auto; height: auto; - color: $text-muted; + color: ansi_bright_black; margin-right: 1; &.success { - color: $text-success; + color: ansi_green; } } -.reasoning-collapsed-text { +.reasoning-collapsed-text, +.reasoning-triangle { width: auto; height: auto; - color: $text-muted; + color: ansi_bright_black; text-style: italic; } .reasoning-triangle { - width: auto; - height: auto; - color: $text-muted; - text-style: italic; margin-left: 1; } .reasoning-message-content { + layout: stream; width: 100%; height: auto; - padding: 0; - padding-left: 2; + padding: 0 0 0 2; margin: 0; - color: $text-muted; + color: ansi_bright_black; text-style: italic; + + * { + color: ansi_bright_black; + text-style: italic; + } + + & > *:last-child { + margin-bottom: 0; + } } -.reasoning-message-content * { - color: $text-muted; - text-style: italic; -} - -.reasoning-message-content > *:last-child { - margin-bottom: 0; -} - -.interrupt-message { - margin-top: 0; - margin-bottom: 0; - height: auto; - width: 100%; -} - -.interrupt-container { - width: 100%; - height: auto; - margin: 0; - padding: 0; -} - -.interrupt-border { - width: auto; - height: 100%; - padding: 0 1 0 2; - color: $foreground-muted; -} - -.interrupt-content { - width: 1fr; - height: auto; - margin: 0; - color: $text-warning; -} - -.error-message { - margin-top: 0; - margin-bottom: 0; - height: auto; - width: 100%; -} - -.error-container { - width: 100%; - height: auto; - margin: 0; - padding: 0; -} - -.error-border { - width: auto; - height: 100%; - padding: 0 1 0 2; - color: $foreground-muted; -} - -.error-content { - width: 1fr; - height: auto; - margin: 0; - color: $error; - text-style: bold; -} - -.warning-message { - margin-top: 0; - margin-bottom: 0; - height: auto; - width: 100%; -} - -.warning-container { - width: 100%; - height: auto; - margin: 0; - padding: 0; -} - -.warning-border { - width: auto; - height: 100%; - padding: 0 1 0 2; - color: $foreground-muted; -} - -.warning-content { - width: 1fr; - height: auto; - margin: 0; - color: $text-warning; -} - +.interrupt-message, +.error-message, +.warning-message, .user-command-message { margin-top: 0; margin-bottom: 0; @@ -380,6 +294,9 @@ Markdown MarkdownFence { width: 100%; } +.interrupt-container, +.error-container, +.warning-container, .user-command-container { width: 100%; height: auto; @@ -387,11 +304,36 @@ Markdown MarkdownFence { padding: 0; } +.interrupt-border, +.error-border, +.warning-border, .user-command-border { width: auto; height: 100%; padding: 0 1 0 2; - color: $foreground-muted; + color: ansi_bright_black; +} + +.interrupt-content { + width: 1fr; + height: auto; + margin: 0; + color: ansi_yellow; +} + +.error-content { + width: 1fr; + height: auto; + margin: 0; + color: ansi_red; + text-style: bold; +} + +.warning-content { + width: 1fr; + height: auto; + margin: 0; + color: ansi_yellow; } .user-command-content { @@ -402,14 +344,14 @@ Markdown MarkdownFence { Markdown { margin: 0; padding: 0; - } - Markdown > *:first-child { - margin-top: 0; - } + & > *:first-child { + margin-top: 0; + } - Markdown > *:last-child { - margin-bottom: 0; + & > *:last-child { + margin-bottom: 0; + } } } @@ -417,67 +359,57 @@ Markdown MarkdownFence { margin-top: 1; width: 100%; height: auto; + + &:first-child { + margin-top: 0; + } +} + +.bash-command-line { + width: 100%; + height: auto; + margin: 0; +} + +.bash-prompt { + width: auto; + height: auto; + + &.bash-success { + color: ansi_green; + } + + &.bash-error { + color: ansi_red; + } +} + +.bash-command { + width: 1fr; + height: auto; } .bash-output-container { width: 100%; height: auto; - padding: 1 2; - background: $surface; + margin: 0; } -.bash-cwd-line, -.bash-command-line { - width: 100%; - height: auto; - margin-bottom: 1; - align: left middle; -} - -.bash-cwd { +.bash-output-border { width: auto; - color: $text-muted; -} - -.bash-cwd-spacer, -.bash-command-spacer { - width: 1fr; -} - -.bash-chevron { - width: auto; - color: $primary; - text-style: bold; -} - -.bash-command { - width: auto; - color: $foreground; + height: 100%; + padding: 0 1 0 2; + color: ansi_bright_black; } .bash-output { - width: 100%; - color: $foreground; -} - -.bash-exit-success { - width: auto; - color: $text-success; -} - -.bash-exit-failure { - width: auto; - color: $text-error; -} - -.bash-exit-code { - width: auto; - color: $text-muted; + width: 1fr; + height: auto; } .unknown-event { height: auto; - color: $text-muted; + color: ansi_bright_black; } StatusMessage { @@ -493,36 +425,35 @@ StatusMessage { .status-indicator-icon { width: auto; height: auto; - color: $foreground; + color: ansi_default; margin-right: 1; &.success { - color: $text-success; + color: ansi_green; } &.error { - color: $text-error; + color: ansi_red; } } .status-indicator-text { width: 1fr; height: auto; - color: $foreground; -} - -.compact-message { - width: 100%; - height: auto; - margin-top: 1; + color: ansi_default; } +.compact-message, .tool-call { width: 100%; height: auto; margin-top: 1; } +.tool-call.no-gap { + margin-top: 0; +} + .tool-call-container { width: 100%; height: auto; @@ -531,7 +462,7 @@ StatusMessage { .tool-stream-message { width: 100%; height: auto; - color: $text-muted; + color: ansi_bright_black; padding-left: 2; } @@ -542,14 +473,14 @@ StatusMessage { margin-left: 0; padding: 0; background: transparent; - color: $foreground; + color: ansi_default; &.error-text { - color: $text-error; + color: ansi_red; } &.warning-text { - color: $text-warning; + color: ansi_yellow; } } @@ -564,7 +495,7 @@ StatusMessage { width: auto; height: 100%; padding: 0 1 0 2; - color: $foreground-muted; + color: ansi_bright_black; } .tool-result-content { @@ -573,110 +504,97 @@ StatusMessage { padding: 0; } -.tool-call-widget { - width: 100%; - height: auto; - color: $foreground; -} - -.tool-call-detail { - height: auto; - color: $text-muted; -} - +.tool-call-widget, .tool-result-widget { width: 100%; height: auto; - color: $foreground; + color: ansi_default; +} - Static { - height: auto; - } +.tool-result-widget Static { + height: auto; +} + +.tool-call-detail, +.tool-result-detail { + height: auto; + color: ansi_bright_black; } .tool-result-detail { - height: auto; margin-top: 0; - color: $text-muted; } .tool-result-error { - color: $text-error; + color: ansi_red; } .tool-result-warning { - color: $text-warning; + color: ansi_yellow; } .diff-header { height: auto; - color: $text-muted; + color: ansi_bright_black; text-style: bold; } .diff-removed { height: auto; - color: $text-error; + color: ansi_red; } .diff-added { height: auto; - color: $text-success; + color: ansi_green; } .diff-range { height: auto; - color: $primary; + color: ansi_blue; } .diff-context { height: auto; - color: $text-muted; + color: ansi_bright_black; } -.todo-empty { +.todo-empty, +.todo-cancelled { height: auto; - color: $text-muted; + color: ansi_bright_black; } .todo-pending { height: auto; - color: $foreground; + color: ansi_default; } .todo-in_progress { height: auto; - color: $warning; + color: ansi_yellow; } .todo-completed { height: auto; - color: $success; -} - -.todo-cancelled { - height: auto; - color: $text-muted; -} - -#todo-area .tool-result { - margin-left: 0; + color: ansi_green; } .loading-widget { width: 100%; height: auto; + color: ansi_bright_black; } -.loading-container { +.loading-container, +.loading-indicator, +.loading-status, +.loading-hint { width: auto; height: auto; } .loading-indicator { - width: auto; - height: auto; - color: $warning; margin-right: 1; } @@ -690,41 +608,46 @@ StatusMessage { height: auto; } -.loading-ellipsis { - width: auto; - height: auto; - color: $text-muted; -} - .loading-hint { width: auto; height: auto; color: $foreground; } -WelcomeBanner { +.history-load-more-message { width: 100%; height: auto; - border: round $surface; - border-title-align: center; text-align: center; content-align: center middle; - padding: 2 0; - margin: 1 0 0 0; - color: $foreground; + margin: 1 0; +} + +.history-load-more-container { + width: 100%; + height: auto; + content-align: center middle; +} + +.history-load-more-button { + background: transparent; + border: none; + color: ansi_yellow; + text-style: bold; + pointer: pointer; + padding: 1; +} + +.history-load-more-button:hover { - .muted { - color: $text-muted; - } } #config-app { width: 100%; height: auto; - background: $background; - border: round $foreground-muted; + background: transparent; + border: solid ansi_bright_black; padding: 0 1; - margin: 0 0 1 0; + margin: 0; } #config-content { @@ -735,59 +658,65 @@ WelcomeBanner { .settings-title { height: auto; text-style: bold; - color: $primary; + color: ansi_blue; } .settings-option { height: auto; - color: $foreground; + color: ansi_default; } .settings-cursor-selected { - color: $primary; + color: ansi_blue; text-style: bold; } .settings-label-selected { - color: $text; + color: ansi_default; text-style: bold; } .settings-value-toggle-on-selected { - color: $text-success; + color: ansi_green; text-style: bold; } .settings-value-toggle-on-unselected { - color: $success; + color: ansi_green; } .settings-value-toggle-off { - color: $text-muted; + color: ansi_bright_black; } .settings-value-cycle-selected { - color: $primary; + color: ansi_blue; text-style: bold; } .settings-value-cycle-unselected { - color: $primary; + color: ansi_blue; } .settings-help { height: auto; - color: $text-muted; + color: ansi_bright_black; } #approval-app { width: 100%; height: auto; - max-height: 16; - background: $background; - border: round $foreground-muted; + max-height: 70vh; + background: transparent; + border: solid ansi_bright_black; padding: 0 1; - margin: 0 0 1 0; + margin: 0; +} + +#approval-options { + width: 100%; + height: auto; + dock: bottom; } #approval-content { @@ -798,13 +727,13 @@ WelcomeBanner { .approval-tool-info-scroll { width: 100%; height: auto; - max-height: 8; + max-height: 50vh; } .approval-title { height: auto; text-style: bold; - color: $warning; + color: ansi_yellow; } .approval-tool-info-container { @@ -816,11 +745,7 @@ WelcomeBanner { width: 100%; height: auto; - Static { - width: 100%; - height: auto; - } - + Static, Vertical { width: 100%; height: auto; @@ -829,49 +754,56 @@ WelcomeBanner { .approval-option { height: auto; - color: $foreground; + color: ansi_default; } .approval-cursor-selected { &.approval-option-yes { - color: $text-success; + color: ansi_green; text-style: bold; } &.approval-option-no { - color: $text-error; + color: ansi_red; text-style: bold; } } .approval-option-selected { &.approval-option-yes { - color: $success; + color: ansi_green; } &.approval-option-no { - color: $error; + color: ansi_red; } } .approval-help { height: auto; - color: $text-muted; + color: ansi_bright_black; } .approval-description { height: auto; - color: $foreground; + color: ansi_default; +} + +.code-block { + height: auto; + color: ansi_default; + background: transparent; + padding: 1; } #question-app { width: 100%; height: auto; - max-height: 16; + max-height: 70vh; background: transparent; - border: round $foreground-muted; + border: solid ansi_bright_black; padding: 0 1; - margin: 0 0 1 0; + margin: 0; } #question-content { @@ -881,24 +813,24 @@ WelcomeBanner { .question-tabs { height: auto; - color: $primary; + color: ansi_blue; margin-bottom: 1; } .question-title { height: auto; text-style: bold; - color: $primary; + color: ansi_blue; margin-bottom: 1; } .question-option { height: auto; - color: $foreground; + color: ansi_default; } .question-option-selected { - color: $primary; + color: ansi_blue; text-style: bold; } @@ -910,7 +842,7 @@ WelcomeBanner { .question-other-prefix { width: auto; height: auto; - color: $foreground; + color: ansi_default; } .question-other-input { @@ -924,104 +856,127 @@ WelcomeBanner { .question-other-static { width: auto; height: auto; - color: $foreground; + color: ansi_bright_black; } .question-submit { height: auto; - color: $foreground-muted; + color: ansi_default; margin-top: 1; } .question-help { height: auto; - color: $foreground-muted; + color: ansi_bright_black; margin-top: 1; } -.code-block { - height: auto; - color: $foreground; - background: $surface; - padding: 1; -} - -.whats-new-message { - background: $surface; - border-left: solid $warning; - margin-bottom: 1; -} - -.plan-offer-message { - background: transparent; - border: none; - height: auto; - padding: 0; - margin-top: 0; - color: $foreground-muted; - text-style: dim; - - Markdown > *:last-child { - margin-bottom: 0; - link-style: none; - link-color: $foreground-muted; - link-background-hover: transparent; - link-style-hover: underline; - link-color-hover: $warning; - } -} - -Horizontal { - width: 100%; - height: auto; -} - ExpandingBorder { width: auto; height: 100%; content-align: left bottom; } -AgentIndicator { - width: auto; - height: auto; - background: transparent; - padding: 0; - margin: 0 0 0 1; - color: $text-muted; - align: left middle; - - &.agent-safe { - color: $success; - } - - &.agent-neutral { - color: $text-muted; - } - - &.agent-destructive { - color: $warning; - } - - &.agent-yolo { - color: $error; - } -} - -PathDisplay { - width: auto; - height: auto; - background: transparent; - padding: 0; - margin: 0; - color: $primary; -} - +PathDisplay, ContextProgress { width: auto; height: auto; background: transparent; padding: 0; margin: 0; - color: $foreground; + color: ansi_bright_black; +} + +#banner-container { + align: left middle; + padding: 0 1 0 0; + width: auto; +} + +.banner-chat { + width: auto; +} + +#banner-info { + width: auto; + height: auto; + margin-left: 2; + content-align: left middle; +} + +.banner-line { + width: auto; + height: auto; +} + +#banner-brand { + color: $mistral_orange; + text-style: bold; + width: auto; +} + +.banner-spacer { + width: 1; + color: transparent; +} + +.banner-meta { + color: ansi_default; + width: auto; +} + +#banner-model { + color: ansi_cyan; + width: auto; +} + +#banner-meta-counts { + width: auto; +} + +.banner-cmd { + color: ansi_cyan; + width: auto; +} + +.petit-chat { + color: ansi_default; + width: 12; +} + +.whats-new-message { + border-left: heavy $mistral_orange; + margin-bottom: 1; + + Markdown { + padding: 0 0 0 1; + margin: 0; + + & > *:first-child { + margin-top: 0; + } + + & > *:last-child { + margin-bottom: 0; + } + + MarkdownBulletList, + MarkdownOrderedList { + margin-top: 1; + margin-bottom: 1; + } + + MarkdownH1 { + content-align: left middle; + } + } + + link-style: none; + link-background-hover: transparent; + link-style-hover: underline; + link-color-hover: $mistral_orange; +} + +.whats-new-message.after-history { + margin-top: 1; } diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index 7130b8c..6c55270 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -7,6 +7,7 @@ from vibe.cli.textual_ui.widgets.compact import CompactMessage from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage +from vibe.core.tools.ui import ToolUIDataAdapter from vibe.core.types import ( AssistantEvent, BaseEvent, @@ -29,15 +30,11 @@ class EventHandler: self, mount_callback: Callable, scroll_callback: Callable, - todo_area_callback: Callable, get_tools_collapsed: Callable[[], bool], - get_todos_collapsed: Callable[[], bool], ) -> None: self.mount_callback = mount_callback self.scroll_callback = scroll_callback - self.todo_area_callback = todo_area_callback self.get_tools_collapsed = get_tools_collapsed - self.get_todos_collapsed = get_todos_collapsed self.current_tool_call: ToolCallMessage | None = None self.current_compact: CompactMessage | None = None @@ -93,35 +90,21 @@ class EventHandler: tool_call = ToolCallMessage(event) if loading_widget and event.tool_class: - from vibe.core.tools.ui import ToolUIDataAdapter - adapter = ToolUIDataAdapter(event.tool_class) status_text = adapter.get_status_text() loading_widget.set_status(status_text) - # Don't show todo in messages - if event.tool_name != "todo": - await self.mount_callback(tool_call) - self.current_tool_call = tool_call + await self.mount_callback(tool_call) + return tool_call async def _handle_tool_result(self, event: ToolResultEvent) -> None: - if event.tool_name == "todo": - todos_collapsed = self.get_todos_collapsed() - tool_result = ToolResultMessage( - event, self.current_tool_call, collapsed=todos_collapsed - ) - # Show in todo area - todo_area = self.todo_area_callback() - await todo_area.remove_children() - await todo_area.mount(tool_result) - else: - tools_collapsed = self.get_tools_collapsed() - tool_result = ToolResultMessage( - event, self.current_tool_call, collapsed=tools_collapsed - ) - await self.mount_callback(tool_result) + tools_collapsed = self.get_tools_collapsed() + tool_result = ToolResultMessage( + event, self.current_tool_call, collapsed=tools_collapsed + ) + await self.mount_callback(tool_result) self.current_tool_call = None @@ -153,9 +136,9 @@ class EventHandler: async def _handle_unknown_event(self, event: BaseEvent) -> None: await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event")) - def stop_current_tool_call(self) -> None: + def stop_current_tool_call(self, success: bool = True) -> None: if self.current_tool_call: - self.current_tool_call.stop_spinning() + self.current_tool_call.stop_spinning(success=success) self.current_tool_call = None def stop_current_compact(self) -> None: diff --git a/vibe/cli/textual_ui/terminal_theme.py b/vibe/cli/textual_ui/terminal_theme.py deleted file mode 100644 index 639b7dd..0000000 --- a/vibe/cli/textual_ui/terminal_theme.py +++ /dev/null @@ -1,266 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager -from dataclasses import dataclass, fields -import os -import re -import sys -from typing import Any - -from textual.theme import Theme - -try: - import select - import termios - - _UNIX_AVAILABLE = True -except ImportError: - select = None # type: ignore[assignment] - termios: Any = None - _UNIX_AVAILABLE = False - -TERMINAL_THEME_NAME = "terminal" - -_LUMINANCE_THRESHOLD = 0.5 -_RGB_16BIT_LEN = 4 -_RGB_8BIT_LEN = 2 - -# OSC codes for terminal colors -_OSC_FOREGROUND = "10" -_OSC_BACKGROUND = "11" - -# ANSI color indices (0-15) mapped to field names -_ANSI_COLORS = ( - "black", - "red", - "green", - "yellow", - "blue", - "magenta", - "cyan", - "white", - "bright_black", - "bright_red", - "bright_green", - "bright_yellow", - "bright_blue", - "bright_magenta", - "bright_cyan", - "bright_white", -) - -# DA1 (Primary Device Attributes) query - used to detect unsupported terminals -# Terminals respond to DA1 even if they don't support OSC color queries -_DA1_QUERY = b"\x1b[c" -_DA1_RESPONSE_PREFIX = b"\x1b[?" - -_OSC_RESPONSE_RE = re.compile( - rb"\x1b\](10|11|4;\d+);rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)(?:\x1b\\|\x07)" -) - - -@dataclass -class TerminalColors: - foreground: str | None = None - background: str | None = None - black: str | None = None - red: str | None = None - green: str | None = None - yellow: str | None = None - blue: str | None = None - magenta: str | None = None - cyan: str | None = None - white: str | None = None - bright_black: str | None = None - bright_red: str | None = None - bright_green: str | None = None - bright_yellow: str | None = None - bright_blue: str | None = None - bright_magenta: str | None = None - bright_cyan: str | None = None - bright_white: str | None = None - - def is_complete(self) -> bool: - return all(getattr(self, f.name) is not None for f in fields(self)) - - -def _build_osc_query(code: str) -> bytes: - """Build an OSC query: ESC ] ; ? ST""" - return f"\x1b]{code};?\x1b\\".encode() - - -def _build_color_queries() -> tuple[bytes, dict[bytes, str]]: - """Build all OSC color queries and the mapping from OSC codes to field names.""" - queries = bytearray() - osc_to_field: dict[bytes, str] = {} - - # Foreground and background - queries.extend(_build_osc_query(_OSC_FOREGROUND)) - queries.extend(_build_osc_query(_OSC_BACKGROUND)) - osc_to_field[_OSC_FOREGROUND.encode()] = "foreground" - osc_to_field[_OSC_BACKGROUND.encode()] = "background" - - # ANSI colors 0-15 - for i, name in enumerate(_ANSI_COLORS): - code = f"4;{i}" - queries.extend(_build_osc_query(code)) - osc_to_field[code.encode()] = name - - return bytes(queries), osc_to_field - - -_COLOR_QUERIES, _OSC_TO_FIELD = _build_color_queries() - - -@contextmanager -def _raw_mode(fd: int) -> Iterator[None]: - """Context manager to temporarily set terminal to raw mode.""" - assert termios is not None # Only called on Unix so typing doesn't freak out - try: - old_settings = termios.tcgetattr(fd) - except termios.error: - yield - return - - try: - new_settings = termios.tcgetattr(fd) - new_settings[3] &= ~(termios.ECHO | termios.ICANON) - termios.tcsetattr(fd, termios.TCSADRAIN, new_settings) - yield - finally: - try: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - except termios.error: - pass - - -def _parse_rgb(r_hex: bytes, g_hex: bytes, b_hex: bytes) -> str | None: - """Parse RGB hex values to a #rrggbb string. - - Terminals return either 16-bit (4 hex chars) or 8-bit (2 hex chars) per channel. - """ - try: - if len(r_hex) == _RGB_16BIT_LEN: - r, g, b = int(r_hex[:2], 16), int(g_hex[:2], 16), int(b_hex[:2], 16) - elif len(r_hex) == _RGB_8BIT_LEN: - r, g, b = int(r_hex, 16), int(g_hex, 16), int(b_hex, 16) - else: - return None - return f"#{r:02x}{g:02x}{b:02x}" - except ValueError: - return None - - -def _read_responses(fd: int, timeout: float = 1.0) -> bytes: - """Read terminal responses until DA1 response or timeout. - - Uses the DA1 trick: we send color queries followed by DA1. Since terminals - respond in order, receiving the DA1 response means all color responses - (if any) have been received. - """ - assert select is not None # Only called on Unix so typing doesn't freak out - response = bytearray() - while True: - ready, _, _ = select.select([fd], [], [], timeout) - if not ready: - break - chunk = os.read(fd, 4096) - if not chunk: - break - response.extend(chunk) - # DA1 response received - we have all the color responses - if _DA1_RESPONSE_PREFIX in response: - break - return bytes(response) - - -def _parse_osc_responses(response: bytes) -> TerminalColors: - colors = TerminalColors() - for match in _OSC_RESPONSE_RE.finditer(response): - osc_code, r_hex, g_hex, b_hex = match.groups() - field = _OSC_TO_FIELD.get(osc_code) - if field and (color := _parse_rgb(r_hex, g_hex, b_hex)): - setattr(colors, field, color) - return colors - - -def _query_terminal_colors() -> TerminalColors: - if not _UNIX_AVAILABLE: - return TerminalColors() - - if not sys.stdin.isatty() or not sys.stdout.isatty(): - return TerminalColors() - - fd = sys.stdin.fileno() - - try: - with _raw_mode(fd): - os.write(sys.stdout.fileno(), _COLOR_QUERIES + _DA1_QUERY) - response = _read_responses(fd) - return _parse_osc_responses(response) - except OSError: - return TerminalColors() - - -def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]: - h = hex_color.lstrip("#") - return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) - - -def _rgb_to_hex(r: int, g: int, b: int) -> str: - return f"#{r:02x}{g:02x}{b:02x}" - - -def _adjust_brightness(hex_color: str, factor: float) -> str: - r, g, b = _hex_to_rgb(hex_color) - return _rgb_to_hex( - min(255, max(0, int(r * factor))), - min(255, max(0, int(g * factor))), - min(255, max(0, int(b * factor))), - ) - - -def _blend(c1: str, c2: str, ratio: float = 0.5) -> str: - r1, g1, b1 = _hex_to_rgb(c1) - r2, g2, b2 = _hex_to_rgb(c2) - return _rgb_to_hex( - int(r1 * (1 - ratio) + r2 * ratio), - int(g1 * (1 - ratio) + g2 * ratio), - int(b1 * (1 - ratio) + b2 * ratio), - ) - - -def _luminance(hex_color: str) -> float: - """Calculate perceived luminance (0-1) using ITU-R BT.601 coefficients.""" - r, g, b = _hex_to_rgb(hex_color) - return (0.299 * r + 0.587 * g + 0.114 * b) / 255 - - -def capture_terminal_theme() -> Theme | None: - colors = _query_terminal_colors() - - if not colors.background or not colors.foreground: - return None - - is_dark = _luminance(colors.background) < _LUMINANCE_THRESHOLD - fg = colors.foreground - bg = colors.background - - surface = _adjust_brightness(bg, 1.15 if is_dark else 0.95) - panel = _blend(bg, surface) - - return Theme( - name=TERMINAL_THEME_NAME, - primary=colors.blue or fg, - secondary=colors.cyan or fg, - warning=colors.yellow or fg, - error=colors.red or fg, - success=colors.green or fg, - accent=colors.magenta or fg, - foreground=fg, - background=bg, - surface=surface, - panel=panel, - dark=is_dark, - ) diff --git a/vibe/cli/textual_ui/widgets/agent_indicator.py b/vibe/cli/textual_ui/widgets/agent_indicator.py deleted file mode 100644 index 852b326..0000000 --- a/vibe/cli/textual_ui/widgets/agent_indicator.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from textual.widgets import Static - -from vibe.core.agents import AgentProfile, AgentSafety, BuiltinAgentName - -AGENT_ICONS: dict[str, str] = { - BuiltinAgentName.DEFAULT: "⏵", - BuiltinAgentName.PLAN: "⏸", - BuiltinAgentName.ACCEPT_EDITS: "⏵⏵", - BuiltinAgentName.AUTO_APPROVE: "⏵⏵⏵", -} - -SAFETY_CLASSES: dict[AgentSafety, str] = { - AgentSafety.SAFE: "agent-safe", - AgentSafety.NEUTRAL: "agent-neutral", - AgentSafety.DESTRUCTIVE: "agent-destructive", - AgentSafety.YOLO: "agent-yolo", -} - - -class AgentIndicator(Static): - def __init__(self, profile: AgentProfile) -> None: - super().__init__(markup=False) - self.can_focus = False - self.profile = profile - self._update_display() - - def _update_display(self) -> None: - icon = AGENT_ICONS.get(self.profile.name, "") - name = self.profile.display_name.lower() - self.update(f"{icon}{' ' if icon else ''}{name} agent (shift+tab to cycle)") - - for safety_class in SAFETY_CLASSES.values(): - self.remove_class(safety_class) - - self.add_class(SAFETY_CLASSES[self.profile.safety]) - - def set_profile(self, profile: AgentProfile) -> None: - self.profile = profile - self._update_display() diff --git a/vibe/cli/textual_ui/widgets/approval_app.py b/vibe/cli/textual_ui/widgets/approval_app.py index 8ee2022..041fee0 100644 --- a/vibe/cli/textual_ui/widgets/approval_app.py +++ b/vibe/cli/textual_ui/widgets/approval_app.py @@ -66,6 +66,18 @@ class ApprovalApp(Container): self.help_widget: Static | None = None def compose(self) -> ComposeResult: + with Vertical(id="approval-options"): + yield NoMarkupStatic("") + for _ in range(3): + widget = NoMarkupStatic("", classes="approval-option") + self.option_widgets.append(widget) + yield widget + yield NoMarkupStatic("") + self.help_widget = NoMarkupStatic( + "↑↓ navigate Enter select ESC reject", classes="approval-help" + ) + yield self.help_widget + with Vertical(id="approval-content"): self.title_widget = NoMarkupStatic( f"⚠ {self.tool_name} command", classes="approval-title" @@ -78,20 +90,6 @@ class ApprovalApp(Container): ) yield self.tool_info_container - yield NoMarkupStatic("") - - for _ in range(3): - widget = NoMarkupStatic("", classes="approval-option") - self.option_widgets.append(widget) - yield widget - - yield NoMarkupStatic("") - - self.help_widget = NoMarkupStatic( - "↑↓ navigate Enter select ESC reject", classes="approval-help" - ) - yield self.help_widget - async def on_mount(self) -> None: await self._update_tool_info() self._update_options() diff --git a/vibe/cli/textual_ui/widgets/banner/banner.py b/vibe/cli/textual_ui/widgets/banner/banner.py new file mode 100644 index 0000000..83f0e47 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/banner/banner.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.widgets import Static + +from vibe import __version__ +from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.core.config import VibeConfig +from vibe.core.skills.manager import SkillManager + + +@dataclass +class BannerState: + active_model: str = "" + models_count: int = 0 + mcp_servers_count: int = 0 + skills_count: int = 0 + + +class Banner(Static): + state = reactive(BannerState(), init=False) + + def __init__( + self, config: VibeConfig, skill_manager: SkillManager, **kwargs: Any + ) -> None: + super().__init__(**kwargs) + self.can_focus = False + self._initial_state = BannerState( + active_model=config.active_model, + models_count=len(config.models), + mcp_servers_count=len(config.mcp_servers), + skills_count=len(skill_manager.available_skills), + ) + self._animated = not config.disable_welcome_banner_animation + + def compose(self) -> ComposeResult: + with Horizontal(id="banner-container"): + yield PetitChat(animate=self._animated) + + with Vertical(id="banner-info"): + with Horizontal(classes="banner-line"): + yield NoMarkupStatic("Mistral Vibe", id="banner-brand") + yield NoMarkupStatic(" ", classes="banner-spacer") + yield NoMarkupStatic(f"v{__version__} · ", classes="banner-meta") + yield NoMarkupStatic("", id="banner-model") + with Horizontal(classes="banner-line"): + yield NoMarkupStatic("", id="banner-meta-counts") + with Horizontal(classes="banner-line"): + yield NoMarkupStatic("Type ", classes="banner-meta") + yield NoMarkupStatic("/help", classes="banner-cmd") + yield NoMarkupStatic(" for more information", classes="banner-meta") + + def on_mount(self) -> None: + self.state = self._initial_state + + def watch_state(self) -> None: + self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model) + self.query_one("#banner-meta-counts", NoMarkupStatic).update( + self._format_meta_counts() + ) + + def freeze_animation(self) -> None: + if self._animated: + self.query_one(PetitChat).freeze_animation() + + def set_state(self, config: VibeConfig, skill_manager: SkillManager) -> None: + self.state = BannerState( + active_model=config.active_model, + models_count=len(config.models), + mcp_servers_count=len(config.mcp_servers), + skills_count=len(skill_manager.available_skills), + ) + + def _format_meta_counts(self) -> str: + return ( + f"{self.state.models_count} model{'s' if self.state.models_count != 1 else ''}" + f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}" + f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}" + ) diff --git a/vibe/cli/textual_ui/widgets/banner/petit_chat.py b/vibe/cli/textual_ui/widgets/banner/petit_chat.py new file mode 100644 index 0000000..9e49436 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/banner/petit_chat.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from typing import Any + +from textual.app import ComposeResult +from textual.timer import Timer +from textual.widgets import Static + +from vibe.cli.textual_ui.widgets.braille_renderer import render_braille + +WIDTH = 22 +HEIGHT = 12 +STARTING_DOTS = [ + set[int](), + {6, 7, 15, 19}, + {5, 8, 14, 16, 18, 20}, + {4, 6, 7, 14, 17, 20}, + {3, 5, 10, 11, 12, 14, 20}, + {3, 5, 9, 13, 14, 16, 18, 20}, + {3, 5, 8, 13, 17, 21}, + {3, 6, 7, 8, 11, 14, 15, 16, 18, 19, 20}, + {4, 5, 8, 12, 17, 19}, + {6, 7, 8, 13, 18, 20}, + {9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, + set[int](), +] +QUEUE_RIGHT_TO_MID = { + "remove": {1j + 6, 1j + 7, 2j + 8, 3j + 4, 3j + 6, 3j + 7, 8j + 4, 8j + 5}, + "add": {1j + 4, 2j + 3, 3j + 3, 3j + 5, 7j + 5, 8j + 3, 9j + 4, 9j + 5}, +} +QUEUE_MID_TO_RIGHT = { + "remove": QUEUE_RIGHT_TO_MID["add"], + "add": QUEUE_RIGHT_TO_MID["remove"], +} +QUEUE_MID_TO_LEFT = { + "remove": {1j + 4, 2j + 5, 3j + 3, 3j + 5, 7j + 5, 8j + 3, 9j + 4, 9j + 5}, + "add": {1j + 1, 1j + 2, 2j, 3j + 1, 3j + 2, 3j + 4, 8j + 4, 8j + 5}, +} +QUEUE_LEFT_TO_MID = { + "remove": QUEUE_MID_TO_LEFT["add"], + "add": QUEUE_MID_TO_LEFT["remove"], +} +WAIT = {"remove": set[int](), "add": set[int]()} +HEAD_RIGHT = {"remove": {5j + 16, 5j + 18, 6j + 17}, "add": {5j + 17, 5j + 19, 6j + 18}} +HEAD_LEFT = {"remove": {5j + 17, 5j + 19, 6j + 18}, "add": {5j + 16, 5j + 18, 6j + 17}} +HEAD_DOWN = { + "remove": { + 1j + 15, + 1j + 19, + 2j + 14, + 2j + 16, + 2j + 18, + 2j + 20, + 3j + 17, + 5j + 17, + 5j + 19, + 6j + 13, + 6j + 18, + 6j + 21, + 7j + 14, + 7j + 15, + 7j + 16, + 7j + 19, + 7j + 20, + }, + "add": { + 2j + 15, + 2j + 19, + 3j + 16, + 3j + 18, + 4j + 17, + 6j + 14, + 6j + 17, + 6j + 19, + 6j + 20, + 7j + 13, + 7j + 18, + 7j + 21, + 8j + 14, + 8j + 15, + 8j + 16, + 8j + 18, + 8j + 20, + }, +} +HEAD_UP = { + "remove": { + 2j + 15, + 2j + 19, + 3j + 16, + 3j + 18, + 4j + 17, + 6j + 14, + 6j + 17, + 6j + 19, + 6j + 20, + 7j + 13, + 7j + 18, + 7j + 21, + 8j + 14, + 8j + 15, + 8j + 16, + 8j + 18, + 8j + 20, + }, + "add": { + 1j + 15, + 1j + 19, + 2j + 14, + 2j + 16, + 2j + 18, + 2j + 20, + 3j + 17, + 5j + 17, + 5j + 19, + 6j + 13, + 6j + 18, + 6j + 21, + 7j + 14, + 7j + 15, + 7j + 16, + 7j + 18, + 7j + 19, + 7j + 20, + }, +} +BLINK_EYES_HEAD_HIGH = [ + {"remove": {5j + 16, 5j + 18}, "add": set[int]()}, + {"remove": set[int](), "add": {5j + 16, 5j + 18}}, +] +BLINK_EYES_HEAD_LOW = [ + {"remove": {6j + 17, 6j + 19}, "add": set[int]()}, + {"remove": set[int](), "add": {6j + 17, 6j + 19}}, +] +TRANSITIONS = [ + *BLINK_EYES_HEAD_HIGH, + WAIT, + QUEUE_RIGHT_TO_MID, + HEAD_RIGHT, + WAIT, + QUEUE_MID_TO_LEFT, + WAIT, + QUEUE_LEFT_TO_MID, + WAIT, + HEAD_DOWN, + WAIT, + QUEUE_MID_TO_RIGHT, + *BLINK_EYES_HEAD_LOW, + WAIT, + QUEUE_RIGHT_TO_MID, + WAIT, + QUEUE_MID_TO_LEFT, + WAIT, + HEAD_UP, + WAIT, + QUEUE_LEFT_TO_MID, + HEAD_LEFT, + WAIT, + QUEUE_MID_TO_RIGHT, +] +# cf render_braille() docstring for coordinates convention + + +class PetitChat(Static): + def __init__(self, animate: bool = True, **kwargs: Any) -> None: + super().__init__(**kwargs, classes="banner-chat") + self._dots = {1j * y + x for y, row in enumerate(STARTING_DOTS) for x in row} + self._transition_index = 0 + self._do_animate = animate + self._freeze_requested = False + self._timer: Timer | None = None + + def compose(self) -> ComposeResult: + yield Static(render_braille(self._dots, WIDTH, HEIGHT), classes="petit-chat") + + def on_mount(self) -> None: + self._inner = self.query_one(".petit-chat", Static) + if self._do_animate: + self._timer = self.set_interval(0.16, self._apply_next_transition) + + def freeze_animation(self) -> None: + self._freeze_requested = True + + def _apply_next_transition(self) -> None: + if self._freeze_requested and self._transition_index == 0: + if self._timer: + self._timer.stop() + self._timer = None + return + + transition = TRANSITIONS[self._transition_index] + self._dots -= transition["remove"] + self._dots |= transition["add"] + self._transition_index = (self._transition_index + 1) % len(TRANSITIONS) + self._inner.update(render_braille(self._dots, WIDTH, HEIGHT)) diff --git a/vibe/cli/textual_ui/widgets/braille_renderer.py b/vibe/cli/textual_ui/widgets/braille_renderer.py new file mode 100644 index 0000000..e1965d3 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/braille_renderer.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from collections.abc import Iterable +import math + +# for more details on braille characters encoding, see: https://en.wikipedia.org/wiki/Braille_Patterns + +_BRAILLE_DOT_COUNT = 8 + + +def _braille_dot_index(x: int, y: int) -> int: + """returns the number associated with a dot in a braille character + x ∈ {0, 1}, y ∈ {0, 1, 2, 3} + -x-> + | 1 4 + y 2 5 + | 3 6 + V 7 8 + """ + if y < 3: # noqa: PLR2004 + return y + 1 + 3 * x + return 7 + x + + +def _braille_char_from_dot_indices(indices: list[int]) -> str: + if any(n < 1 or n > _BRAILLE_DOT_COUNT for n in indices): + raise ValueError(f"Invalid braille dot indices: {indices}") + return chr(0x2800 + sum(2 ** (d - 1) for d in indices)) if indices else " " + + +def render_braille(dot_coords: Iterable[complex], width: int, height: int) -> str: + """this function receives a list of dot coordinantes, a width and a height, + and returns a string representing these dots with braille characters. + + Origin is (0,0) and is located at the top left: + 0----x----> + | + y + | + V + """ + dots_matrix: list[list[list[int]]] = [ + [[] for _ in range(math.ceil(width / 2))] for _ in range(math.ceil(height / 4)) + ] # the list of dots for each character in the final str + + for coord in dot_coords: + x = int(coord.real // 2) + y = int(coord.imag // 4) + sub_x = int(coord.real) % 2 + sub_y = int(coord.imag) % 4 + dots_matrix[y][x].append(_braille_dot_index(sub_x, sub_y)) + + braille_chars = [ + [_braille_char_from_dot_indices(char_dots) for char_dots in row] + for row in dots_matrix + ] + + return "\n".join("".join(row) for row in braille_chars) diff --git a/vibe/cli/textual_ui/widgets/chat_input/body.py b/vibe/cli/textual_ui/widgets/chat_input/body.py index f49664a..407b2ef 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/body.py +++ b/vibe/cli/textual_ui/widgets/chat_input/body.py @@ -20,10 +20,16 @@ class ChatInputBody(Widget): self.value = value super().__init__() - def __init__(self, history_file: Path | None = None, **kwargs: Any) -> None: + def __init__( + self, + history_file: Path | None = None, + nuage_enabled: bool = False, + **kwargs: Any, + ) -> None: super().__init__(**kwargs) self.input_widget: ChatTextArea | None = None self.prompt_widget: NoMarkupStatic | None = None + self._nuage_enabled = nuage_enabled if history_file: self.history = HistoryManager(history_file) @@ -37,7 +43,9 @@ class ChatInputBody(Widget): self.prompt_widget = NoMarkupStatic(">", id="prompt") yield self.prompt_widget - self.input_widget = ChatTextArea(placeholder="Ask anything...", id="input") + self.input_widget = ChatTextArea( + id="input", nuage_enabled=self._nuage_enabled + ) yield self.input_widget def on_mount(self) -> None: @@ -49,6 +57,8 @@ class ChatInputBody(Widget): return "!", text[1:] elif text.startswith("/"): return "/", text[1:] + elif text.startswith("&") and self._nuage_enabled: + return "&", text[1:] else: return ">", text diff --git a/vibe/cli/textual_ui/widgets/chat_input/container.py b/vibe/cli/textual_ui/widgets/chat_input/container.py index 747e86e..bfc7a02 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/container.py +++ b/vibe/cli/textual_ui/widgets/chat_input/container.py @@ -40,14 +40,18 @@ class ChatInputContainer(Vertical): history_file: Path | None = None, command_registry: CommandRegistry | None = None, safety: AgentSafety = AgentSafety.NEUTRAL, + agent_name: str = "", skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None, + nuage_enabled: bool = False, **kwargs: Any, ) -> None: super().__init__(**kwargs) self._history_file = history_file self._command_registry = command_registry or CommandRegistry() self._safety = safety + self._agent_name = agent_name self._skill_entries_getter = skill_entries_getter + self._nuage_enabled = nuage_enabled self._completion_manager = MultiCompletionManager([ SlashCommandController(CommandCompleter(self._get_slash_entries), self), @@ -71,8 +75,13 @@ class ChatInputContainer(Vertical): yield self._completion_popup border_class = SAFETY_BORDER_CLASSES.get(self._safety, "") - with Vertical(id=self.ID_INPUT_BOX, classes=border_class): - self._body = ChatInputBody(history_file=self._history_file, id="input-body") + with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box: + input_box.border_title = self._agent_name + self._body = ChatInputBody( + history_file=self._history_file, + id="input-body", + nuage_enabled=self._nuage_enabled, + ) yield self._body @@ -175,3 +184,12 @@ class ChatInputContainer(Vertical): if safety in SAFETY_BORDER_CLASSES: input_box.add_class(SAFETY_BORDER_CLASSES[safety]) + + def set_agent_name(self, name: str) -> None: + self._agent_name = name + + try: + input_box = self.get_widget_by_id(self.ID_INPUT_BOX) + input_box.border_title = name + except Exception: + pass diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py index 6989725..953dcce 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -13,7 +13,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import ( MultiCompletionManager, ) -InputMode = Literal["!", "/", ">"] +InputMode = Literal["!", "/", ">", "&"] class ChatTextArea(TextArea): @@ -28,7 +28,6 @@ class ChatTextArea(TextArea): Binding("ctrl+g", "open_external_editor", "External Editor", show=False), ] - MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"} DEFAULT_MODE: ClassVar[Literal[">"]] = ">" class Submitted(Message): @@ -50,14 +49,15 @@ class ChatTextArea(TextArea): """Message sent when history navigation should be reset.""" class ModeChanged(Message): - """Message sent when the input mode changes (>, !, /).""" + """Message sent when the input mode changes (>, !, /, &).""" def __init__(self, mode: InputMode) -> None: self.mode = mode super().__init__() - def __init__(self, **kwargs: Any) -> None: + def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None: super().__init__(**kwargs) + self._nuage_enabled = nuage_enabled self._input_mode: InputMode = self.DEFAULT_MODE self._history_prefix: str | None = None self._last_text = "" @@ -213,7 +213,7 @@ class ChatTextArea(TextArea): if ( event.character - and event.character in self.MODE_CHARACTERS + and event.character in self.mode_characters and not self.text and self._input_mode == self.DEFAULT_MODE ): @@ -328,7 +328,14 @@ class ChatTextArea(TextArea): return self.get_cursor_offset() + self._get_mode_prefix_length() def _get_mode_prefix_length(self) -> int: - return {">": 0, "/": 1, "!": 1}[self._input_mode] + return {">": 0, "/": 1, "!": 1, "&": 1}[self._input_mode] + + @property + def mode_characters(self) -> set[InputMode]: + chars: set[InputMode] = {"!", "/"} + if self._nuage_enabled: + chars.add("&") + return chars @property def input_mode(self) -> InputMode: diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py index 3086e56..838844c 100644 --- a/vibe/cli/textual_ui/widgets/config_app.py +++ b/vibe/cli/textual_ui/widgets/config_app.py @@ -7,19 +7,13 @@ from textual.app import ComposeResult from textual.binding import Binding, BindingType from textual.containers import Container, Vertical from textual.message import Message -from textual.theme import BUILTIN_THEMES from textual.widgets import Static -from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic if TYPE_CHECKING: from vibe.core.config import VibeConfig -_ALL_THEMES = [TERMINAL_THEME_NAME] + sorted( - k for k in BUILTIN_THEMES if k != "textual-ansi" -) - class SettingDefinition(TypedDict): key: str @@ -46,22 +40,16 @@ class ConfigApp(Container): self.value = value class ConfigClosed(Message): - def __init__(self, changes: dict[str, str]) -> None: + def __init__(self, changes: dict[str, str | bool]) -> None: super().__init__() self.changes = changes - def __init__(self, config: VibeConfig, *, has_terminal_theme: bool = False) -> None: + def __init__(self, config: VibeConfig) -> None: super().__init__(id="config-app") self.config = config self.selected_index = 0 self.changes: dict[str, str] = {} - themes = ( - _ALL_THEMES - if has_terminal_theme - else [t for t in _ALL_THEMES if t != TERMINAL_THEME_NAME] - ) - self.settings: list[SettingDefinition] = [ { "key": "active_model", @@ -70,10 +58,10 @@ class ConfigApp(Container): "options": [m.alias for m in self.config.models], }, { - "key": "textual_theme", - "label": "Theme", + "key": "autocopy_to_clipboard", + "label": "Auto-copy", "type": "cycle", - "options": themes, + "options": ["On", "Off"], }, ] @@ -104,6 +92,15 @@ class ConfigApp(Container): self._update_display() self.focus() + def _get_display_value(self, setting: SettingDefinition) -> str: + key = setting["key"] + if key in self.changes: + return self.changes[key] + raw_value = getattr(self.config, key, "") + if isinstance(raw_value, bool): + return "On" if raw_value else "Off" + return str(raw_value) + def _update_display(self) -> None: for i, (setting, widget) in enumerate( zip(self.settings, self.setting_widgets, strict=True) @@ -112,9 +109,7 @@ class ConfigApp(Container): cursor = "› " if is_selected else " " label: str = setting["label"] - value: str = self.changes.get( - setting["key"], getattr(self.config, setting["key"], "") - ) + value: str = self._get_display_value(setting) text = f"{cursor}{label}: {value}" @@ -140,7 +135,7 @@ class ConfigApp(Container): def action_toggle_setting(self) -> None: setting = self.settings[self.selected_index] key: str = setting["key"] - current: str = self.changes.get(key, getattr(self.config, key)) or "" + current: str = self._get_display_value(setting) options: list[str] = setting["options"] new_value = "" @@ -160,8 +155,17 @@ class ConfigApp(Container): def action_cycle(self) -> None: self.action_toggle_setting() + def _convert_changes_for_save(self) -> dict[str, str | bool]: + result: dict[str, str | bool] = {} + for key, value in self.changes.items(): + if value in {"On", "Off"}: + result[key] = value == "On" + else: + result[key] = value + return result + def action_close(self) -> None: - self.post_message(self.ConfigClosed(changes=self.changes.copy())) + self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save())) def on_blur(self, event: events.Blur) -> None: self.call_after_refresh(self.focus) diff --git a/vibe/cli/textual_ui/widgets/load_more.py b/vibe/cli/textual_ui/widgets/load_more.py new file mode 100644 index 0000000..da2ca06 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/load_more.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.message import Message +from textual.widgets import Button, Static + + +class HistoryLoadMoreRequested(Message): + pass + + +class HistoryLoadMoreMessage(Static): + def __init__(self) -> None: + super().__init__() + self.add_class("history-load-more-message") + self._label_widget: Button | None = None + self._remaining: int | None = None + + def compose(self) -> ComposeResult: + with Horizontal(classes="history-load-more-container"): + self._label_widget = Button( + self._label_text(), classes="history-load-more-button" + ) + yield self._label_widget + + def _label_text(self) -> str: + if self._remaining is None: + return "Load more messages" + return f"Load more messages ({self._remaining})" + + def set_enabled(self, enabled: bool) -> None: + if self._label_widget: + self._label_widget.disabled = not enabled + + def set_remaining(self, remaining: int | None) -> None: + self._remaining = remaining + if self._label_widget: + self._label_widget.label = self._label_text() + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.post_message(HistoryLoadMoreRequested()) diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py index 396b0d6..f497257 100644 --- a/vibe/cli/textual_ui/widgets/loading.py +++ b/vibe/cli/textual_ui/widgets/loading.py @@ -15,9 +15,21 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType +def _format_elapsed(seconds: int) -> str: + if seconds < 60: # noqa: PLR2004 + return f"{seconds}s" + + minutes, secs = divmod(seconds, 60) + if minutes < 60: # noqa: PLR2004 + return f"{minutes}m{secs}s" + + hours, mins = divmod(minutes, 60) + return f"{hours}h{mins}m{secs}s" + + class LoadingWidget(SpinnerMixin, Static): TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500") - SPINNER_TYPE = SpinnerType.BRAILLE + SPINNER_TYPE = SpinnerType.SNAKE EASTER_EGGS: ClassVar[list[str]] = [ "Eating a chocolatine", @@ -173,7 +185,9 @@ class LoadingWidget(SpinnerMixin, Static): elapsed = int(time() - self.start_time - paused) if elapsed != self._last_elapsed: self._last_elapsed = elapsed - self.hint_widget.update(f"({elapsed}s esc to interrupt)") + self.hint_widget.update( + f"({_format_elapsed(elapsed)} esc to interrupt)" + ) @contextmanager diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index 20c27e5..d0b0d54 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -4,9 +4,10 @@ from typing import Any from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Markdown, Static +from textual.widgets import Static from textual.widgets._markdown import MarkdownStream +from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType @@ -42,7 +43,6 @@ class UserMessage(Static): def compose(self) -> ComposeResult: with Horizontal(classes="user-message-container"): - yield NonSelectableStatic("> ", classes="user-message-prompt") yield NoMarkupStatic(self._content, classes="user-message-content") if self._pending: self.add_class("pending") @@ -66,6 +66,7 @@ class StreamingMessageBase(Static): self._content = content self._markdown: Markdown | None = None self._stream: MarkdownStream | None = None + self._content_initialized = False def _get_markdown(self) -> Markdown: if self._markdown is None: @@ -89,6 +90,8 @@ class StreamingMessageBase(Static): await stream.write(content) async def write_initial_content(self) -> None: + if self._content_initialized: + return if self._content and self._should_write_content(): stream = self._ensure_stream() await stream.write(self._content) @@ -110,16 +113,15 @@ class AssistantMessage(StreamingMessageBase): self.add_class("assistant-message") def compose(self) -> ComposeResult: - with Horizontal(classes="assistant-message-container"): - yield NonSelectableStatic("● ", classes="assistant-message-dot") - with Vertical(classes="assistant-message-content"): - markdown = Markdown("") - self._markdown = markdown - yield markdown + if self._content: + self._content_initialized = True + markdown = Markdown(self._content) + self._markdown = markdown + yield markdown class ReasoningMessage(SpinnerMixin, StreamingMessageBase): - SPINNER_TYPE = SpinnerType.LINE + SPINNER_TYPE = SpinnerType.PULSE SPINNING_TEXT = "Thinking" COMPLETED_TEXT = "Thought" @@ -207,19 +209,6 @@ class WhatsNewMessage(Static): yield Markdown(self._content) -class PlanOfferMessage(Static): - def __init__(self, text: str) -> None: - super().__init__() - self.add_class("plan-offer-message") - self._text = text - - def compose(self) -> ComposeResult: - yield Markdown(self._text) - - def get_text(self) -> str: - return self._text - - class InterruptMessage(Static): def __init__(self) -> None: super().__init__() @@ -240,30 +229,22 @@ class BashOutputMessage(Static): self.add_class("bash-output-message") self._command = command self._cwd = cwd - self._output = output + self._output = output.rstrip("\n") self._exit_code = exit_code def compose(self) -> ComposeResult: - with Vertical(classes="bash-output-container"): - with Horizontal(classes="bash-cwd-line"): - yield NoMarkupStatic(self._cwd, classes="bash-cwd") - yield NoMarkupStatic("", classes="bash-cwd-spacer") - if self._exit_code == 0: - yield NoMarkupStatic("✓", classes="bash-exit-success") - else: - yield NoMarkupStatic("✗", classes="bash-exit-failure") - yield NoMarkupStatic( - f" ({self._exit_code})", classes="bash-exit-code" - ) - with Horizontal(classes="bash-command-line"): - yield NoMarkupStatic("> ", classes="bash-chevron") - yield NoMarkupStatic(self._command, classes="bash-command") - yield NoMarkupStatic("", classes="bash-command-spacer") + status_class = "bash-success" if self._exit_code == 0 else "bash-error" + self.add_class(status_class) + with Horizontal(classes="bash-command-line"): + yield NonSelectableStatic("$ ", classes=f"bash-prompt {status_class}") + yield NoMarkupStatic(self._command, classes="bash-command") + with Horizontal(classes="bash-output-container"): + yield ExpandingBorder(classes="bash-output-border") yield NoMarkupStatic(self._output, classes="bash-output") class ErrorMessage(Static): - def __init__(self, error: str, collapsed: bool = True) -> None: + def __init__(self, error: str, collapsed: bool = False) -> None: super().__init__() self.add_class("error-message") self._error = error @@ -274,22 +255,12 @@ class ErrorMessage(Static): with Horizontal(classes="error-container"): yield ExpandingBorder(classes="error-border") self._content_widget = NoMarkupStatic( - self._get_text(), classes="error-content" + f"Error: {self._error}", classes="error-content" ) yield self._content_widget - def _get_text(self) -> str: - if self.collapsed: - return "Error. (ctrl+o to expand)" - return f"Error: {self._error}" - def set_collapsed(self, collapsed: bool) -> None: - if self.collapsed == collapsed: - return - - self.collapsed = collapsed - if self._content_widget: - self._content_widget.update(self._get_text()) + pass class WarningMessage(Static): diff --git a/vibe/cli/textual_ui/widgets/question_app.py b/vibe/cli/textual_ui/widgets/question_app.py index 742061c..f8cc94c 100644 --- a/vibe/cli/textual_ui/widgets/question_app.py +++ b/vibe/cli/textual_ui/widgets/question_app.py @@ -69,26 +69,36 @@ class QuestionApp(Container): def _current_question(self) -> Question: return self.questions[self.current_question_idx] + @property + def _has_other(self) -> bool: + return not self._current_question.hide_other + @property def _total_options(self) -> int: - base = len(self._current_question.options) + 1 + base = len(self._current_question.options) + if self._has_other: + base += 1 if self._current_question.multi_select: - return base + 1 + base += 1 return base @property def _other_option_idx(self) -> int: + if not self._has_other: + return -1 return len(self._current_question.options) @property def _submit_option_idx(self) -> int: if not self._current_question.multi_select: return -1 - return self._other_option_idx + 1 + if self._has_other: + return self._other_option_idx + 1 + return len(self._current_question.options) @property def _is_other_selected(self) -> bool: - return self.selected_option == self._other_option_idx + return self._has_other and self.selected_option == self._other_option_idx @property def _is_submit_selected(self) -> bool: @@ -217,6 +227,12 @@ class QuestionApp(Container): if not self.other_prefix or not self.other_input or not self.other_static: return + if not self._has_other: + self.other_prefix.display = False + self.other_input.display = False + self.other_static.display = False + return + q = self._current_question is_multi = q.multi_select multi_selected = self.multi_selections.get(self.current_question_idx, set()) @@ -235,6 +251,7 @@ class QuestionApp(Container): show_input = is_focused or bool(stored_text) + self.other_prefix.display = True self.other_input.display = show_input self.other_static.display = not show_input diff --git a/vibe/cli/textual_ui/widgets/spinner.py b/vibe/cli/textual_ui/widgets/spinner.py index aac5b88..1f7475c 100644 --- a/vibe/cli/textual_ui/widgets/spinner.py +++ b/vibe/cli/textual_ui/widgets/spinner.py @@ -2,11 +2,14 @@ from __future__ import annotations from abc import ABC from collections.abc import Callable -from enum import Enum +from enum import Enum, auto +import random from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable from textual.timer import Timer +from vibe.cli.textual_ui.widgets.braille_renderer import render_braille + if TYPE_CHECKING: from textual.widgets import Static @@ -51,47 +54,84 @@ class BrailleSpinner(Spinner): ) -class LineSpinner(Spinner): - FRAMES: ClassVar[tuple[str, ...]] = ("|", "/", "-", "\\") - - -class CircleSpinner(Spinner): - FRAMES: ClassVar[tuple[str, ...]] = ("◴", "◷", "◶", "◵") - - -class BowtieSpinner(Spinner): +class PulseSpinner(Spinner): FRAMES: ClassVar[tuple[str, ...]] = ( - "⠋", - "⠙", - "⠚", - "⠞", - "⠖", - "⠦", - "⠴", - "⠲", - "⠳", - "⠓", + "■", + "■", + "■", + "■", + "■", + "■", + "□", + "□", + "□", + "□", ) -class DotWaveSpinner(Spinner): - FRAMES: ClassVar[tuple[str, ...]] = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷") - - class SpinnerType(Enum): - BRAILLE = "braille" - LINE = "line" - CIRCLE = "circle" - BOWTIE = "bowtie" - DOT_WAVE = "dot_wave" + BRAILLE = auto() + PULSE = auto() + SNAKE = auto() + + +class SnakeSpinner(Spinner): + MAP_WIDTH: ClassVar[int] = 4 + MAP_HEIGHT: ClassVar[int] = 4 + SNAKE_LENGTH: ClassVar[int] = 3 + + def __init__(self) -> None: + self._positions: list[complex] = [1, 0, 1j] + super().__init__() + + @property + def current_direction(self) -> complex: + return self._positions[0] - self._positions[1] + + def _is_in_bounds(self, position: complex) -> bool: + return ( + 0 <= position.real < self.MAP_WIDTH and 0 <= position.imag < self.MAP_HEIGHT + ) + + def _get_direction(self) -> complex: + if ( + len(set(z.real for z in self._positions)) > 1 + and len(set(z.imag for z in self._positions)) > 1 + and self._is_in_bounds(self._positions[0] + self.current_direction) + ): + return self.current_direction + valid_directions = [] + for rotation in [1, 1j, -1j]: + offset = rotation * self.current_direction + new_position = self._positions[0] + offset + if self._is_in_bounds(new_position) and new_position not in self._positions: + valid_directions.append(offset) + return random.choice(valid_directions) + + def _next_positions(self) -> list[complex]: + if len(self._positions) > self.SNAKE_LENGTH: + return self._positions[: self.SNAKE_LENGTH] + head_position = self._positions[0] + direction = self._get_direction() + if self.current_direction != direction: + return [head_position + direction] + self._positions + return [head_position + direction] + self._positions[:-1] + + def current_frame(self) -> str: + return render_braille(self._positions, self.MAP_WIDTH, self.MAP_HEIGHT) + + def next_frame(self) -> str: + self._positions = self._next_positions() + return self.current_frame() + + def reset(self) -> None: + self._positions = [1, 0, 1j] _SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = { SpinnerType.BRAILLE: BrailleSpinner, - SpinnerType.LINE: LineSpinner, - SpinnerType.CIRCLE: CircleSpinner, - SpinnerType.BOWTIE: BowtieSpinner, - SpinnerType.DOT_WAVE: DotWaveSpinner, + SpinnerType.PULSE: PulseSpinner, + SpinnerType.SNAKE: SnakeSpinner, } @@ -101,7 +141,7 @@ def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner: class SpinnerMixin: - SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE + SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE SPINNING_TEXT: ClassVar[str] = "" COMPLETED_TEXT: ClassVar[str] = "" diff --git a/vibe/cli/textual_ui/widgets/status_message.py b/vibe/cli/textual_ui/widgets/status_message.py index 5608244..a28e744 100644 --- a/vibe/cli/textual_ui/widgets/status_message.py +++ b/vibe/cli/textual_ui/widgets/status_message.py @@ -12,7 +12,7 @@ from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType class StatusMessage(SpinnerMixin, NoMarkupStatic): - SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE + SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.PULSE def __init__(self, initial_text: str = "", **kwargs: Any) -> None: self._initial_text = initial_text diff --git a/vibe/cli/textual_ui/widgets/teleport_message.py b/vibe/cli/textual_ui/widgets/teleport_message.py new file mode 100644 index 0000000..1631901 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/teleport_message.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from vibe.cli.textual_ui.widgets.status_message import StatusMessage + + +class TeleportMessage(StatusMessage): + def __init__(self) -> None: + super().__init__() + self.add_class("teleport-message") + self._status: str = "Teleporting..." + self._final_url: str | None = None + self._error: str | None = None + + def get_content(self) -> str: + if self._error: + return f"Teleport failed: {self._error}" + if self._final_url: + return f"Teleported to Nuage: {self._final_url}" + return self._status + + def set_status(self, status: str) -> None: + self._status = status + self.update_display() + + def set_complete(self, url: str) -> None: + self._final_url = url + self.stop_spinning(success=True) + + def set_error(self, error: str) -> None: + self._error = error + self.stop_spinning(success=False) diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py index 8ac519b..64bcaf4 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -6,10 +6,10 @@ from pathlib import Path from pydantic import BaseModel from textual.app import ComposeResult from textual.containers import Vertical -from textual.widgets import Markdown, Static +from textual.widgets import Static +from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult from vibe.core.tools.builtins.bash import BashArgs, BashResult from vibe.core.tools.builtins.grep import GrepArgs, GrepResult @@ -23,13 +23,13 @@ from vibe.core.tools.builtins.todo import TodoArgs, TodoResult from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult -def _truncate_lines(content: str, max_lines: int) -> str: - """Truncate content to max_lines, adding indicator if truncated.""" +def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]: + """Truncate content to max_lines, returning (content, truncation_info).""" lines = content.split("\n") if len(lines) <= max_lines: - return content + return content, None remaining = len(lines) - max_lines - return "\n".join(lines[:max_lines] + [f"… ({remaining} more lines)"]) + return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)" def parse_search_replace_to_diff(content: str) -> list[str]: @@ -90,8 +90,6 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical): class ToolResultWidget[TResult: BaseModel](Static): """Base class for result widgets with typed result.""" - SHORTCUT = DEFAULT_TOOL_SHORTCUT - def __init__( self, result: TResult | None, @@ -108,21 +106,13 @@ class ToolResultWidget[TResult: BaseModel](Static): self.warnings = warnings or [] self.add_class("tool-result-widget") - def _hint(self) -> str: - action = "expand" if self.collapsed else "collapse" - return f"({self.SHORTCUT} to {action})" - - def _header(self) -> ComposeResult: - """Yield the standard header. Subclasses can call this then add content.""" - if self.collapsed: - yield NoMarkupStatic(f"{self.message} {self._hint()}") - else: - yield NoMarkupStatic(self.message) + def _footer(self, extra: str | None = None) -> ComposeResult: + """Yield the footer with optional extra info.""" + if extra: + yield NoMarkupStatic(extra, classes="tool-result-hint") def compose(self) -> ComposeResult: - """Default: show message and optionally result fields.""" - yield from self._header() - + """Default: show result fields.""" if not self.collapsed and self.result: for field_name in type(self.result).model_fields: value = getattr(self.result, field_name) @@ -130,6 +120,7 @@ class ToolResultWidget[TResult: BaseModel](Static): yield NoMarkupStatic( f"{field_name}: {value}", classes="tool-result-detail" ) + yield from self._footer() class BashApprovalWidget(ToolApprovalWidget[BashArgs]): @@ -139,8 +130,17 @@ class BashApprovalWidget(ToolApprovalWidget[BashArgs]): class BashResultWidget(ToolResultWidget[BashResult]): def compose(self) -> ComposeResult: - yield from self._header() - if self.collapsed or not self.result: + if not self.result: + yield from self._footer() + return + if self.collapsed: + truncation_info = None + if self.result.stdout: + content, truncation_info = _truncate_lines(self.result.stdout, 10) + yield NoMarkupStatic(content, classes="tool-result-detail") + else: + yield NoMarkupStatic("(no content)", classes="tool-result-detail") + yield from self._footer(truncation_info) return yield NoMarkupStatic( f"returncode: {self.result.returncode}", classes="tool-result-detail" @@ -155,6 +155,7 @@ class BashResultWidget(ToolResultWidget[BashResult]): yield NoMarkupStatic( f"stderr:{sep}{self.result.stderr}", classes="tool-result-detail" ) + yield from self._footer() class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]): @@ -169,8 +170,16 @@ class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]): class WriteFileResultWidget(ToolResultWidget[WriteFileResult]): def compose(self) -> ComposeResult: - yield from self._header() - if self.collapsed or not self.result: + if not self.result: + yield from self._footer() + return + ext = Path(self.result.path).suffix.lstrip(".") or "text" + if self.collapsed: + truncation_info = None + if self.result.content: + content, truncation_info = _truncate_lines(self.result.content, 10) + yield Markdown(f"```{ext}\n{content}\n```") + yield from self._footer(truncation_info) return yield NoMarkupStatic(f"Path: {self.result.path}", classes="tool-result-detail") yield NoMarkupStatic( @@ -178,8 +187,9 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]): ) if self.result.content: yield NoMarkupStatic("") - ext = Path(self.result.path).suffix.lstrip(".") or "text" - yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```") + content, _ = _truncate_lines(self.result.content, 10) + yield Markdown(f"```{ext}\n{content}\n```") + yield from self._footer() class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]): @@ -196,23 +206,15 @@ class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]): class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]): def compose(self) -> ComposeResult: - yield from self._header() - if self.collapsed or not self.result: + if not self.result: + yield from self._footer() return - yield NoMarkupStatic(f"File: {self.result.file}", classes="tool-result-detail") - yield NoMarkupStatic( - f"Blocks applied: {self.result.blocks_applied}", - classes="tool-result-detail", - ) - yield NoMarkupStatic( - f"Lines changed: {self.result.lines_changed}", classes="tool-result-detail" - ) - for warning in self.result.warnings: + for warning in self.warnings: yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") if self.result.content: - yield NoMarkupStatic("") for line in parse_search_replace_to_diff(self.result.content): yield render_diff_line(line) + yield from self._footer() class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]): @@ -227,41 +229,30 @@ class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]): class TodoResultWidget(ToolResultWidget[TodoResult]): - SHORTCUT = TOOL_SHORTCUTS["todo"] - def compose(self) -> ComposeResult: - if self.collapsed: - yield NoMarkupStatic(f"{self.message} {self._hint()}") - else: - yield NoMarkupStatic(f"{self.message} {self._hint()}") - yield NoMarkupStatic("") + if not self.result or not self.result.todos: + yield NoMarkupStatic("No todos", classes="todo-empty") + yield from self._footer() + return - if not self.result or not self.result.todos: - yield NoMarkupStatic("No todos", classes="todo-empty") - return + by_status: dict[str, list] = { + "in_progress": [], + "pending": [], + "completed": [], + "cancelled": [], + } + for todo in self.result.todos: + status = ( + todo.status.value if hasattr(todo.status, "value") else str(todo.status) + ) + if status in by_status: + by_status[status].append(todo) - # Group todos by status - by_status: dict[str, list] = { - "in_progress": [], - "pending": [], - "completed": [], - "cancelled": [], - } - for todo in self.result.todos: - status = ( - todo.status.value - if hasattr(todo.status, "value") - else str(todo.status) - ) - if status in by_status: - by_status[status].append(todo) - - for status in ["in_progress", "pending", "completed", "cancelled"]: - for todo in by_status[status]: - icon = self._get_status_icon(status) - yield NoMarkupStatic( - f"{icon} {todo.content}", classes=f"todo-{status}" - ) + for status in ["in_progress", "pending", "completed", "cancelled"]: + for todo in by_status[status]: + icon = self._get_status_icon(status) + yield NoMarkupStatic(f"{icon} {todo.content}", classes=f"todo-{status}") + yield from self._footer() def _get_status_icon(self, status: str) -> str: icons = {"pending": "☐", "in_progress": "☐", "completed": "☑", "cancelled": "☒"} @@ -283,8 +274,8 @@ class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]): class ReadFileResultWidget(ToolResultWidget[ReadFileResult]): def compose(self) -> ComposeResult: - yield from self._header() if self.collapsed: + yield from self._footer() return if self.result: yield NoMarkupStatic( @@ -292,10 +283,13 @@ class ReadFileResultWidget(ToolResultWidget[ReadFileResult]): ) for warning in self.warnings: yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") + truncation_info = None if self.result and self.result.content: yield NoMarkupStatic("") ext = Path(self.result.path).suffix.lstrip(".") or "text" - yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```") + content, truncation_info = _truncate_lines(self.result.content, 10) + yield Markdown(f"```{ext}\n{content}\n```") + yield from self._footer(truncation_info) class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]): @@ -312,20 +306,24 @@ class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]): class GrepResultWidget(ToolResultWidget[GrepResult]): def compose(self) -> ComposeResult: - yield from self._header() - if self.collapsed: - return for warning in self.warnings: yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") - if self.result and self.result.matches: - yield NoMarkupStatic("") - yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```") + if not self.result or not self.result.matches: + yield from self._footer() + return + max_lines = 10 if self.collapsed else None + if max_lines: + content, truncation_info = _truncate_lines(self.result.matches, max_lines) + else: + content, truncation_info = self.result.matches, None + yield NoMarkupStatic(content, classes="tool-result-detail") + yield from self._footer(truncation_info) class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]): def compose(self) -> ComposeResult: if self.collapsed or not self.result: - yield from self._header() + yield from self._footer() return for answer in self.result.answers: @@ -333,6 +331,7 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]): yield NoMarkupStatic(answer.question, classes="tool-result-detail") prefix = "(Other) " if answer.is_other else "" yield NoMarkupStatic(f"{prefix}{answer.answer}", classes="ask-user-answer") + yield from self._footer() APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = { diff --git a/vibe/cli/textual_ui/widgets/tools.py b/vibe/cli/textual_ui/widgets/tools.py index 30a4fcd..964a7f1 100644 --- a/vibe/cli/textual_ui/widgets/tools.py +++ b/vibe/cli/textual_ui/widgets/tools.py @@ -8,7 +8,6 @@ from vibe.cli.textual_ui.widgets.messages import ExpandingBorder, NonSelectableS from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.status_message import StatusMessage from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget -from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS from vibe.core.tools.ui import ToolUIDataAdapter from vibe.core.types import ToolCallEvent, ToolResultEvent @@ -44,6 +43,14 @@ class ToolCallMessage(StatusMessage): self._stream_widget.display = False yield self._stream_widget + def on_mount(self) -> None: + siblings = list(self.parent.children) if self.parent else [] + idx = siblings.index(self) if self in siblings else -1 + if idx > 0 and isinstance( + siblings[idx - 1], (ToolCallMessage, ToolResultMessage) + ): + self.add_class("no-gap") + def get_content(self) -> str: if self._event and self._event.tool_class: adapter = ToolUIDataAdapter(self._event.tool_class) @@ -63,6 +70,10 @@ class ToolCallMessage(StatusMessage): self._stream_widget.display = False super().stop_spinning(success) + def set_result_text(self, text: str) -> None: + if self._text_widget: + self._text_widget.update(text) + class ToolResultMessage(Static): def __init__( @@ -91,13 +102,6 @@ class ToolResultMessage(Static): def tool_name(self) -> str: return self._tool_name - def _shortcut(self) -> str: - return TOOL_SHORTCUTS.get(self._tool_name, DEFAULT_TOOL_SHORTCUT) - - def _hint(self) -> str: - action = "expand" if self.collapsed else "collapse" - return f"({self._shortcut()} to {action})" - def compose(self) -> ComposeResult: with Horizontal(classes="tool-result-container"): yield ExpandingBorder(classes="tool-result-border") @@ -108,6 +112,8 @@ class ToolResultMessage(Static): if self._call_widget: success = self._determine_success() self._call_widget.stop_spinning(success=success) + result_text = self._get_result_text() + self._call_widget.set_result_text(result_text) await self._render_result() def _determine_success(self) -> bool: @@ -121,6 +127,23 @@ class ToolResultMessage(Static): return display.success return True + def _get_result_text(self) -> str: + if self._event is None: + return f"{self._tool_name} completed" + + if self._event.error: + return f"{self._tool_name}: error" + + if self._event.skipped: + return f"{self._tool_name}: skipped" + + if self._event.tool_class: + adapter = ToolUIDataAdapter(self._event.tool_class) + display = adapter.get_result_display(self._event) + return display.message + + return f"{self._tool_name} completed" + async def _render_result(self) -> None: if self._content_container is None: return @@ -128,39 +151,29 @@ class ToolResultMessage(Static): await self._content_container.remove_children() if self._event is None: - await self._render_simple() + self.display = False return if self._event.error: self.add_class("error-text") - if self.collapsed: - await self._content_container.mount( - NoMarkupStatic(f"Error. {self._hint()}") - ) - else: - await self._content_container.mount( - NoMarkupStatic(f"Error: {self._event.error}") - ) + await self._content_container.mount( + NoMarkupStatic(f"Error: {self._event.error}") + ) + self.display = True return if self._event.skipped: self.add_class("warning-text") reason = self._event.skip_reason or "User skipped" - if self.collapsed: - await self._content_container.mount( - NoMarkupStatic(f"Skipped. {self._hint()}") - ) - else: - await self._content_container.mount( - NoMarkupStatic(f"Skipped: {reason}") - ) + await self._content_container.mount(NoMarkupStatic(f"Skipped: {reason}")) + self.display = True return self.remove_class("error-text") self.remove_class("warning-text") if self._event.tool_class is None: - await self._render_simple() + self.display = False return adapter = ToolUIDataAdapter(self._event.tool_class) @@ -175,23 +188,7 @@ class ToolResultMessage(Static): warnings=display.warnings, ) await self._content_container.mount(widget) - - async def _render_simple(self) -> None: - if self._content_container is None: - return - - if self.collapsed: - await self._content_container.mount( - NoMarkupStatic(f"{self._tool_name} completed {self._hint()}") - ) - return - - if self._content: - await self._content_container.mount(NoMarkupStatic(self._content)) - else: - await self._content_container.mount( - NoMarkupStatic(f"{self._tool_name} completed.") - ) + self.display = bool(widget.children) async def set_collapsed(self, collapsed: bool) -> None: if self.collapsed == collapsed: diff --git a/vibe/cli/textual_ui/widgets/utils.py b/vibe/cli/textual_ui/widgets/utils.py deleted file mode 100644 index feb0977..0000000 --- a/vibe/cli/textual_ui/widgets/utils.py +++ /dev/null @@ -1,4 +0,0 @@ -from __future__ import annotations - -TOOL_SHORTCUTS = {"todo": "ctrl+t"} -DEFAULT_TOOL_SHORTCUT = "ctrl+o" diff --git a/vibe/cli/textual_ui/widgets/welcome.py b/vibe/cli/textual_ui/widgets/welcome.py deleted file mode 100644 index b2af828..0000000 --- a/vibe/cli/textual_ui/widgets/welcome.py +++ /dev/null @@ -1,283 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from time import monotonic - -from rich.align import Align -from rich.console import Group -from rich.text import Text -from textual.color import Color -from textual.widgets import Static - -from vibe import __version__ -from vibe.core.config import VibeConfig - - -def hex_to_rgb(hex_color: str) -> tuple[int, int, int]: - normalized = hex_color.lstrip("#") - r, g, b = (int(normalized[i : i + 2], 16) for i in (0, 2, 4)) - return (r, g, b) - - -def rgb_to_hex(r: int, g: int, b: int) -> str: - return f"#{r:02x}{g:02x}{b:02x}" - - -def interpolate_color( - start_rgb: tuple[int, int, int], end_rgb: tuple[int, int, int], progress: float -) -> str: - progress = max(0.0, min(1.0, progress)) - r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * progress) - g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * progress) - b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * progress) - return rgb_to_hex(r, g, b) - - -@dataclass -class LineAnimationState: - progress: float = 0.0 - cached_color: str | None = None - cached_progress: float = -1.0 - rendered_color: str | None = None - - -class WelcomeBanner(Static): - FLASH_COLOR = "#FFFFFF" - TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500") - BORDER_TARGET_COLOR = "#b05800" - - LINE_ANIMATION_DURATION_MS = 200 - LINE_STAGGER_MS = 280 - FLASH_RESET_DURATION_MS = 400 - ANIMATION_TICK_INTERVAL = 0.1 - - COLOR_FLASH_MIDPOINT = 0.5 - COLOR_PHASE_SCALE = 2.0 - COLOR_CACHE_THRESHOLD = 0.001 - BORDER_PROGRESS_THRESHOLD = 0.01 - - BLOCK = "▇▇" - SPACE = " " - LOGO_TEXT_GAP = " " - - def __init__(self, config: VibeConfig) -> None: - super().__init__(" ") - self.config = config - self.animation_timer = None - self._animation_start_time: float | None = None - - self._cached_skeleton_color: str | None = None - self._cached_skeleton_rgb: tuple[int, int, int] | None = None - self._flash_rgb = hex_to_rgb(self.FLASH_COLOR) - self._target_rgbs = [hex_to_rgb(c) for c in self.TARGET_COLORS] - self._border_target_rgb = hex_to_rgb(self.BORDER_TARGET_COLOR) - - self._line_states = [LineAnimationState() for _ in self.TARGET_COLORS] - self.border_progress = 0.0 - self._cached_border_color: str | None = None - self._cached_border_progress = -1.0 - - self._line_duration = self.LINE_ANIMATION_DURATION_MS / 1000 - self._line_stagger = self.LINE_STAGGER_MS / 1000 - self._border_duration = self.FLASH_RESET_DURATION_MS / 1000 - self._line_start_times = [ - idx * self._line_stagger for idx in range(len(self.TARGET_COLORS)) - ] - self._all_lines_finish_time = ( - (len(self.TARGET_COLORS) - 1) * self.LINE_STAGGER_MS - + self.LINE_ANIMATION_DURATION_MS - ) / 1000 - - self._cached_text_lines: list[Text | None] = [None] * 7 - self._initialize_static_line_suffixes() - - def _initialize_static_line_suffixes(self) -> None: - self._static_line1_suffix = ( - f"{self.LOGO_TEXT_GAP}[b]Mistral Vibe v{__version__}[/]" - ) - self._static_line2_suffix = ( - f"{self.LOGO_TEXT_GAP}[dim]{self.config.active_model}[/]" - ) - mcp_count = len(self.config.mcp_servers) - model_count = len(self.config.models) - self._static_line3_suffix = f"{self.LOGO_TEXT_GAP}[dim]{model_count} models · {mcp_count} MCP servers[/]" - self._static_line5_suffix = ( - f"{self.LOGO_TEXT_GAP}[dim]{self.config.displayed_workdir or Path.cwd()}[/]" - ) - self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]" - - @property - def skeleton_color(self) -> str: - return self._cached_skeleton_color or "#1e1e1e" - - @property - def skeleton_rgb(self) -> tuple[int, int, int]: - return self._cached_skeleton_rgb or hex_to_rgb("#1e1e1e") - - def on_mount(self) -> None: - if not self.config.disable_welcome_banner_animation: - self.call_after_refresh(self._init_after_styles) - - def _init_after_styles(self) -> None: - self._cache_skeleton_color() - self._cached_text_lines[5] = Text("") - self._cached_text_lines[6] = Text.from_markup(self._static_line7) - self._update_display() - self._start_animation() - - def _cache_skeleton_color(self) -> None: - try: - border = self.styles.border - if ( - hasattr(border, "top") - and isinstance(edge := border.top, tuple) - and len(edge) >= 2 # noqa: PLR2004 - and isinstance(color := edge[1], Color) - ): - self._cached_skeleton_color = color.hex - self._cached_skeleton_rgb = hex_to_rgb(color.hex) - return - except (AttributeError, TypeError): - pass - - self._cached_skeleton_color = "#1e1e1e" - self._cached_skeleton_rgb = hex_to_rgb("#1e1e1e") - - def _stop_timer(self) -> None: - if self.animation_timer: - try: - self.animation_timer.stop() - except Exception: - pass - self.animation_timer = None - - def on_unmount(self) -> None: - self._stop_timer() - - def _start_animation(self) -> None: - self._animation_start_time = monotonic() - - def tick() -> None: - if self._is_animation_complete(): - self._stop_timer() - return - if self._animation_start_time is None: - return - - elapsed = monotonic() - self._animation_start_time - updated_lines = self._advance_line_progress(elapsed) - border_updated = self._advance_border_progress(elapsed) - - if border_updated: - self._update_border_color() - if updated_lines or border_updated: - self._update_display() - - self.animation_timer = self.set_interval(self.ANIMATION_TICK_INTERVAL, tick) - - def _advance_line_progress(self, elapsed: float) -> bool: - any_updates = False - for line_idx, state in enumerate(self._line_states): - if state.progress >= 1.0: - continue - start_time = self._line_start_times[line_idx] - if elapsed < start_time: - continue - progress = min(1.0, (elapsed - start_time) / self._line_duration) - if progress > state.progress: - state.progress = progress - any_updates = True - return any_updates - - def _advance_border_progress(self, elapsed: float) -> bool: - if elapsed < self._all_lines_finish_time: - return False - - new_progress = min( - 1.0, (elapsed - self._all_lines_finish_time) / self._border_duration - ) - - if abs(new_progress - self.border_progress) > self.BORDER_PROGRESS_THRESHOLD: - self.border_progress = new_progress - return True - - return False - - def _is_animation_complete(self) -> bool: - return ( - all(state.progress >= 1.0 for state in self._line_states) - and self.border_progress >= 1.0 - ) - - def _update_border_color(self) -> None: - progress = self.border_progress - if abs(progress - self._cached_border_progress) < self.COLOR_CACHE_THRESHOLD: - return - - border_color = self._compute_color_for_progress( - progress, self._border_target_rgb - ) - self._cached_border_color = border_color - self._cached_border_progress = progress - self.styles.border = ("round", border_color) - - def _compute_color_for_progress( - self, progress: float, target_rgb: tuple[int, int, int] - ) -> str: - if progress <= 0: - return self.skeleton_color - - if progress <= self.COLOR_FLASH_MIDPOINT: - phase = progress * self.COLOR_PHASE_SCALE - return interpolate_color(self.skeleton_rgb, self._flash_rgb, phase) - - phase = (progress - self.COLOR_FLASH_MIDPOINT) * self.COLOR_PHASE_SCALE - return interpolate_color(self._flash_rgb, target_rgb, phase) - - def _update_display(self) -> None: - for idx in range(5): - self._update_colored_line(idx, idx) - - lines = [line if line else Text("") for line in self._cached_text_lines] - self.update(Align.center(Group(*lines))) - - def _get_color(self, line_idx: int) -> str: - state = self._line_states[line_idx] - if ( - abs(state.progress - state.cached_progress) < self.COLOR_CACHE_THRESHOLD - and state.cached_color - ): - return state.cached_color - - color = self._compute_color_for_progress( - state.progress, self._target_rgbs[line_idx] - ) - state.cached_color = color - state.cached_progress = state.progress - return color - - def _update_colored_line(self, slot_idx: int, line_idx: int) -> None: - color = self._get_color(line_idx) - state = self._line_states[line_idx] - - if color == state.rendered_color and self._cached_text_lines[slot_idx]: - return - - state.rendered_color = color - self._cached_text_lines[slot_idx] = Text.from_markup( - self._build_line(slot_idx, color) - ) - - def _build_line(self, line_idx: int, color: str) -> str: - B = self.BLOCK - S = self.SPACE - - patterns = [ - f"{S}[{color}]{B}[/]{S}{S}{S}[{color}]{B}[/]{S}{self._static_line1_suffix}", - f"{S}[{color}]{B}{B}[/]{S}[{color}]{B}{B}[/]{S}{self._static_line2_suffix}", - f"{S}[{color}]{B}{B}{B}{B}{B}[/]{S}{self._static_line3_suffix}", - f"{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}", - f"[{color}]{B}{B}{B}[/]{S}[{color}]{B}{B}{B}[/]{self._static_line5_suffix}", - ] - return patterns[line_idx] diff --git a/vibe/cli/textual_ui/windowing/__init__.py b/vibe/cli/textual_ui/windowing/__init__.py new file mode 100644 index 0000000..80804ef --- /dev/null +++ b/vibe/cli/textual_ui/windowing/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from vibe.cli.textual_ui.windowing.history import ( + build_history_widgets, + non_system_history_messages, +) +from vibe.cli.textual_ui.windowing.history_windowing import ( + create_resume_plan, + should_resume_history, + sync_backfill_state, +) +from vibe.cli.textual_ui.windowing.state import ( + HISTORY_RESUME_TAIL_MESSAGES, + LOAD_MORE_BATCH_SIZE, + HistoryLoadMoreManager, + SessionWindowing, +) + +__all__ = [ + "HISTORY_RESUME_TAIL_MESSAGES", + "LOAD_MORE_BATCH_SIZE", + "HistoryLoadMoreManager", + "SessionWindowing", + "build_history_widgets", + "create_resume_plan", + "non_system_history_messages", + "should_resume_history", + "sync_backfill_state", +] diff --git a/vibe/cli/textual_ui/windowing/history.py b/vibe/cli/textual_ui/windowing/history.py new file mode 100644 index 0000000..4b0c8c7 --- /dev/null +++ b/vibe/cli/textual_ui/windowing/history.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from weakref import WeakKeyDictionary + +from textual.widget import Widget + +from vibe.cli.textual_ui.widgets.messages import ( + AssistantMessage, + ReasoningMessage, + UserMessage, +) +from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage +from vibe.core.types import LLMMessage, Role + + +def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]: + return [msg for msg in messages if msg.role != Role.system] + + +def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]: + tool_call_map: dict[str, str] = {} + for msg in messages: + if msg.role != Role.assistant or not msg.tool_calls: + continue + for tool_call in msg.tool_calls: + if tool_call.id: + tool_call_map[tool_call.id] = tool_call.function.name or "unknown" + return tool_call_map + + +def build_history_widgets( + batch: list[LLMMessage], + tool_call_map: dict[str, str], + *, + start_index: int, + tools_collapsed: bool, + history_widget_indices: WeakKeyDictionary[Widget, int], +) -> list[Widget]: + widgets: list[Widget] = [] + + for offset, msg in enumerate(batch): + history_index = start_index + offset + match msg.role: + case Role.user: + if msg.content: + widget = UserMessage(msg.content) + widgets.append(widget) + history_widget_indices[widget] = history_index + + case Role.assistant: + if msg.content: + assistant_widget = AssistantMessage(msg.content) + widgets.append(assistant_widget) + history_widget_indices[assistant_widget] = history_index + + if msg.tool_calls: + for tool_call in msg.tool_calls: + tool_name = tool_call.function.name or "unknown" + if tool_call.id: + tool_call_map[tool_call.id] = tool_name + widget = ToolCallMessage(tool_name=tool_name) + widgets.append(widget) + history_widget_indices[widget] = history_index + + case Role.tool: + tool_name = msg.name or tool_call_map.get( + msg.tool_call_id or "", "tool" + ) + widget = ToolResultMessage( + tool_name=tool_name, content=msg.content, collapsed=tools_collapsed + ) + widgets.append(widget) + history_widget_indices[widget] = history_index + + return widgets + + +def split_history_tail( + history_messages: list[LLMMessage], tail_size: int +) -> tuple[list[LLMMessage], list[LLMMessage], int]: + tail_messages = history_messages[-tail_size:] + backfill_messages = history_messages[:-tail_size] + tail_start_index = len(history_messages) - len(tail_messages) + return tail_messages, backfill_messages, tail_start_index + + +def visible_history_indices( + children: list[Widget], history_widget_indices: WeakKeyDictionary[Widget, int] +) -> list[int]: + return [ + idx + for child in children + if (idx := history_widget_indices.get(child)) is not None + ] + + +def visible_history_widgets_count(children: list[Widget]) -> int: + history_widget_types = ( + UserMessage, + AssistantMessage, + ReasoningMessage, + ToolCallMessage, + ToolResultMessage, + ) + return sum(isinstance(child, history_widget_types) for child in children) diff --git a/vibe/cli/textual_ui/windowing/history_windowing.py b/vibe/cli/textual_ui/windowing/history_windowing.py new file mode 100644 index 0000000..59c49ec --- /dev/null +++ b/vibe/cli/textual_ui/windowing/history_windowing.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +from weakref import WeakKeyDictionary + +from textual.widget import Widget + +from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage +from vibe.cli.textual_ui.windowing.history import ( + build_tool_call_map, + split_history_tail, + visible_history_indices, + visible_history_widgets_count, +) +from vibe.cli.textual_ui.windowing.state import SessionWindowing +from vibe.core.types import LLMMessage + + +@dataclass(frozen=True) +class HistoryResumePlan: + tool_call_map: dict[str, str] + tail_messages: list[LLMMessage] + backfill_messages: list[LLMMessage] + tail_start_index: int + + @property + def has_backfill(self) -> bool: + return bool(self.backfill_messages) + + +def should_resume_history(messages_children: list[Widget]) -> bool: + allowed_pre_existing_types = (WhatsNewMessage,) + return all( + isinstance(child, allowed_pre_existing_types) for child in messages_children + ) + + +def create_resume_plan( + history_messages: list[LLMMessage], tail_size: int +) -> HistoryResumePlan | None: + if not history_messages: + return None + tail_messages, backfill_messages, tail_start_index = split_history_tail( + history_messages, tail_size + ) + return HistoryResumePlan( + tool_call_map=build_tool_call_map(history_messages), + tail_messages=tail_messages, + backfill_messages=backfill_messages, + tail_start_index=tail_start_index, + ) + + +def sync_backfill_state( + *, + history_messages: list[LLMMessage], + messages_children: list[Widget], + history_widget_indices: WeakKeyDictionary[Widget, int], + windowing: SessionWindowing, +) -> tuple[bool, dict[str, str] | None]: + if not history_messages: + windowing.reset() + return False, None + visible_indices = visible_history_indices(messages_children, history_widget_indices) + visible_history_widgets = visible_history_widgets_count(messages_children) + has_backfill = windowing.recompute_backfill( + history_messages, + visible_indices=visible_indices, + visible_history_widgets_count=visible_history_widgets, + ) + return has_backfill, build_tool_call_map(history_messages) diff --git a/vibe/cli/textual_ui/windowing/state.py b/vibe/cli/textual_ui/windowing/state.py new file mode 100644 index 0000000..d9cdf3f --- /dev/null +++ b/vibe/cli/textual_ui/windowing/state.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from textual.widget import Widget + +from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreMessage +from vibe.core.types import LLMMessage + +HISTORY_RESUME_TAIL_MESSAGES = 20 +LOAD_MORE_BATCH_SIZE = 10 + + +@dataclass(frozen=True) +class LoadMoreBatch: + start_index: int + messages: list[LLMMessage] + + +class SessionWindowing: + def __init__(self, load_more_batch_size: int) -> None: + self.load_more_batch_size = load_more_batch_size + self._backfill_messages: list[LLMMessage] = [] + self._backfill_cursor = 0 + + @property + def remaining(self) -> int: + return self._backfill_cursor + + @property + def has_backfill(self) -> bool: + return self._backfill_cursor > 0 + + def reset(self) -> None: + self._backfill_messages = [] + self._backfill_cursor = 0 + + def set_backfill(self, backfill_messages: list[LLMMessage]) -> None: + self._backfill_messages = backfill_messages + self._backfill_cursor = len(backfill_messages) + + def next_load_more_batch(self) -> LoadMoreBatch | None: + if self._backfill_cursor == 0: + return None + start_index = max(self._backfill_cursor - self.load_more_batch_size, 0) + batch = self._backfill_messages[start_index : self._backfill_cursor] + self._backfill_cursor = start_index + if not batch: + return None + return LoadMoreBatch(start_index=start_index, messages=batch) + + def recompute_backfill( + self, + history_messages: list[LLMMessage], + visible_indices: list[int], + visible_history_widgets_count: int, + ) -> bool: + if not history_messages: + self._backfill_messages = [] + self._backfill_cursor = 0 + return False + if visible_indices: + backfill_end = min(visible_indices) + else: + backfill_end = max(len(history_messages) - visible_history_widgets_count, 0) + self._backfill_messages = history_messages[:backfill_end] + self._backfill_cursor = len(self._backfill_messages) + return self._backfill_cursor > 0 + + +class HistoryLoadMoreManager: + def __init__(self) -> None: + self.widget: HistoryLoadMoreMessage | None = None + + async def show(self, messages_area: Widget, remaining: int) -> None: + if self.widget is None: + widget = HistoryLoadMoreMessage() + await messages_area.mount(widget, before=0) + self.widget = widget + self.set_remaining(remaining) + + async def hide(self) -> None: + if self.widget is None: + return + if self.widget.parent: + await self.widget.remove() + self.widget = None + + async def set_visible( + self, messages_area: Widget, *, visible: bool, remaining: int + ) -> None: + if visible: + await self.show(messages_area, remaining) + return + await self.hide() + + def set_enabled(self, enabled: bool) -> None: + if self.widget is None: + return + self.widget.set_enabled(enabled) + + def set_remaining(self, remaining: int) -> None: + if self.widget is None: + return + self.widget.set_remaining(remaining) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 78ecdd4..c13e097 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -6,7 +6,7 @@ from enum import StrEnum, auto from http import HTTPStatus from threading import Thread import time -from typing import cast +from typing import TYPE_CHECKING, cast from uuid import uuid4 from pydantic import BaseModel @@ -75,6 +75,19 @@ from vibe.core.utils import ( is_user_cancellation_event, ) +try: + from vibe.core.teleport.teleport import TeleportService as _TeleportService + + _TELEPORT_AVAILABLE = True +except ImportError: + _TELEPORT_AVAILABLE = False + _TeleportService = None + +if TYPE_CHECKING: + from vibe.core.teleport.nuage import TeleportSession + from vibe.core.teleport.teleport import TeleportService + from vibe.core.teleport.types import TeleportPushResponseEvent, TeleportYieldEvent + class ToolExecutionResponse(StrEnum): SKIP = auto() @@ -98,6 +111,10 @@ class AgentLoopLLMResponseError(AgentLoopError): """Raised when LLM response is malformed or missing expected data.""" +class TeleportError(AgentLoopError): + """Raised when teleport to Vibe Nuage fails.""" + + def _should_raise_rate_limit_error(e: Exception) -> bool: return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS @@ -134,7 +151,7 @@ class AgentLoop: self._setup_middleware() system_prompt = get_universal_system_prompt( - self.tool_manager, config, self.skill_manager, self.agent_manager + self.tool_manager, self.config, self.skill_manager, self.agent_manager ) self.messages = [LLMMessage(role=Role.system, content=system_prompt)] @@ -156,6 +173,7 @@ class AgentLoop: self.session_id = str(uuid4()) self.session_logger = SessionLogger(config.session_logging, self.session_id) + self._teleport_service: TeleportService | None = None thread = Thread( target=migrate_sessions_entrypoint, @@ -227,6 +245,60 @@ class AgentLoop: async for event in self._conversation_loop(msg): yield event + @property + def teleport_service(self) -> TeleportService: + if not _TELEPORT_AVAILABLE: + raise TeleportError( + "Teleport requires git to be installed. " + "Please install git and try again." + ) + + if self._teleport_service is None: + if _TeleportService is None: + raise TeleportError("_TeleportService is unexpectedly None") + self._teleport_service = _TeleportService( + session_logger=self.session_logger, + nuage_base_url=self.config.nuage_base_url, + nuage_workflow_id=self.config.nuage_workflow_id, + nuage_api_key=self.config.nuage_api_key, + ) + return self._teleport_service + + def teleport_to_vibe_nuage( + self, prompt: str | None + ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]: + from vibe.core.teleport.nuage import TeleportSession + + session = TeleportSession( + metadata={ + "agent": self.agent_profile.name, + "model": self.config.active_model, + "stats": self.stats.model_dump(), + }, + messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]], + ) + return self._teleport_generator(prompt, session) + + async def _teleport_generator( + self, prompt: str | None, session: TeleportSession + ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]: + from vibe.core.teleport.errors import ServiceTeleportError + + try: + async with self.teleport_service: + gen = self.teleport_service.execute(prompt=prompt, session=session) + response: TeleportPushResponseEvent | None = None + while True: + try: + event = await gen.asend(response) + response = yield event + except StopAsyncIteration: + break + except ServiceTeleportError as e: + raise TeleportError(str(e)) from e + finally: + self._teleport_service = None + def _setup_middleware(self) -> None: """Configure middleware pipeline for this conversation.""" self.middleware_pipeline.clear() @@ -575,19 +647,18 @@ class AgentLoop: try: start_time = time.perf_counter() - async with self.backend as backend: - result = await backend.complete( - model=active_model, - messages=self.messages, - temperature=active_model.temperature, - tools=available_tools, - tool_choice=tool_choice, - extra_headers={ - "user-agent": get_user_agent(provider.backend), - "x-affinity": self.session_id, - }, - max_tokens=max_tokens, - ) + result = await self.backend.complete( + model=active_model, + messages=self.messages, + temperature=active_model.temperature, + tools=available_tools, + tool_choice=tool_choice, + extra_headers={ + "user-agent": get_user_agent(provider.backend), + "x-affinity": self.session_id, + }, + max_tokens=max_tokens, + ) end_time = time.perf_counter() if result.usage is None: @@ -622,28 +693,25 @@ class AgentLoop: start_time = time.perf_counter() usage = LLMUsage() chunk_agg = LLMChunk(message=LLMMessage(role=Role.assistant)) - async with self.backend as backend: - async for chunk in backend.complete_streaming( - model=active_model, - messages=self.messages, - temperature=active_model.temperature, - tools=available_tools, - tool_choice=tool_choice, - extra_headers={ - "user-agent": get_user_agent(provider.backend), - "x-affinity": self.session_id, - }, - max_tokens=max_tokens, - ): - processed_message = ( - self.format_handler.process_api_response_message(chunk.message) - ) - processed_chunk = LLMChunk( - message=processed_message, usage=chunk.usage - ) - chunk_agg += processed_chunk - usage += chunk.usage or LLMUsage() - yield processed_chunk + async for chunk in self.backend.complete_streaming( + model=active_model, + messages=self.messages, + temperature=active_model.temperature, + tools=available_tools, + tool_choice=tool_choice, + extra_headers={ + "user-agent": get_user_agent(provider.backend), + "x-affinity": self.session_id, + }, + max_tokens=max_tokens, + ): + processed_message = self.format_handler.process_api_response_message( + chunk.message + ) + processed_chunk = LLMChunk(message=processed_message, usage=chunk.usage) + chunk_agg += processed_chunk + usage += chunk.usage or LLMUsage() + yield processed_chunk end_time = time.perf_counter() if chunk_agg.usage is None: @@ -851,13 +919,12 @@ class AgentLoop: active_model = self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) - async with self.backend as backend: - actual_context_tokens = await backend.count_tokens( - model=active_model, - messages=self.messages, - tools=self.format_handler.get_available_tools(self.tool_manager), - extra_headers={"user-agent": get_user_agent(provider.backend)}, - ) + actual_context_tokens = await self.backend.count_tokens( + model=active_model, + messages=self.messages, + tools=self.format_handler.get_available_tools(self.tool_manager), + extra_headers={"user-agent": get_user_agent(provider.backend)}, + ) self.stats.context_tokens = actual_context_tokens @@ -896,6 +963,12 @@ class AgentLoop: max_turns: int | None = None, max_price: float | None = None, ) -> None: + # Force an immediate yield to allow the UI to update before heavy sync work. + # When there are no messages, save_interaction returns early without any await, + # so the coroutine would run synchronously through ToolManager, SkillManager, + # and system prompt generation without yielding control to the event loop. + await asyncio.sleep(0) + await self.session_logger.save_interaction( self.messages, self.stats, diff --git a/vibe/core/auth/__init__.py b/vibe/core/auth/__init__.py new file mode 100644 index 0000000..35173e5 --- /dev/null +++ b/vibe/core/auth/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from vibe.core.auth.crypto import EncryptedPayload, decrypt, encrypt +from vibe.core.auth.github import GitHubAuthProvider + +__all__ = ["EncryptedPayload", "GitHubAuthProvider", "decrypt", "encrypt"] diff --git a/vibe/core/auth/crypto.py b/vibe/core/auth/crypto.py new file mode 100644 index 0000000..fd41627 --- /dev/null +++ b/vibe/core/auth/crypto.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import base64 +import binascii +from dataclasses import dataclass +import os + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +_AES_KEY_SIZE = 32 +_NONCE_SIZE = 12 +_MIN_RSA_KEY_SIZE = 2048 +_MAX_ENCRYPTED_KEY_SIZE = 1024 +_MAX_CIPHERTEXT_SIZE = 2 * 1024 * 1024 # Workflow transport limit: 2MB +_PAYLOAD_VERSION = 1 +_ALG = "RSA-OAEP-SHA256" +_ENC = "A256GCM" + + +@dataclass(frozen=True) +class EncryptedPayload: + encrypted_key: str + nonce: str + ciphertext: str + version: int | None = None + alg: str | None = None + enc: str | None = None + kid: str | None = None + purpose: str | None = None + + +def _b64decode_strict(value: str, field_name: str) -> bytes: + try: + return base64.b64decode(value, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError(f"Invalid base64 for {field_name}") from exc + + +def _validate_payload_lengths( + encrypted_key: bytes, nonce: bytes, ciphertext: bytes +) -> None: + if len(encrypted_key) > _MAX_ENCRYPTED_KEY_SIZE: + raise ValueError("Encrypted key too large") + if len(nonce) != _NONCE_SIZE: + raise ValueError("Invalid nonce size") + if not ciphertext: + raise ValueError("Ciphertext is empty") + if len(ciphertext) > _MAX_CIPHERTEXT_SIZE: + raise ValueError("Ciphertext exceeds maximum allowed size") + + +def _build_aad(payload: EncryptedPayload) -> bytes | None: + if payload.version is None or payload.version <= 0: + return None + alg = payload.alg or _ALG + enc = payload.enc or _ENC + parts = [f"v={payload.version}", f"alg={alg}", f"enc={enc}"] + if payload.kid: + parts.append(f"kid={payload.kid}") + if payload.purpose: + parts.append(f"purpose={payload.purpose}") + return "|".join(parts).encode("utf-8") + + +def encrypt(plaintext: str, public_key_pem: bytes) -> EncryptedPayload: + public_key = serialization.load_pem_public_key(public_key_pem) + if not isinstance(public_key, RSAPublicKey): + raise TypeError("Expected RSA public key") + + if public_key.key_size < _MIN_RSA_KEY_SIZE: + raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits") + + aes_key = os.urandom(_AES_KEY_SIZE) + nonce = os.urandom(_NONCE_SIZE) + + payload = EncryptedPayload( + encrypted_key="", + nonce="", + ciphertext="", + version=_PAYLOAD_VERSION, + alg=_ALG, + enc=_ENC, + ) + aad = _build_aad(payload) + aesgcm = AESGCM(aes_key) + ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), aad) + + encrypted_key = public_key.encrypt( + aes_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + + return EncryptedPayload( + encrypted_key=base64.b64encode(encrypted_key).decode("ascii"), + nonce=base64.b64encode(nonce).decode("ascii"), + ciphertext=base64.b64encode(ciphertext).decode("ascii"), + version=payload.version, + alg=payload.alg, + enc=payload.enc, + ) + + +def decrypt(payload: EncryptedPayload, private_key_pem: bytes) -> str: + private_key = serialization.load_pem_private_key(private_key_pem, password=None) + if not isinstance(private_key, RSAPrivateKey): + raise TypeError("Expected RSA private key") + + if private_key.key_size < _MIN_RSA_KEY_SIZE: + raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits") + + encrypted_key = _b64decode_strict(payload.encrypted_key, "encrypted_key") + nonce = _b64decode_strict(payload.nonce, "nonce") + ciphertext = _b64decode_strict(payload.ciphertext, "ciphertext") + _validate_payload_lengths(encrypted_key, nonce, ciphertext) + + aes_key = private_key.decrypt( + encrypted_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + + if len(aes_key) != _AES_KEY_SIZE: + raise ValueError("Invalid AES key size after decryption") + + aesgcm = AESGCM(aes_key) + aad = _build_aad(payload) + return aesgcm.decrypt(nonce, ciphertext, aad).decode("utf-8") diff --git a/vibe/core/auth/github.py b/vibe/core/auth/github.py new file mode 100644 index 0000000..3ba4370 --- /dev/null +++ b/vibe/core/auth/github.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import types +import webbrowser + +import httpx +import keyring +import keyring.errors + +GITHUB_CLIENT_ID = "Ov23liJ7sk5kFDMEyvDT" + +_SERVICE_NAME = "vibe" +_KEYRING_USERNAME = "github_token" +_DEVICE_CODE_URL = "https://github.com/login/device/code" +_TOKEN_URL = "https://github.com/login/oauth/access_token" +_VALIDATE_URL = "https://api.github.com/user" +_SCOPES = "repo read:org write:org workflow read:user user:email" + + +class GitHubAuthError(Exception): + pass + + +@dataclass +class DeviceFlowInfo: + user_code: str + verification_uri: str + + +@dataclass +class DeviceFlowHandle: + device_code: str + expires_in: int + info: DeviceFlowInfo + + +class GitHubAuthProvider: + def __init__( + self, + client_id: str = GITHUB_CLIENT_ID, + *, + client: httpx.AsyncClient | None = None, + timeout: float = 60.0, + ) -> None: + self._client_id = client_id + self._client = client + self._owns_client = client is None + self._timeout = timeout + + async def __aenter__(self) -> GitHubAuthProvider: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + if self._owns_client and self._client: + await self._client.aclose() + self._client = None + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + self._owns_client = True + return self._client + + def get_token(self) -> str | None: + try: + return keyring.get_password(_SERVICE_NAME, _KEYRING_USERNAME) + except keyring.errors.KeyringError: + return None + + def has_token(self) -> bool: + return bool(self.get_token()) + + def delete_token(self) -> None: + try: + keyring.delete_password(_SERVICE_NAME, _KEYRING_USERNAME) + except keyring.errors.KeyringError: + pass + + async def get_valid_token(self) -> str | None: + token = self.get_token() + if not token: + return None + if await self._is_token_valid(token): + return token + self.delete_token() + return None + + async def _is_token_valid(self, token: str) -> bool: + client = self._get_client() + try: + response = await client.get( + _VALIDATE_URL, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + ) + return response.is_success + except httpx.HTTPError: + return False + + async def start_device_flow(self, open_browser: bool = True) -> DeviceFlowHandle: + client = self._get_client() + response = await client.post( + _DEVICE_CODE_URL, + data={"client_id": self._client_id, "scope": _SCOPES}, + headers={"Accept": "application/json"}, + ) + if not response.is_success: + raise GitHubAuthError(f"Failed to initiate device flow: {response.text}") + + data = response.json() + + if open_browser: + webbrowser.open(data["verification_uri"]) + + return DeviceFlowHandle( + device_code=data["device_code"], + expires_in=data["expires_in"], + info=DeviceFlowInfo(data["user_code"], data["verification_uri"]), + ) + + async def wait_for_token(self, handle: DeviceFlowHandle) -> str: + client = self._get_client() + token = await self._poll_for_token( + client, handle.device_code, handle.expires_in, interval=1 + ) + self._save_token(token) + return token + + def _save_token(self, token: str) -> None: + try: + keyring.set_password(_SERVICE_NAME, _KEYRING_USERNAME, token) + except keyring.errors.KeyringError as e: + raise GitHubAuthError(f"Failed to save token to keyring: {e}") from e + + async def _poll_for_token( + self, + client: httpx.AsyncClient, + device_code: str, + expires_in: int, + interval: int, + ) -> str: + elapsed = 0.0 + while elapsed < expires_in: + await asyncio.sleep(interval) + elapsed += interval + + response = await client.post( + _TOKEN_URL, + data={ + "client_id": self._client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + headers={"Accept": "application/json"}, + ) + result = response.json() + + if "access_token" in result: + return result["access_token"] + + error = result.get("error") + if error == "slow_down": + interval = result.get("interval", interval + 5) + elif error in {"expired_token", "access_denied"}: + raise GitHubAuthError(f"Authentication failed: {error}") + + raise GitHubAuthError("Authentication timed out") diff --git a/vibe/core/config.py b/vibe/core/config.py index 1a84d0d..c8c615e 100644 --- a/vibe/core/config.py +++ b/vibe/core/config.py @@ -34,7 +34,8 @@ def load_dotenv_values( env_path: Path = GLOBAL_ENV_FILE.path, environ: MutableMapping[str, str] = os.environ, ) -> None: - if not env_path.is_file(): + # We allow FIFO path to support some environment management solutions (e.g. https://developer.1password.com/docs/environments/local-env-file/) + if not env_path.is_file() and not env_path.is_fifo(): return env_vars = dotenv_values(env_path) @@ -260,11 +261,14 @@ class ModelConfig(BaseModel): return data +DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" + + DEFAULT_PROVIDERS = [ ProviderConfig( name="mistral", api_base="https://api.mistral.ai/v1", - api_key_env_var="MISTRAL_API_KEY", + api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY, backend=Backend.MISTRAL, ), ProviderConfig( @@ -301,9 +305,9 @@ DEFAULT_MODELS = [ class VibeConfig(BaseSettings): active_model: str = "devstral-2" - textual_theme: str = "terminal" vim_keybindings: bool = False disable_welcome_banner_animation: bool = False + autocopy_to_clipboard: bool = True displayed_workdir: str = "" auto_compact_threshold: int = 200_000 context_warnings: bool = False @@ -316,6 +320,14 @@ class VibeConfig(BaseSettings): enable_update_checks: bool = True enable_auto_update: bool = True api_timeout: float = 720.0 + + # TODO(vibe-nuage): remove exclude=True once the feature is publicly available + nuage_enabled: bool = Field(default=False, exclude=True) + nuage_base_url: str = Field(default="https://api.globalaegis.net", exclude=True) + nuage_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True) + # TODO(vibe-nuage): change default value to MISTRAL_API_KEY once prod has shared vibe-nuage workers + nuage_api_key_env_var: str = Field(default="STAGING_MISTRAL_API_KEY", exclude=True) + providers: list[ProviderConfig] = Field( default_factory=lambda: list(DEFAULT_PROVIDERS) ) @@ -402,6 +414,10 @@ class VibeConfig(BaseSettings): env_prefix="VIBE_", case_sensitive=False, extra="ignore" ) + @property + def nuage_api_key(self) -> str: + return os.getenv(self.nuage_api_key_env_var, "") + @property def system_prompt(self) -> str: try: diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py index 3087f57..b6b7e5f 100644 --- a/vibe/core/session/session_logger.py +++ b/vibe/core/session/session_logger.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import getpass import json import os @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any from anyio import NamedTemporaryFile, Path as AsyncPath from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata -from vibe.core.utils import is_windows +from vibe.core.utils import is_windows, utc_now if TYPE_CHECKING: from vibe.core.agents.models import AgentProfile @@ -40,7 +40,7 @@ class SessionLogger: self.save_dir = Path(session_config.save_dir) self.session_prefix = session_config.session_prefix self.session_id = session_id - self.session_start_time = datetime.now().isoformat() + self.session_start_time = utc_now().isoformat() self.save_dir.mkdir(parents=True, exist_ok=True) self.session_dir = self.save_folder @@ -53,7 +53,7 @@ class SessionLogger: "Cannot get session save folder when logging is disabled" ) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = utc_now().strftime("%Y%m%d_%H%M%S") folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}" return self.save_dir / folder_name @@ -265,7 +265,7 @@ class SessionLogger: metadata_dump = { **self.session_metadata.model_dump(), - "end_time": datetime.now().isoformat(), + "end_time": utc_now().isoformat(), "stats": stats.model_dump(), "title": title, "total_messages": total_messages, @@ -292,7 +292,7 @@ class SessionLogger: return self.session_id = session_id - self.session_start_time = datetime.now().isoformat() + self.session_start_time = utc_now().isoformat() self.session_dir = self.save_folder self.session_metadata = self._initialize_session_metadata() @@ -301,7 +301,7 @@ class SessionLogger: if not self.enabled or not self.save_dir: return - now = datetime.now() + now = utc_now() ago = now - timedelta(minutes=5) tmp_files = self.save_dir.glob("**/*.json.tmp") # Recursive search @@ -309,7 +309,9 @@ class SessionLogger: for file_path in tmp_files: if file_path.is_file(): try: - file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime) + file_mtime = datetime.fromtimestamp( + file_path.stat().st_mtime, tz=UTC + ) if file_mtime < ago: file_path.unlink() except Exception: diff --git a/vibe/core/teleport/errors.py b/vibe/core/teleport/errors.py new file mode 100644 index 0000000..a64f4ea --- /dev/null +++ b/vibe/core/teleport/errors.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class ServiceTeleportError(Exception): + """Base exception for teleport errors.""" + + +class ServiceTeleportNotSupportedError(ServiceTeleportError): + """Raised when teleport is not supported in current environment.""" diff --git a/vibe/core/teleport/git.py b/vibe/core/teleport/git.py new file mode 100644 index 0000000..a11159c --- /dev/null +++ b/vibe/core/teleport/git.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from git import InvalidGitRepositoryError, Repo +from git.exc import GitCommandError +from giturlparse import parse as parse_git_url + +from vibe.core.teleport.errors import ( + ServiceTeleportError, + ServiceTeleportNotSupportedError, +) +from vibe.core.utils import AsyncExecutor + + +@dataclass +class GitRepoInfo: + remote_url: str + owner: str + repo: str + branch: str | None + commit: str + diff: str + + +class GitRepository: + def __init__(self, workdir: Path | None = None) -> None: + self._workdir = workdir or Path.cwd() + self._repo: Repo | None = None + # For network I/O (fetch, push) and potentially slow git commands (diff, rev-list) + self._executor = AsyncExecutor(max_workers=2, timeout=60.0, name="git") + + async def __aenter__(self) -> GitRepository: + return self + + async def __aexit__(self, *_: object) -> None: + self._executor.shutdown(wait=False) + + async def is_supported(self) -> bool: + try: + repo = self._repo_or_raise() + except ServiceTeleportNotSupportedError: + return False + return self._find_github_remote(repo) is not None + + async def get_info(self) -> GitRepoInfo: + repo = self._repo_or_raise() + + parsed = self._find_github_remote(repo) + if not parsed: + raise ServiceTeleportNotSupportedError( + "No GitHub remote found. Teleport only supports GitHub repositories." + ) + + try: + commit = repo.head.commit.hexsha + except (ValueError, TypeError) as e: + raise ServiceTeleportNotSupportedError( + "Could not determine current commit" + ) from e + + if not commit: + raise ServiceTeleportNotSupportedError("Could not determine current commit") + + owner, repo_name = parsed + branch = None if repo.head.is_detached else repo.active_branch.name + diff = await self._get_diff(repo) + + return GitRepoInfo( + remote_url=self._to_https_url(owner, repo_name), + owner=owner, + repo=repo_name, + branch=branch, + commit=commit, + diff=diff, + ) + + async def is_commit_pushed(self, commit: str, remote: str = "origin") -> bool: + repo = self._repo_or_raise() + await self._fetch(repo, remote) + return await self._branch_contains(repo, commit, remote) + + async def get_unpushed_commit_count(self, remote: str = "origin") -> int: + repo = self._repo_or_raise() + + if repo.head.is_detached: + raise ServiceTeleportError( + "Cannot count unpushed commits: no current branch" + ) + branch = repo.active_branch.name + + await self._fetch(repo, remote) + + result = await self._rev_list_count(repo, f"{remote}/{branch}..HEAD") + if result is not None: + return result + + # Fallback: branch not pushed yet, count commits from default branch + default_branch = await self._get_remote_default_branch(repo, remote) + if default_branch: + result = await self._rev_list_count(repo, f"{default_branch}..HEAD") + if result is not None: + return result + + raise ServiceTeleportError(f"Failed to count unpushed commits for {branch}") + + async def push_current_branch(self, remote: str = "origin") -> bool: + repo = self._repo_or_raise() + if repo.head.is_detached: + return False + return await self._push(repo, repo.active_branch.name, remote) + + def _repo_or_raise(self) -> Repo: + if self._repo is None: + try: + self._repo = Repo(self._workdir, search_parent_directories=True) + except InvalidGitRepositoryError as e: + raise ServiceTeleportNotSupportedError("Not a git repository") from e + return self._repo + + def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None: + for remote in repo.remotes: + for url in remote.urls: + if parsed := self._parse_github_url(url): + return parsed + return None + + async def _fetch(self, repo: Repo, remote: str) -> None: + try: + await self._executor.run(lambda: repo.remote(remote).fetch()) + except (TimeoutError, ValueError, GitCommandError): + pass + + async def _get_diff(self, repo: Repo) -> str: + def get_full_diff() -> str: + # Mark untracked files as intent-to-add so they appear in diff + repo.git.add("-N", ".") + return repo.git.diff("HEAD", binary=True) + + try: + return await self._executor.run(get_full_diff) + except (TimeoutError, GitCommandError): + return "" + + async def _branch_contains(self, repo: Repo, commit: str, remote: str) -> bool: + try: + out = await self._executor.run( + lambda: repo.git.branch("-r", "--contains", commit) + ) + return any(ln.strip().startswith(f"{remote}/") for ln in out.splitlines()) + except (TimeoutError, GitCommandError): + return False + + async def _rev_list_count(self, repo: Repo, ref_range: str) -> int | None: + try: + out = await self._executor.run( + lambda: repo.git.rev_list("--count", ref_range) + ) + return int(out) + except (TimeoutError, GitCommandError, ValueError): + return None + + async def _ref_exists(self, repo: Repo, ref: str) -> bool: + try: + await self._executor.run(lambda: repo.git.rev_parse("--verify", ref)) + return True + except (TimeoutError, GitCommandError): + return False + + async def _get_remote_default_branch(self, repo: Repo, remote: str) -> str | None: + try: + ref = repo.remotes[remote].refs.HEAD.reference.name + if await self._ref_exists(repo, ref): + return ref + except (KeyError, IndexError, TypeError, AttributeError): + pass + return None + + async def _push(self, repo: Repo, branch: str, remote: str) -> bool: + try: + await self._executor.run(lambda: repo.remote(remote).push(branch)) + return True + except (TimeoutError, ValueError, GitCommandError): + return False + + @staticmethod + def _parse_github_url(url: str) -> tuple[str, str] | None: + p = parse_git_url(url) + if p.github and p.owner and p.repo: + return p.owner, p.repo + return None + + @staticmethod + def _to_https_url(owner: str, repo: str) -> str: + return f"https://github.com/{owner}/{repo}.git" diff --git a/vibe/core/teleport/nuage.py b/vibe/core/teleport/nuage.py new file mode 100644 index 0000000..7bae56a --- /dev/null +++ b/vibe/core/teleport/nuage.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from dataclasses import asdict +import types +from typing import Any + +import httpx +from pydantic import BaseModel, Field + +from vibe.core.auth import EncryptedPayload, encrypt +from vibe.core.teleport.errors import ServiceTeleportError + + +class GitRepoConfig(BaseModel): + url: str + branch: str | None = None + commit: str | None = None + + +class VibeSandboxConfig(BaseModel): + git_repo: GitRepoConfig | None = None + + +class VibeNewSandbox(BaseModel): + type: str = "new" + config: VibeSandboxConfig = Field(default_factory=VibeSandboxConfig) + teleported_diffs: bytes | None = None + + +class TeleportSession(BaseModel): + metadata: dict[str, Any] = Field(default_factory=dict) + messages: list[dict[str, Any]] = Field(default_factory=list) + + +class WorkflowParams(BaseModel): + prompt: str + sandbox: VibeNewSandbox + session: TeleportSession | None = None + + +class WorkflowExecuteResponse(BaseModel): + execution_id: str + + +class PublicKeyResult(BaseModel): + public_key: str + + +class QueryResponse(BaseModel): + result: PublicKeyResult + + +class CreateLeChatThreadInput(BaseModel): + encrypted_api_key: dict[str, str] + user_message: str + project_name: str | None = None + + +class CreateLeChatThreadOutput(BaseModel): + chat_url: str + + +class UpdateResponse(BaseModel): + result: CreateLeChatThreadOutput + + +class NuageClient: + def __init__( + self, + base_url: str, + api_key: str, + workflow_id: str, + *, + client: httpx.AsyncClient | None = None, + timeout: float = 60.0, + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._workflow_id = workflow_id + self._client = client + self._owns_client = client is None + self._timeout = timeout + + async def __aenter__(self) -> NuageClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + if self._owns_client and self._client: + await self._client.aclose() + self._client = None + + @property + def _http_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + self._owns_client = True + return self._client + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + async def start_workflow(self, params: WorkflowParams) -> str: + response = await self._http_client.post( + f"{self._base_url}/v1/workflows/{self._workflow_id}/execute", + headers=self._headers(), + json={"input": params.model_dump(mode="json")}, + ) + if not response.is_success: + error_msg = f"Nuage workflow trigger failed: {response.text}" + # TODO(vibe-nuage): remove this once prod has shared vibe-nuage workers + if "Unauthorized" in response.text or "unauthorized" in response.text: + error_msg += ( + "\n\nHint: This version uses Mistral staging environment. " + "Set STAGING_MISTRAL_API_KEY from https://console.globalaegis.net/" + ) + raise ServiceTeleportError(error_msg) + result = WorkflowExecuteResponse.model_validate(response.json()) + return result.execution_id + + async def send_github_token(self, execution_id: str, token: str) -> None: + public_key_pem = await self._query_public_key(execution_id) + encrypted = encrypt(token, public_key_pem) + await self._signal_encrypted_token(execution_id, encrypted) + + async def _query_public_key(self, execution_id: str) -> bytes: + response = await self._http_client.post( + f"{self._base_url}/v1/workflows/executions/{execution_id}/queries", + headers=self._headers(), + json={"name": "get_public_key", "input": {}}, + ) + if not response.is_success: + raise ServiceTeleportError(f"Failed to get public key: {response.text}") + + result = QueryResponse.model_validate(response.json()) + return result.result.public_key.encode("utf-8") + + async def _signal_encrypted_token( + self, execution_id: str, encrypted: EncryptedPayload + ) -> None: + response = await self._http_client.post( + f"{self._base_url}/v1/workflows/executions/{execution_id}/signals", + headers=self._headers(), + json={"name": "github_token", "input": {"payload": asdict(encrypted)}}, + ) + if not response.is_success: + raise ServiceTeleportError(f"Failed to send GitHub token: {response.text}") + + async def create_le_chat_thread( + self, execution_id: str, user_message: str, project_name: str | None = None + ) -> str: + public_key_pem = await self._query_public_key(execution_id) + encrypted = encrypt(self._api_key, public_key_pem) + input_data = CreateLeChatThreadInput( + encrypted_api_key={ + k: v for k, v in asdict(encrypted).items() if v is not None + }, + user_message=user_message, + project_name=project_name, + ) + response = await self._http_client.post( + f"{self._base_url}/v1/workflows/executions/{execution_id}/updates", + headers=self._headers(), + json={"name": "create_le_chat_thread", "input": input_data.model_dump()}, + ) + if not response.is_success: + raise ServiceTeleportError( + f"Failed to create Le Chat thread: {response.text}" + ) + result = UpdateResponse.model_validate(response.json()) + return result.result.chat_url diff --git a/vibe/core/teleport/teleport.py b/vibe/core/teleport/teleport.py new file mode 100644 index 0000000..8da8538 --- /dev/null +++ b/vibe/core/teleport/teleport.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import base64 +from collections.abc import AsyncGenerator +from pathlib import Path +import types + +import httpx +import zstandard + +from vibe.core.auth.github import GitHubAuthProvider +from vibe.core.session.session_logger import SessionLogger +from vibe.core.teleport.errors import ServiceTeleportError +from vibe.core.teleport.git import GitRepoInfo, GitRepository +from vibe.core.teleport.nuage import ( + GitRepoConfig, + NuageClient, + TeleportSession, + VibeNewSandbox, + VibeSandboxConfig, + WorkflowParams, +) +from vibe.core.teleport.types import ( + TeleportAuthCompleteEvent, + TeleportAuthRequiredEvent, + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportPushResponseEvent, + TeleportSendEvent, + TeleportSendingGithubTokenEvent, + TeleportStartingWorkflowEvent, + TeleportYieldEvent, +) + +# TODO(vibe-nuage): update URL once prod has shared vibe-nuage workers +_NUAGE_EXECUTION_URL_TEMPLATE = "https://console.globalaegis.net/build/workflows/{workflow_id}?tab=executions&executionId={execution_id}" +_DEFAULT_TELEPORT_PROMPT = "please continue where you left off" + + +class TeleportService: + def __init__( + self, + session_logger: SessionLogger, + nuage_base_url: str, + nuage_workflow_id: str, + nuage_api_key: str, + workdir: Path | None = None, + *, + client: httpx.AsyncClient | None = None, + timeout: float = 60.0, + ) -> None: + self._session_logger = session_logger + self._nuage_base_url = nuage_base_url + self._nuage_workflow_id = nuage_workflow_id + self._nuage_api_key = nuage_api_key + self._git = GitRepository(workdir) + self._client = client + self._owns_client = client is None + self._timeout = timeout + self._github_auth: GitHubAuthProvider | None = None + self._nuage: NuageClient | None = None + + async def __aenter__(self) -> TeleportService: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + self._github_auth = GitHubAuthProvider(client=self._client) + self._nuage = NuageClient( + self._nuage_base_url, + self._nuage_api_key, + self._nuage_workflow_id, + client=self._client, + ) + await self._git.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + await self._git.__aexit__(exc_type, exc_val, exc_tb) + if self._owns_client and self._client: + await self._client.aclose() + self._client = None + + @property + def _http_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) + self._owns_client = True + return self._client + + @property + def _github_auth_provider(self) -> GitHubAuthProvider: + if self._github_auth is None: + self._github_auth = GitHubAuthProvider(client=self._http_client) + return self._github_auth + + @property + def _nuage_client(self) -> NuageClient: + if self._nuage is None: + self._nuage = NuageClient( + self._nuage_base_url, + self._nuage_api_key, + self._nuage_workflow_id, + client=self._http_client, + ) + return self._nuage + + async def check_supported(self) -> None: + await self._git.get_info() + + async def is_supported(self) -> bool: + return await self._git.is_supported() + + async def execute( + self, prompt: str | None, session: TeleportSession + ) -> AsyncGenerator[TeleportYieldEvent, TeleportSendEvent]: + prompt = prompt or _DEFAULT_TELEPORT_PROMPT + self._validate_config() + + git_info = await self._git.get_info() + + yield TeleportCheckingGitEvent() + if not await self._git.is_commit_pushed(git_info.commit): + unpushed_count = await self._git.get_unpushed_commit_count() + response = yield TeleportPushRequiredEvent( + unpushed_count=max(1, unpushed_count) + ) + if ( + not isinstance(response, TeleportPushResponseEvent) + or not response.approved + ): + raise ServiceTeleportError("Teleport cancelled: commit not pushed.") + + yield TeleportPushingEvent() + await self._push_or_fail() + + github_token = await self._github_auth_provider.get_valid_token() + + if not github_token: + handle = await self._github_auth_provider.start_device_flow( + open_browser=True + ) + yield TeleportAuthRequiredEvent( + user_code=handle.info.user_code, + verification_uri=handle.info.verification_uri, + ) + github_token = await self._github_auth_provider.wait_for_token(handle) + yield TeleportAuthCompleteEvent() + + yield TeleportStartingWorkflowEvent() + + execution_id = await self._nuage_client.start_workflow( + WorkflowParams( + prompt=prompt, sandbox=self._build_sandbox(git_info), session=session + ) + ) + + yield TeleportSendingGithubTokenEvent() + await self._nuage_client.send_github_token(execution_id, github_token) + + chat_url = _NUAGE_EXECUTION_URL_TEMPLATE.format( + workflow_id=self._nuage_workflow_id, execution_id=execution_id + ) + # chat_url = await nuage.create_le_chat_thread( + # execution_id=execution_id, user_message=prompt + # ) + + yield TeleportCompleteEvent(url=chat_url) + + async def _push_or_fail(self) -> None: + if not await self._git.push_current_branch(): + raise ServiceTeleportError("Failed to push current branch to remote.") + + def _validate_config(self) -> None: + # TODO(vibe-nuage): update error message once prod has shared vibe-nuage workers + if not self._nuage_api_key: + raise ServiceTeleportError( + "STAGING_MISTRAL_API_KEY not set. " + "Set it from https://console.globalaegis.net/ to use teleport." + ) + + def _build_sandbox(self, git_info: GitRepoInfo) -> VibeNewSandbox: + return VibeNewSandbox( + config=VibeSandboxConfig( + git_repo=GitRepoConfig( + url=git_info.remote_url, + branch=git_info.branch, + commit=git_info.commit, + ) + ), + teleported_diffs=self._compress_diff(git_info.diff or ""), + ) + + def _compress_diff(self, diff: str, max_size: int = 1_000_000) -> bytes | None: + if not diff: + return None + compressed = zstandard.ZstdCompressor().compress(diff.encode("utf-8")) + encoded = base64.b64encode(compressed) + if len(encoded) > max_size: + raise ServiceTeleportError( + "Diff too large to teleport. Please commit and push your changes first." + ) + return encoded diff --git a/vibe/core/teleport/types.py b/vibe/core/teleport/types.py new file mode 100644 index 0000000..842b04d --- /dev/null +++ b/vibe/core/teleport/types.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from vibe.core.types import BaseEvent + + +class TeleportAuthRequiredEvent(BaseEvent): + user_code: str + verification_uri: str + + +class TeleportAuthCompleteEvent(BaseEvent): + pass + + +class TeleportStartingWorkflowEvent(BaseEvent): + pass + + +class TeleportCheckingGitEvent(BaseEvent): + pass + + +class TeleportPushRequiredEvent(BaseEvent): + unpushed_count: int = 1 + + +class TeleportPushResponseEvent(BaseEvent): + approved: bool + + +class TeleportPushingEvent(BaseEvent): + pass + + +class TeleportSendingGithubTokenEvent(BaseEvent): + pass + + +class TeleportCompleteEvent(BaseEvent): + url: str + + +type TeleportYieldEvent = ( + TeleportAuthRequiredEvent + | TeleportAuthCompleteEvent + | TeleportCheckingGitEvent + | TeleportPushRequiredEvent + | TeleportPushingEvent + | TeleportStartingWorkflowEvent + | TeleportSendingGithubTokenEvent + | TeleportCompleteEvent +) + +type TeleportSendEvent = TeleportPushResponseEvent | None diff --git a/vibe/core/tools/builtins/ask_user_question.py b/vibe/core/tools/builtins/ask_user_question.py index 3e2f37d..accc26d 100644 --- a/vibe/core/tools/builtins/ask_user_question.py +++ b/vibe/core/tools/builtins/ask_user_question.py @@ -39,6 +39,9 @@ class Question(BaseModel): multi_select: bool = Field( default=False, description="If true, user can select multiple options" ) + hide_other: bool = Field( + default=False, description="If true, hide the 'Other' free text option" + ) class AskUserQuestionArgs(BaseModel): diff --git a/vibe/core/types.py b/vibe/core/types.py index ce26d05..2fd4cd7 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -5,7 +5,7 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable import copy from enum import StrEnum, auto -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal from uuid import uuid4 if TYPE_CHECKING: @@ -18,6 +18,7 @@ from pydantic import ( BeforeValidator, ConfigDict, Field, + PrivateAttr, computed_field, model_validator, ) @@ -33,7 +34,6 @@ class AgentStats(BaseModel): tool_calls_succeeded: int = 0 context_tokens: int = 0 - listeners: ClassVar[dict[str, Callable[[AgentStats], None]]] = {} last_turn_prompt_tokens: int = 0 last_turn_completion_tokens: int = 0 @@ -43,20 +43,23 @@ class AgentStats(BaseModel): input_price_per_million: float = 0.0 output_price_per_million: float = 0.0 + _listeners: dict[str, Callable[[AgentStats], None]] = PrivateAttr( + default_factory=dict + ) + def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) - if name in self.listeners: - self.listeners[name](self) + if name in self._listeners: + self._listeners[name](self) def trigger_listeners(self) -> None: - for listener in self.listeners.values(): + for listener in self._listeners.values(): listener(self) - @classmethod def add_listener( - cls, attr_name: str, listener: Callable[[AgentStats], None] + self, attr_name: str, listener: Callable[[AgentStats], None] ) -> None: - cls.listeners[attr_name] = listener + self._listeners[attr_name] = listener @computed_field @property diff --git a/vibe/core/utils.py b/vibe/core/utils.py index 97168c0..c5a1eaa 100644 --- a/vibe/core/utils.py +++ b/vibe/core/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine import concurrent.futures +from datetime import UTC, datetime from enum import Enum, auto from fnmatch import fnmatch import functools @@ -306,6 +307,37 @@ def name_matches(name: str, patterns: list[str]) -> bool: return False +class AsyncExecutor: + """Run sync functions in a thread pool with timeout. Supports async context manager.""" + + def __init__( + self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor" + ) -> None: + self._executor = concurrent.futures.ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix=name + ) + self._timeout = timeout + + async def __aenter__(self) -> AsyncExecutor: + return self + + async def __aexit__(self, *_: object) -> None: + self.shutdown(wait=False) + + async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + loop = asyncio.get_running_loop() + future = loop.run_in_executor( + self._executor, functools.partial(fn, *args, **kwargs) + ) + try: + return await asyncio.wait_for(future, timeout=self._timeout) + except TimeoutError as e: + raise TimeoutError(f"Operation timed out after {self._timeout}s") from e + + def shutdown(self, wait: bool = True) -> None: + self._executor.shutdown(wait=wait) + + def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str: if old_tokens is None or new_tokens is None: return "Compaction complete" @@ -316,3 +348,7 @@ def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> f"Compaction complete: {old_tokens:,} → " f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)" ) + + +def utc_now() -> datetime: + return datetime.now(UTC) diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py index 88d0ac8..befb224 100644 --- a/vibe/setup/onboarding/__init__.py +++ b/vibe/setup/onboarding/__init__.py @@ -4,34 +4,18 @@ import sys from rich import print as rprint from textual.app import App -from textual.theme import Theme -from vibe.cli.textual_ui.terminal_theme import ( - TERMINAL_THEME_NAME, - capture_terminal_theme, -) from vibe.core.paths.global_paths import GLOBAL_ENV_FILE -from vibe.setup.onboarding.screens import ( - ApiKeyScreen, - ThemeSelectionScreen, - WelcomeScreen, -) +from vibe.setup.onboarding.screens import ApiKeyScreen, WelcomeScreen class OnboardingApp(App[str | None]): CSS_PATH = "onboarding.tcss" - def __init__(self) -> None: - super().__init__() - self._terminal_theme: Theme | None = capture_terminal_theme() - def on_mount(self) -> None: - if self._terminal_theme: - self.register_theme(self._terminal_theme) - self.theme = TERMINAL_THEME_NAME + self.theme = "textual-ansi" self.install_screen(WelcomeScreen(), "welcome") - self.install_screen(ThemeSelectionScreen(), "theme_selection") self.install_screen(ApiKeyScreen(), "api_key") self.push_screen("welcome") diff --git a/vibe/setup/onboarding/onboarding.tcss b/vibe/setup/onboarding/onboarding.tcss index ec616bd..605f84f 100644 --- a/vibe/setup/onboarding/onboarding.tcss +++ b/vibe/setup/onboarding/onboarding.tcss @@ -27,7 +27,7 @@ OnboardingScreen { } WelcomeScreen #enter-hint { - color: $text-muted; + color: ansi_bright_black; min-width: 16; text-align: center; } @@ -36,102 +36,6 @@ WelcomeScreen #enter-hint.hidden { visibility: hidden; } -/* ============================================================================= - Theme Selection Screen - ============================================================================= */ - -#theme-outer { - width: 100%; - height: 100%; - align: center middle; - overflow-y: auto; -} - -#theme-content { - width: auto; - height: auto; - padding: 1 0; -} - -#theme-title { - text-align: center; - width: 100%; - margin-bottom: 2; -} - -#theme-row { - width: auto; - height: auto; -} - -#nav-hint { - color: $text-muted; - width: 16; - height: 100%; - content-align: right middle; - padding-right: 2; -} - -#theme-list { - width: 24; - height: auto; -} - -.theme-item { - text-align: center; - width: 100%; -} - -.theme-item.selected { - color: $text; - text-style: bold; - border: round #555555; - padding: 0 2; -} - -.theme-item.fade-1 { - text-opacity: 50%; -} - -.theme-item.fade-2 { - text-opacity: 25%; -} - -.theme-item.fade-3 { - text-opacity: 10%; -} - -ThemeSelectionScreen #enter-hint { - color: $text-muted; - width: 16; - height: 100%; - content-align: left middle; - padding-left: 2; -} - -#preview-center { - width: 100%; - height: auto; - margin-top: 2; - align: center middle; -} - -#preview { - min-width: 50; - max-width: 70; - height: auto; - max-height: 100%; - overflow: auto; - border: round #555555; - border-title-align: center; -} - -#preview-inner { - width: 100%; - height: auto; - padding: 0 1 0 1; -} - /* ============================================================================= API Key Screen ============================================================================= */ @@ -177,11 +81,11 @@ ThemeSelectionScreen #enter-hint { } #input-box.valid { - border: round $success; + border: round ansi_green; } #input-box.invalid { - border: round $error; + border: round ansi_red; } #key { @@ -202,11 +106,11 @@ ThemeSelectionScreen #enter-hint { } #feedback.error { - color: $error; + color: ansi_red; } #feedback.success { - color: $text-muted; + color: ansi_bright_black; } #config-docs-section { @@ -222,7 +126,7 @@ ThemeSelectionScreen #enter-hint { } #config-docs-group Static { - color: $text-muted; + color: ansi_bright_black; } #config-docs-group .link-row { diff --git a/vibe/setup/onboarding/screens/__init__.py b/vibe/setup/onboarding/screens/__init__.py index 76113dd..49931c2 100644 --- a/vibe/setup/onboarding/screens/__init__.py +++ b/vibe/setup/onboarding/screens/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations from vibe.setup.onboarding.screens.api_key import ApiKeyScreen -from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen from vibe.setup.onboarding.screens.welcome import WelcomeScreen -__all__ = ["ApiKeyScreen", "ThemeSelectionScreen", "WelcomeScreen"] +__all__ = ["ApiKeyScreen", "WelcomeScreen"] diff --git a/vibe/setup/onboarding/screens/theme_selection.py b/vibe/setup/onboarding/screens/theme_selection.py deleted file mode 100644 index fab2034..0000000 --- a/vibe/setup/onboarding/screens/theme_selection.py +++ /dev/null @@ -1,164 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar - -from textual.app import ComposeResult -from textual.binding import Binding, BindingType -from textual.containers import Center, Container, Horizontal, Vertical -from textual.events import Resize -from textual.theme import BUILTIN_THEMES -from textual.widgets import Markdown, Static - -from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME -from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.config import VibeConfig -from vibe.setup.onboarding.base import OnboardingScreen - -if TYPE_CHECKING: - from vibe.setup.onboarding import OnboardingApp - -THEMES = [TERMINAL_THEME_NAME] + sorted( - k for k in BUILTIN_THEMES if k != "textual-ansi" -) - -VISIBLE_NEIGHBORS = 3 -FADE_CLASSES = ["fade-1", "fade-2", "fade-3"] - -PREVIEW_MARKDOWN = """ -### Heading - -**Bold**, *italic*, and `inline code`. - -- Bullet point -- Another bullet point - -1. First item -2. Second item - -```python -def greet(name: str = "World") -> str: - return f"Hello, {name}!" -``` - -> Blockquote - ---- - -| Column 1 | Column 2 | -|----------|----------| -| Item 1 | Item 2 | -""" - - -class ThemeSelectionScreen(OnboardingScreen): - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("enter", "next", "Next", show=False, priority=True), - Binding("up", "prev_theme", "Previous", show=False), - Binding("down", "next_theme", "Next Theme", show=False), - Binding("ctrl+c", "cancel", "Cancel", show=False), - Binding("escape", "cancel", "Cancel", show=False), - ] - - NEXT_SCREEN = "api_key" - - def __init__(self) -> None: - super().__init__() - self._theme_index = 0 - self._theme_widgets: list[Static] = [] - - def _compose_theme_list(self) -> ComposeResult: - for _ in range(VISIBLE_NEIGHBORS * 2 + 1): - widget = NoMarkupStatic("", classes="theme-item") - self._theme_widgets.append(widget) - yield widget - - def compose(self) -> ComposeResult: - with Center(id="theme-outer"): - with Vertical(id="theme-content"): - yield NoMarkupStatic("Select your preferred theme", id="theme-title") - yield Center( - Horizontal( - NoMarkupStatic("Navigate ↑ ↓", id="nav-hint"), - Vertical(*self._compose_theme_list(), id="theme-list"), - NoMarkupStatic("Press Enter \u21b5", id="enter-hint"), - id="theme-row", - ) - ) - with Container(id="preview-center"): - preview = Container(id="preview") - preview.border_title = "Preview" - with preview: - yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner") - - @property - def _has_terminal_theme(self) -> bool: - app: OnboardingApp = self.app # type: ignore[assignment] - return app._terminal_theme is not None - - @property - def _available_themes(self) -> list[str]: - if self._has_terminal_theme: - return THEMES - return [t for t in THEMES if t != TERMINAL_THEME_NAME] - - def on_mount(self) -> None: - current_theme = self.app.theme - themes = self._available_themes - if current_theme == TERMINAL_THEME_NAME: - self._theme_index = 0 - elif current_theme in themes: - self._theme_index = themes.index(current_theme) - self._update_display() - self._update_preview_height() - self.focus() - - def on_resize(self, _: Resize) -> None: - self._update_preview_height() - - def _update_preview_height(self) -> None: - # Height is dynamically set because css won't allow filling available space and page scroll on overflow. - preview = self.query_one("#preview", Container) - header_height = 17 # title + margins + theme row + padding + buffer - available = self.app.size.height - header_height - preview.styles.max_height = max(10, available) - - def _get_theme_at_offset(self, offset: int) -> str: - themes = self._available_themes - index = (self._theme_index + offset) % len(themes) - return themes[index] - - def _update_display(self) -> None: - for i, widget in enumerate(self._theme_widgets): - offset = i - VISIBLE_NEIGHBORS - theme = self._get_theme_at_offset(offset) - - widget.remove_class("selected", *FADE_CLASSES) - - if offset == 0: - widget.update(f" {theme} ") - widget.add_class("selected") - else: - distance = min(abs(offset) - 1, len(FADE_CLASSES) - 1) - widget.update(theme) - widget.add_class(FADE_CLASSES[distance]) - - def _navigate(self, direction: int) -> None: - themes = self._available_themes - self._theme_index = (self._theme_index + direction) % len(themes) - theme = themes[self._theme_index] - self.app.theme = theme - self._update_display() - - def action_next_theme(self) -> None: - self._navigate(1) - - def action_prev_theme(self) -> None: - self._navigate(-1) - - def action_next(self) -> None: - theme = self._available_themes[self._theme_index] - try: - VibeConfig.save_updates({"textual_theme": theme}) - except OSError: - pass - super().action_next() diff --git a/vibe/setup/onboarding/screens/welcome.py b/vibe/setup/onboarding/screens/welcome.py index 6fcc7fe..2349537 100644 --- a/vibe/setup/onboarding/screens/welcome.py +++ b/vibe/setup/onboarding/screens/welcome.py @@ -50,7 +50,7 @@ class WelcomeScreen(OnboardingScreen): Binding("escape", "cancel", "Cancel", show=False), ] - NEXT_SCREEN = "theme_selection" + NEXT_SCREEN = "api_key" def __init__(self) -> None: super().__init__() diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py index 92f9ec5..86f3012 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.py +++ b/vibe/setup/trusted_folders/trust_folder_dialog.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -import tomllib from typing import Any, ClassVar from textual import events @@ -11,12 +10,8 @@ from textual.containers import CenterMiddle, Horizontal from textual.message import Message from textual.widgets import Static -from vibe.cli.textual_ui.terminal_theme import ( - TERMINAL_THEME_NAME, - capture_terminal_theme, -) from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE +from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE class TrustDialogQuitException(Exception): @@ -154,32 +149,9 @@ class TrustFolderApp(App): self.folder_path = folder_path self._result: bool | None = None self._quit_without_saving = False - self._terminal_theme = capture_terminal_theme() - self._load_theme() - def _load_theme(self) -> None: - if self._terminal_theme: - self.register_theme(self._terminal_theme) - - config_file = GLOBAL_CONFIG_FILE.path - if not config_file.is_file(): - return - - try: - with config_file.open("rb") as f: - config_data = tomllib.load(f) - except (OSError, tomllib.TOMLDecodeError): - return - - textual_theme = config_data.get("textual_theme") - if not textual_theme: - return - - if textual_theme == TERMINAL_THEME_NAME: - if self._terminal_theme: - self.theme = TERMINAL_THEME_NAME - else: - self.theme = textual_theme + def on_mount(self) -> None: + self.theme = "textual-ansi" def compose(self) -> ComposeResult: yield TrustFolderDialog(self.folder_path) diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.tcss b/vibe/setup/trusted_folders/trust_folder_dialog.tcss index 5e210d3..7b7ab40 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.tcss +++ b/vibe/setup/trusted_folders/trust_folder_dialog.tcss @@ -1,6 +1,6 @@ Screen { align: center middle; - background: $background 80%; + background: transparent 80%; } #trust-dialog { @@ -8,8 +8,8 @@ Screen { max-width: 90%; min-width: 50; height: auto; - border: round $foreground-muted; - background: $background; + border: round ansi_bright_black; + background: transparent; padding: 1; } @@ -17,7 +17,7 @@ Screen { width: 100%; height: auto; text-style: bold; - color: $warning; + color: ansi_yellow; text-align: center; margin-bottom: 1; } @@ -25,7 +25,7 @@ Screen { #trust-dialog-path { width: 100%; height: auto; - color: $primary; + color: ansi_blue; text-align: center; text-wrap: wrap; margin-bottom: 1; @@ -34,7 +34,7 @@ Screen { #trust-dialog-message { width: 100%; height: auto; - color: $foreground; + color: ansi_default; text-align: center; text-wrap: wrap; margin-bottom: 1; @@ -50,24 +50,24 @@ Screen { .trust-option { height: auto; width: auto; - color: $foreground; + color: ansi_default; margin: 0 3; content-align: center middle; } .trust-cursor-selected { - color: $foreground; + color: ansi_default; text-style: bold; } .trust-option-selected { - color: $foreground; + color: ansi_default; } .trust-dialog-help { width: 100%; height: auto; - color: $text-muted; + color: ansi_bright_black; text-align: center; margin-bottom: 1; } @@ -75,7 +75,7 @@ Screen { .trust-dialog-save-info { width: 100%; height: auto; - color: $text-muted; + color: ansi_bright_black; text-align: center; text-style: italic; text-wrap: wrap; diff --git a/vibe/whats_new.md b/vibe/whats_new.md index d38c32b..6c5a023 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,5 +1,9 @@ -# What's New +# What's New in 2.1.0 -- **Config:** Variables defined in the `.env` file in your global `.vibe` folder now override environment variables -- **Sessions:** Fixed message duplication in persisted sessions -- **Resume:** Only shown when a session is available to resume +- **UI redesign** — Refreshed interface with a cleaner layout and better performance when streaming. + +- **Themes removed** — Application themes have been removed; the UI now follows your terminal’s theme (colors and appearance). + +- **Clipboard** — You can now disable autocopying in /config, and copy with Ctrl+Shift+C or Cmd+C (in terminals that support it). + +- **Performance** — Various optimizations throughout the app for a more responsive UI.