diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c88eb1e..0f6b577 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,7 @@ # CODEOWNERS # Default owners for everything in the repo -* @mistralai/mistral-vibe +* @OEvortex/revibe # Owners for specific directories # Not needed for now, can be filled later diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 0268c3b..fb412a6 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -16,7 +16,7 @@ jobs: - name: Set matrix id: set-matrix run: | - if [[ "${{ github.repository }}" == "revibe-ai/revibe" ]]; then + if [[ "${{ github.repository }}" == "OEvortex/revibe" ]]; then matrix='{ "include": [ {"runner": "ubuntu-22.04", "os": "linux", "arch": "x86_64"}, @@ -117,7 +117,7 @@ jobs: chmod +x "$dir/revibe-acp" zip -j "release-assets/${name}.zip" "$dir/revibe-acp" elif [ -f "$dir/revibe-acp.exe" ]; then - zip -j "release-assets/${name}.zip" "$dir/vibe-acp.exe" + zip -j "release-assets/${name}.zip" "$dir/revibe-acp.exe" fi done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a57431..bda52df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,8 +73,8 @@ jobs: - name: Verify CLI can start run: | - uv run vibe --help - uv run vibe-acp --help + uv run revibe --help + uv run revibe-acp --help - name: Install ripgrep run: sudo apt-get update && sudo apt-get install -y ripgrep diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19b0617..6f38935 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/mistral-vibe + url: https://pypi.org/p/revibe permissions: id-token: write contents: read @@ -41,19 +41,19 @@ jobs: path: dist/ - name: Publish distribution to PyPI - if: github.repository == 'mistralai/mistral-vibe' + if: github.repository == 'OEvortex/revibe' uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 release-zed-extension: name: Release Zed Extension runs-on: ubuntu-latest - if: github.repository == 'mistralai/mistral-vibe' + if: github.repository == 'OEvortex/revibe' environment: name: zed steps: - uses: huacnlee/zed-extension-action@8cd592a0d24e1e41157740f1a529aeabddc88a1b # v2 with: - extension-name: mistral-vibe - push-to: mistralai/zed-extensions + extension-name: revibe + push-to: OEvortex/zed-extensions env: COMMITTER_TOKEN: ${{ secrets.ZED_EXTENSION_GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a339126..f023d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,23 +14,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `XMLToolFormatHandler` for robust parsing of XML tool calls and generation of XML tool results. - `supported_formats` field in `ModelConfig` and backend implementations to manage compatibility. - Dynamic tool prompt resolution in `BaseTool` allowing automatic fallback to standard prompts if XML version is missing. -- First public release of ReVibe with all core functionality +- First public release of ReVibe with all core functionality. +- New models added to Hugging Face provider. +- Animated "ReVibe" text logo in setup completion screen with gradient colors. +- Provider help URLs for all API key requiring providers (Hugging Face, Cerebras). ### Changed +- ReVibe configuration and data now saved in `.revibe` directory (migrated from `.vibe`). +- Setup TUI improvements: + - Skip API key input screen for providers that don't require API keys (ollama, llamacpp, qwencode) + - Display setup completion screen with "Press Enter to exit" instruction + - Hide configuration documentation links from completion screen + - Show usage message "Use 'revibe' to start using ReVibe" after setup completion - TUI Visual & Functional Enhancements: - Added `redact_xml_tool_calls(text)` utility in `revibe/core/utils.py` to remove raw `...` blocks from assistant output stream - Refactored `StreamingMessageBase` in `revibe/cli/textual_ui/widgets/messages.py` to track `_displayed_content` for smart UI updates - Enhanced premium tool summaries in chat history: - * Grep now shows as `Grep (pattern)` instead of `grep: 'pattern'` + * Find now shows as `Find (pattern)` instead of `grep: 'pattern'` * Bash now shows as `Bash (command)` instead of raw command string * Read File now shows as `Read (filename)` with cleaner summary * Write File now shows as `Write (filename)` * Search & Replace now shows as `Patch (filename)` - Applied redaction logic to `ReasoningMessage` in `revibe/cli/textual_ui/widgets/messages.py` to hide raw XML in reasoning blocks +- Model alias validation now allows same aliases for different providers while maintaining uniqueness within each provider. ### Fixed +- Duplicate model alias found in `VibeConfig` when multiple providers used same alias. +- AttributeError in `revibe --setup` caused by models loaded as dicts instead of ModelConfig objects. +- Type errors in config loading and provider handling. +- Various TUI bug fixes and stability improvements. - Case-sensitivity issue when specifying tool format via CLI. - Type errors in backends when implementing `BackendLike` protocol (added missing `supported_formats`). - Typo in `XMLToolFormatHandler` name property. diff --git a/README.md b/README.md index 8205413..b13fb57 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,146 @@ -# ReVibe +
-[![PyPI Version](https://img.shields.io/pypi/v/revibe)](https://pypi.org/project/revibe) -[![Python Version](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/release/python-3120/) -[![License](https://img.shields.io/github/license/OEvortex/revibe)](https://github.com/OEvortex/revibe/blob/main/LICENSE) +# 🌊 ReVibe **Multi-provider CLI coding agent with a clean, minimal interface.** -ReVibe is a command-line coding assistant powered by multiple language model providers. It provides a conversational interface to your codebase, allowing you to use natural language to explore, modify, and interact with your projects through a powerful set of tools. +[![PyPI Version](https://img.shields.io/pypi/v/revibe?style=flat-square&color=blue)](https://pypi.org/project/revibe) +[![Python Version](https://img.shields.io/badge/python-3.12%2B-blue?style=flat-square)](https://www.python.org/downloads/release/python-3120/) +[![License](https://img.shields.io/github/license/OEvortex/revibe?style=flat-square)](https://github.com/OEvortex/revibe/blob/main/LICENSE) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) -### ✨ Key Features +[Features](#-key-features) • [Installation](#-installation) • [Setup](#️-setup) • [Usage](#-usage) • [Configuration](#-configuration) -- **Multi-Provider Support**: OpenAI, Mistral, HuggingFace, Groq, and local models -- **Runtime Provider Switching**: Use `/provider` and `/model` commands to switch providers on the fly -- **Clean Minimal TUI**: Inspired by Codex CLI and Claude Code for distraction-free coding -- **Powerful Toolset**: File manipulation, code search, version control, and command execution +
-> [!NOTE] -> ReVibe works on Windows, macOS, and Linux. +--- -## Installation +ReVibe is a high-performance command-line coding assistant powered by a wide range of Large Language Models. It provides a conversational interface to your codebase, enabling you to explore, refactor, and build complex features through natural language and a robust set of autonomous tools. -### Using uv (recommended) +## ✨ Key Features +- 🚀 **Multi-Provider Ecosystem**: Support for OpenAI, Mistral, Qwen, Cerebras, Groq, HuggingFace, Ollama, and LlamaCPP. +- 🔄 **Hot-Swapping**: Switch providers and models instantly mid-session with `/provider` and `/model`. +- 🎨 **Modern TUI**: A polished, minimal interface inspired by leading AI coding tools for zero distraction. +- 🛠️ **Autonomous Toolset**: File system operations, advanced code search, git integration, and command execution. +- 🛡️ **Safe by Design**: Granular tool permissions with an interactive approval system. +- 🧩 **MCP Support**: Extend capabilities with Model Context Protocol servers. + +## 🚀 Installation + +### Using uv (Recommended) ```bash uv tool install revibe ``` ### Using pip - ```bash pip install revibe ``` -## Quick Start - -1. Navigate to your project directory: - - ```bash - cd /path/to/your/project - ``` - -2. Run ReVibe: - - ```bash - revibe - ``` - -3. On first run, ReVibe will: - - Create a default configuration at `~/.vibe/config.toml` - - Prompt you to enter your API key - - Save your API key to `~/.vibe/.env` - -4. Start coding with natural language! - -## Features - -- **Interactive Chat**: Conversational AI that understands your requests and breaks down complex tasks -- **Powerful Toolset**: File manipulation, code search, version control, and command execution -- **Project-Aware Context**: Automatic project structure and Git status scanning -- **Runtime Provider/Model Switching**: Use `/provider` and `/model` commands -- **Highly Configurable**: Customize via `config.toml` -- **Safety First**: Tool execution approval system - -## Usage - -### Interactive Mode - -Run `revibe` to start the interactive session. - -- **Multi-line Input**: `Ctrl+J` or `Shift+Enter` -- **File Paths**: Use `@` for autocompletion (e.g., `@src/main.py`) -- **Shell Commands**: Prefix with `!` (e.g., `!ls -l`) -- **Provider Switching**: Use `/provider` to switch providers -- **Model Selection**: Use `/model` to select models - +### From Source ```bash -revibe "Refactor the main function to be more modular." +git clone https://github.com/OEvortex/revibe.git +cd revibe +uv sync --all-extras +uv run revibe ``` -### Programmatic Mode +## 🛠️ Setup -Run non-interactively with `--prompt`: +### Quick Start +1. Navigate to your project directory. +2. Run `revibe` to start the onboarding process. +3. ReVibe will automatically create your configuration at `~/.revibe/config.toml` and prompt for necessary API keys. -```bash -revibe --prompt "Refactor the main function to be more modular." -``` - -### Slash Commands - -Use slash commands for configuration and control: - -- `/provider` - Switch between providers -- `/model` - Select a model -- `/config` - Edit settings -- `/help` - Show help -- `/clear` - Clear history -- `/status` - Show agent statistics +### 🔑 Authentication & Environment Variables -## Configuration +ReVibe manages API keys in `~/.revibe/.env`. You can also set them directly in your shell. -ReVibe is configured via `config.toml`. It looks for this file first in `./.vibe/config.toml` and then falls back to `~/.vibe/config.toml`. +| Provider | Environment Variable | Auth Method | +| :--- | :--- | :--- | +| **OpenAI** | `OPENAI_API_KEY` | API Key | +| **Mistral** | `MISTRAL_API_KEY` | API Key | +| **Groq** | `GROQ_API_KEY` | API Key | +| **Cerebras** | `CEREBRAS_API_KEY` | API Key | +| **Hugging Face** | `HUGGINGFACE_API_KEY` | API Key | +| **Qwen** | *None (Default)* | **OAuth** (via `/auth` in qwen CLI) | +| **Ollama** | *Not Required* | Local (Default: `http://localhost:11434`) | +| **Llama.cpp** | *Not Required* | Local (Default: `http://localhost:8080`) | -### API Key Configuration +> [!TIP] +> For Qwen, install qwen-code if not installed: `npm install -g @qwen-code/qwen-code@latest`, then use `/auth` in qwen to authenticate, then you can close qwen and use qwencode provider in ReVibe. -ReVibe supports multiple ways to configure API keys: +## 📖 Usage -1. **Interactive Setup**: On first run, ReVibe will prompt for API keys +### 💬 Interactive Mode +Simply run `revibe` to enter the interactive TUI. -2. **Environment Variables**: - ```bash - export OPENAI_API_KEY="your_key" - export ANTHROPIC_API_KEY="your_key" - export MISTRAL_API_KEY="your_key" - export GROQ_API_KEY="your_key" - ``` - -3. **`.env` File** in `~/.vibe/`: - ```bash - OPENAI_API_KEY=your_key - ANTHROPIC_API_KEY=your_key - ``` - -### Custom Agent Configurations - -Create agent configurations in `~/.vibe/agents/`: +* **Multi-line Input**: Use `Ctrl+J` or `Shift+Enter` for newlines. +* **File Referencing**: Type `@` to trigger fuzzy path autocompletion. +* **Direct Commands**: Prefix with `!` to execute shell commands (e.g., `!npm test`). +### 🤖 Programmatic Mode +Execute single prompts directly from your shell: ```bash -revibe --agent my_custom_agent +revibe --prompt "Explain the logic in @revibe/core/agent.py" ``` -Example custom agent configuration (`~/.vibe/agents/redteam.toml`): - -```toml -# Custom agent configuration for red-teaming -active_model = "devstral-2" -system_prompt_id = "redteam" - -# Disable some tools for this agent -disabled_tools = ["search_replace", "write_file"] - -# Override tool permissions for this agent -[tools.bash] -permission = "always" - -[tools.read_file] -permission = "always" -``` +### ⚡ Slash Commands +| Command | Action | +| :--- | :--- | +| `/provider` | Switch the active LLM provider | +| `/model` | Change the model for the current provider | +| `/config` | Open configuration settings | +| `/status` | View session stats and token usage | +| `/clear` | Reset conversation context | +| `/exit` | Terminate the session | -Note: this implies that you have setup a redteam prompt names `~/.vibe/prompts/redteam.md` +## ⚙️ Configuration -### MCP Server Configuration +ReVibe uses TOML for configuration. It checks `./.revibe/config.toml` first, then falls back to `~/.revibe/config.toml`. -You can configure MCP (Model Context Protocol) servers to extend ReVibe's capabilities: +
+Example MCP Configuration ```toml -# Example MCP server configurations -[[mcp_servers]] -name = "my_http_server" -transport = "http" -url = "http://localhost:8000" -headers = { "Authorization" = "Bearer my_token" } -api_key_env = "MY_API_KEY_ENV_VAR" -api_key_header = "Authorization" -api_key_format = "Bearer {token}" - -[[mcp_servers]] -name = "my_streamable_server" -transport = "streamable-http" -url = "http://localhost:8001" -headers = { "X-API-Key" = "my_api_key" } - [[mcp_servers]] name = "fetch_server" transport = "stdio" command = "uvx" args = ["mcp-server-fetch"] -``` - -Supported transports: - -- `http`: Standard HTTP transport -- `streamable-http`: HTTP transport with streaming support -- `stdio`: Standard input/output transport (for local processes) - -Key fields: -- `name`: A short alias for the server (used in tool names) -- `transport`: The transport type -- `url`: Base URL for HTTP transports -- `headers`: Additional HTTP headers -- `api_key_env`: Environment variable containing the API key -- `command`: Command to run for stdio transport -- `args`: Additional arguments for stdio transport - -MCP tools are named using the pattern `{server_name}_{tool_name}` and can be configured with permissions like built-in tools: - -```toml -# Configure permissions for specific MCP tools -[tools.fetch_server_get] -permission = "always" - -[tools.my_http_server_query] -permission = "ask" +[[mcp_servers]] +name = "github" +transport = "http" +url = "https://mcp-github-server.com" +api_key_env = "GITHUB_TOKEN" ``` +
-### Enable/disable tools with patterns - -You can control which tools are active using `enabled_tools` and `disabled_tools`. -These fields support exact names, glob patterns, and regular expressions. - -Examples: +
+Customizing Agent Behavior +You can create specialized agents in `~/.revibe/agents/my_agent.toml`: ```toml -# Only enable tools that start with "serena_" (glob) -enabled_tools = ["serena_*"] - -# Regex (prefix with re:) — matches full tool name (case-insensitive) -enabled_tools = ["re:^serena_.*$"] - -# Heuristic regex support (patterns like `serena.*` are treated as regex) -enabled_tools = ["serena.*"] - -# Disable a group with glob; everything else stays enabled -disabled_tools = ["mcp_*", "grep"] -``` - -Notes: - -- MCP tool names use underscores, e.g., `serena_list` not `serena.list`. -- Regex patterns are matched against the full tool name using fullmatch. - -### Custom Home Directory +active_model = "gpt-4o" +system_prompt_id = "architect" +disabled_tools = ["bash"] -By default, ReVibe stores configuration in `~/.vibe/`. Override with `VIBE_HOME`: - -```bash -export VIBE_HOME="/path/to/custom/home" +[tools.read_file] +permission = "always" ``` +Launch with `revibe --agent my_agent`. +
-## Editors/IDEs +## 🖥️ Editor Integration +ReVibe supports the **Agent Client Protocol (ACP)**, allowing it to act as a backend for compatible editors like Zed. See [ACP Setup](docs/acp-setup.md) for instructions. -ReVibe can be used in editors supporting [Agent Client Protocol](https://agentclientprotocol.com/overview/clients). See [ACP Setup](docs/acp-setup.md) for details. - -## Resources - -- [CHANGELOG](CHANGELOG.md) -- [CONTRIBUTING](CONTRIBUTING.md) +## 📄 License +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details. -## License +--- -Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details. +
+Made with ❤️ by the ReVibe Contributors +
diff --git a/chnageimade.md b/chnageimade.md deleted file mode 100644 index 108a889..0000000 --- a/chnageimade.md +++ /dev/null @@ -1,30 +0,0 @@ -# TUI Visual & Functional Enhancements - -I have implemented several key changes to the TUI to improve the visual experience and support the new XML-based tool calling mode. - -## 1. XML Tool Call Redaction -- **File**: `revibe/core/utils.py` -- **Change**: Added `redact_xml_tool_calls(text)` utility. -- **Purpose**: This function detects and removes raw `...` blocks from the assistant's output stream. It supports partially written tags, ensuring that raw XML never "flickers" on screen during streaming. - -## 2. Streaming UI Refresh -- **File**: `revibe/cli/textual_ui/widgets/messages.py` -- **Change**: Refactored `StreamingMessageBase` to track `_displayed_content`. -- **Purpose**: Allows the UI to smart-update only when visible content changes. If a tool call block starts in the stream, the UI detects the decrease in "visible" characters (due to redaction) and resets the stream to prevent showing fragments of XML. - -## 3. Premium Tool Summaries -I updated the display logic for all built-in tools to provide a cleaner, more premium aesthetic in the chat history: - -- **Grep**: Now shows as `Grep (pattern)` instead of `grep: 'pattern'`. -- **Bash**: Now shows as `Bash (command)` instead of a raw command string. -- **Read File**: Now shows as `Read (filename)` with a cleaner summary. -- **Write File**: Now shows as `Write (filename)`. -- **Search & Replace**: Now shows as `Patch (filename)`. - -## 4. Reasoning Integration -- **File**: `revibe/cli/textual_ui/widgets/messages.py` -- **Change**: Applied the same redaction logic to `ReasoningMessage`. -- **Purpose**: Ensures that even if the model starts thinking about tool calls in its reasoning block, the raw tags remain hidden from the user. - ---- -*Created on 2025-12-30 following TUI Aesthetic overhaul.* diff --git a/image.png b/image.png deleted file mode 100644 index 8881781..0000000 Binary files a/image.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 22906b9..c9a6131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,10 +50,10 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/revibe-ai/revibe" -Repository = "https://github.com/revibe-ai/revibe" -Issues = "https://github.com/revibe-ai/revibe/issues" -Documentation = "https://github.com/revibe-ai/revibe#readme" +Homepage = "https://github.com/OEvortex/revibe" +Repository = "https://github.com/OEvortex/revibe" +Issues = "https://github.com/OEvortex/revibe/issues" +Documentation = "https://github.com/OEvortex/revibe#readme" [build-system] diff --git a/revibe-acp.spec b/revibe-acp.spec index 4c52fa1..e0579f4 100644 --- a/revibe-acp.spec +++ b/revibe-acp.spec @@ -5,16 +5,15 @@ a = Analysis( ['revibe/acp/entrypoint.py'], pathex=[], binaries=[], - datas=[ - # By default, pyinstaller doesn't include the .md files - ('revibe/core/prompts/*.md', 'revibe/core/prompts'), - ('vibe/core/tools/builtins/prompts/*.md', 'vibe/core/tools/builtins/prompts'), - # We also need to add all setup files - ('vibe/setup/*', 'vibe/setup'), - # This is necessary because tools are dynamically called in vibe, meaning there is no static reference to those files - ('vibe/core/tools/builtins/*.py', 'vibe/core/tools/builtins'), - ('vibe/acp/tools/builtins/*.py', 'vibe/acp/tools/builtins'), - ], +datas=[ + ('revibe/core/prompts/*.md', 'revibe/core/prompts'), + ('revibe/core/tools/builtins/prompts/*.md', 'revibe/core/tools/builtins/prompts'), + ('revibe/setup/onboarding/app.tcss', 'revibe/setup/onboarding'), + ('revibe/setup/*', 'revibe/setup'), + # This is necessary because tools are dynamically called in revibe, meaning there is no static reference to those files + ('revibe/core/tools/builtins/*.py', 'revibe/core/tools/builtins'), + ('revibe/acp/tools/builtins/*.py', 'revibe/acp/tools/builtins'), +], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/revibe/acp/acp_agent.py b/revibe/acp/acp_agent.py index 36776ee..59374a5 100644 --- a/revibe/acp/acp_agent.py +++ b/revibe/acp/acp_agent.py @@ -118,14 +118,14 @@ async def initialize(self, params: InitializeRequest) -> InitializeResponse: auth_methods = ( [ AuthMethod( - id="vibe-setup", + id="revibe-setup", name="Register your API Key", - description="Register your API Key inside Mistral Vibe", + description="Register your API Key inside ReVibe", field_meta={ "terminal-auth": { "command": command, "args": args, - "label": "Mistral Vibe Setup", + "label": "ReVibe Setup", } }, ) @@ -143,8 +143,8 @@ async def initialize(self, params: InitializeRequest) -> InitializeResponse: ), protocolVersion=PROTOCOL_VERSION, agentInfo=Implementation( - name="@mistralai/mistral-vibe", - title="Mistral Vibe", + name="@OEvortex/revibe", + title="ReVibe", version=__version__, ), authMethods=auth_methods, diff --git a/revibe/cli/entrypoint.py b/revibe/cli/entrypoint.py index a4c66f3..eb62479 100644 --- a/revibe/cli/entrypoint.py +++ b/revibe/cli/entrypoint.py @@ -83,7 +83,7 @@ def parse_arguments() -> argparse.Namespace: "--agent", metavar="NAME", default=None, - help="Load agent configuration from ~/.vibe/agents/NAME.toml", + help="Load agent configuration from ~/.revibe/agents/NAME.toml", ) parser.add_argument( "--setup", @@ -119,7 +119,7 @@ def parse_arguments() -> argparse.Namespace: def check_and_resolve_trusted_folder() -> None: cwd = Path.cwd() - if not (cwd / ".vibe").exists() or cwd.resolve() == Path.home().resolve(): + if not (cwd / ".revibe").exists() or cwd.resolve() == Path.home().resolve(): return is_folder_trusted = trusted_folders_manager.is_trusted(cwd) diff --git a/revibe/cli/textual_ui/app.py b/revibe/cli/textual_ui/app.py index d4d1c2a..4bdc25a 100644 --- a/revibe/cli/textual_ui/app.py +++ b/revibe/cli/textual_ui/app.py @@ -928,8 +928,7 @@ async def _reload_config(self) -> None: display_context = self.config.auto_compact_threshold self._context_progress.tokens = TokenState( - max_tokens=display_context, - current_tokens=current_tokens, + max_tokens=display_context, current_tokens=current_tokens ) else: self._context_progress.tokens = TokenState() @@ -1277,54 +1276,30 @@ def _focus_current_bottom_app(self) -> None: except Exception: pass - def action_interrupt(self) -> None: - current_time = time.monotonic() - - if self._current_bottom_app == BottomApp.Config: - try: - config_app = self.query_one(ConfigApp) - config_app.action_close() - self._last_escape_time = None - return - except Exception: - pass - - if self._current_bottom_app == BottomApp.Approval: - try: - approval_app = self.query_one(ApprovalApp) - approval_app.action_reject() - self._last_escape_time = None - return - except Exception: - pass - - if self._current_bottom_app == BottomApp.ApiKeyInput: - try: - api_key_input = self.query_one(ApiKeyInput) - api_key_input.action_cancel() - self._last_escape_time = None - return - except Exception: - pass - - if self._current_bottom_app == BottomApp.Model: + def _handle_bottom_app_interrupt(self) -> bool: + """Handle interrupt for bottom apps. Returns True if handled.""" + handlers = { + BottomApp.Config: (ConfigApp, "action_close"), + BottomApp.Approval: (ApprovalApp, "action_reject"), + BottomApp.ApiKeyInput: (ApiKeyInput, "action_cancel"), + BottomApp.Model: (ModelSelector, "action_close"), + BottomApp.Provider: (ProviderSelector, "action_close"), + } + + handler = handlers.get(self._current_bottom_app) + if handler: try: - model_selector = self.query_one(ModelSelector) - model_selector.action_close() + widget_class, action = handler + widget = self.query_one(widget_class) + getattr(widget, action)() self._last_escape_time = None - return - except Exception: - pass - - if self._current_bottom_app == BottomApp.Provider: - try: - provider_selector = self.query_one(ProviderSelector) - provider_selector.action_close() - self._last_escape_time = None - return + return True except Exception: pass + return False + def _handle_input_interrupt(self, current_time: float) -> bool: + """Handle interrupt for input widget. Returns True if handled.""" if ( self._current_bottom_app == BottomApp.Input and self._last_escape_time is not None @@ -1335,21 +1310,35 @@ def action_interrupt(self) -> None: if input_widget.value: input_widget.value = "" self._last_escape_time = None - return + return True except Exception: pass + return False + def _needs_interrupt(self) -> bool: + """Check if agent interruption is needed.""" has_pending_user_message = any( msg.has_class("pending") for msg in self.query(UserMessage) ) - - interrupt_needed = self._agent_running or ( - self._agent_init_task - and not self._agent_init_task.done() - and has_pending_user_message + return bool( + self._agent_running + or ( + self._agent_init_task + and not self._agent_init_task.done() + and has_pending_user_message + ) ) - if interrupt_needed: + def action_interrupt(self) -> None: + current_time = time.monotonic() + + if self._handle_bottom_app_interrupt(): + return + + if self._handle_input_interrupt(current_time): + return + + if self._needs_interrupt(): self.run_worker(self._interrupt_agent(), exclusive=False) self._last_escape_time = current_time diff --git a/revibe/cli/textual_ui/terminal_theme.py b/revibe/cli/textual_ui/terminal_theme.py index fd8b24b..8a3c1f5 100644 --- a/revibe/cli/textual_ui/terminal_theme.py +++ b/revibe/cli/textual_ui/terminal_theme.py @@ -11,7 +11,7 @@ from textual.theme import Theme try: - import select + import select # noqa: F401 _UNIX_AVAILABLE = True except ImportError: diff --git a/revibe/cli/textual_ui/widgets/api_key_input.py b/revibe/cli/textual_ui/widgets/api_key_input.py index 18f0dd8..6f4d667 100644 --- a/revibe/cli/textual_ui/widgets/api_key_input.py +++ b/revibe/cli/textual_ui/widgets/api_key_input.py @@ -20,7 +20,8 @@ class ApiKeyInput(Container): can_focus_children = True BINDINGS: ClassVar[list[BindingType]] = [ - Binding("escape", "cancel", "Cancel", show=False) + Binding("escape", "cancel", "Cancel", show=False), + Binding("enter", "submit", "Submit", show=False), ] class ApiKeySubmitted(Message): @@ -41,6 +42,24 @@ def __init__(self, provider: ProviderConfigUnion) -> None: def compose(self) -> ComposeResult: with Vertical(id="api-key-content"): + if not self.provider.api_key_env_var: + self.title_widget = Static( + f"{self.provider.name.capitalize()} does not require an API key", + classes="settings-title", + ) + yield self.title_widget + yield Static("") + yield Static( + "This provider works without an API key. No further setup needed.", + classes="settings-help", + ) + yield Static("") + self.help_widget = Static( + "Enter to continue ESC cancel", classes="settings-help" + ) + yield self.help_widget + return + self.title_widget = Static( f"Enter API Key for {self.provider.name}", classes="settings-title" ) @@ -85,8 +104,11 @@ def _ensure_focus(self) -> None: def on_input_submitted(self, event: Input.Submitted) -> None: api_key = event.value.strip() - if api_key: + if api_key or not self.provider.api_key_env_var: self.post_message(self.ApiKeySubmitted(self.provider.name, api_key)) def action_cancel(self) -> None: self.post_message(self.ApiKeyCancelled()) + + def action_submit(self) -> None: + self.post_message(self.ApiKeySubmitted(self.provider.name, "")) diff --git a/revibe/cli/textual_ui/widgets/model_selector.py b/revibe/cli/textual_ui/widgets/model_selector.py index 1ee6da9..8358f06 100644 --- a/revibe/cli/textual_ui/widgets/model_selector.py +++ b/revibe/cli/textual_ui/widgets/model_selector.py @@ -112,6 +112,98 @@ def _update_list(self, filter_text: str = "") -> None: if not found_active and self._filtered_models: option_list.highlighted = 0 + def _build_provider_map(self) -> dict[str, ProviderConfigUnion]: + """Build a merged provider map from defaults and user config.""" + from revibe.core.config import DEFAULT_PROVIDERS + + providers_map: dict[str, ProviderConfigUnion] = {} + for p in DEFAULT_PROVIDERS: + providers_map[p.name] = p + for p in self.config.providers: + providers_map[p.name] = p + return providers_map + + def _get_providers_to_query(self, providers_map: dict[str, ProviderConfigUnion]) -> list[ProviderConfigUnion]: + """Determine which providers to query for dynamic models.""" + import os + + providers_to_query: list[ProviderConfigUnion] = [] + + if self.provider_filter: + provider = providers_map.get(self.provider_filter) + if provider: + # Only fetch dynamic models for ollama and llamacpp + if provider.backend not in {Backend.OLLAMA, Backend.LLAMACPP}: + return [] + + # If provider requires an API key and none is set, show a helpful message + if provider.api_key_env_var and not os.getenv(provider.api_key_env_var): + self._missing_api_key_message = f"API key required: set {provider.api_key_env_var} to list models for {provider.name}" + # Clear any models we had filtered earlier + self.models = [ + m for m in self.models if m.provider != provider.name + ] + return [] + providers_to_query.append(provider) + else: + # Query only ollama and llamacpp providers for dynamic models + # Skip providers that require API keys but don't have one set + for p in providers_map.values(): + if p.backend not in {Backend.OLLAMA, Backend.LLAMACPP}: + continue + if p.api_key_env_var and not os.getenv(p.api_key_env_var): + continue + providers_to_query.append(p) + + return providers_to_query + + async def _fetch_models_from_provider( + self, provider: ProviderConfigUnion, existing_names: set[tuple[str, str]] + ) -> bool: + """Fetch models from a single provider. Returns True if any models were added.""" + from revibe.core.llm.backend.factory import BACKEND_FACTORY + + try: + backend_cls = BACKEND_FACTORY.get(provider.backend) + if not backend_cls: + return False + + model_names = await backend_cls(provider=provider).list_models() + if not model_names: + return False + + added_any = False + for name in model_names: + key = (provider.name, name) + if key not in existing_names: + self.models.append( + ModelConfig( + name=name, + provider=provider.name, + alias=name, + ) + ) + existing_names.add(key) + added_any = True + + return added_any + except Exception: + # Ignore failures per-provider so one bad provider doesn't block others + return False + + async def _fetch_models_from_providers( + self, providers_to_query: list[ProviderConfigUnion] + ) -> bool: + """Fetch models from the specified providers. Returns True if any models were added.""" + existing_names = {(m.provider, m.name) for m in self.models} + added_any = False + + for provider in providers_to_query: + if await self._fetch_models_from_provider(provider, existing_names): + added_any = True + + return added_any + async def _fetch_dynamic_models(self) -> None: """Fetch models from provider backends. Only fetches dynamically for ollama and llamacpp, other providers use hardcoded DEFAULT_MODELS. @@ -121,86 +213,14 @@ async def _fetch_dynamic_models(self) -> None: self._update_list(self.query_one("#model-selector-filter", Input).value) try: - from revibe.core.config import DEFAULT_PROVIDERS - from revibe.core.llm.backend.factory import BACKEND_FACTORY - - # Build a merged provider map (defaults + user config) - providers_map: dict[str, ProviderConfigUnion] = {} - for p in DEFAULT_PROVIDERS: - providers_map[p.name] = p - for p in self.config.providers: - providers_map[p.name] = p - - providers_to_query: list[ProviderConfigUnion] = [] - - import os - - if self.provider_filter: - provider = providers_map.get(self.provider_filter) - if provider: - # Only fetch dynamic models for ollama and llamacpp - if provider.backend not in {Backend.OLLAMA, Backend.LLAMACPP}: - # Use hardcoded models for other providers - self.loading = False - self._update_list( - self.query_one("#model-selector-filter", Input).value - ) - return - - # If provider requires an API key and none is set, show a helpful message - if provider.api_key_env_var and not os.getenv( - provider.api_key_env_var - ): - self._missing_api_key_message = f"API key required: set {provider.api_key_env_var} to list models for {provider.name}" - # Clear any models we had filtered earlier - self.models = [ - m for m in self.models if m.provider != provider.name - ] - self.loading = False - self._update_list( - self.query_one("#model-selector-filter", Input).value - ) - return - providers_to_query.append(provider) - else: - # Query only ollama and llamacpp providers for dynamic models - # Skip providers that require API keys but don't have one set - for p in providers_map.values(): - if p.backend not in {Backend.OLLAMA, Backend.LLAMACPP}: - continue - if p.api_key_env_var and not os.getenv(p.api_key_env_var): - continue - providers_to_query.append(p) - - existing_names = {(m.provider, m.name) for m in self.models} - added_any = False - - for provider in providers_to_query: - try: - backend_cls = BACKEND_FACTORY.get(provider.backend) - if backend_cls: - async with backend_cls(provider=provider) as backend: - model_names = await backend.list_models() - if model_names: - for name in model_names: - key = (provider.name, name) - if key not in existing_names: - self.models.append( - ModelConfig( - name=name, - provider=provider.name, - alias=name, - ) - ) - existing_names.add(key) - added_any = True - except Exception: - # Ignore failures per-provider so one bad provider doesn't block others - continue - - if added_any: - # Sort models by provider then name for stable display - self.models.sort(key=lambda x: (x.provider, x.name)) + providers_map = self._build_provider_map() + providers_to_query = self._get_providers_to_query(providers_map) + + if providers_to_query: + added_any = await self._fetch_models_from_providers(providers_to_query) + if added_any: + # Sort models by provider then name for stable display + self.models.sort(key=lambda x: (x.provider, x.name)) finally: self.loading = False self._update_list(self.query_one("#model-selector-filter", Input).value) diff --git a/revibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py b/revibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py index 2aeaf42..f3e9a36 100644 --- a/revibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +++ b/revibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py @@ -8,12 +8,12 @@ UpdateCache, UpdateCacheRepository, ) -from revibe.core.paths.global_paths import VIBE_HOME +from revibe.core.paths.global_paths import REVIBE_HOME class FileSystemUpdateCacheRepository(UpdateCacheRepository): def __init__(self, base_path: Path | str | None = None) -> None: - self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path + self._base_path = Path(base_path) if base_path is not None else REVIBE_HOME.path self._cache_file = self._base_path / "update_cache.json" async def get(self) -> UpdateCache | None: diff --git a/revibe/core/config.py b/revibe/core/config.py index c15a96a..35d74a5 100644 --- a/revibe/core/config.py +++ b/revibe/core/config.py @@ -9,7 +9,7 @@ from typing import Annotated, Any, Literal from dotenv import dotenv_values -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator from pydantic.fields import FieldInfo from pydantic_core import to_jsonable_python from pydantic_settings import ( @@ -206,6 +206,7 @@ class QwenProviderConfig(_ProviderBase): type ProviderConfig = ProviderConfigUnion +PROVIDER_CONFIG_ADAPTER = TypeAdapter(ProviderConfigUnion) class _MCPBase(BaseModel): @@ -403,7 +404,7 @@ class VibeConfig(BaseSettings): ) model_config = SettingsConfigDict( - env_prefix="VIBE_", case_sensitive=False, extra="ignore" + env_prefix="REVIBE_", case_sensitive=False, extra="ignore" ) @property @@ -424,26 +425,47 @@ def system_prompt(self) -> str: def get_active_model(self) -> ModelConfig: for model in self.models: - if model.alias == self.active_model: - return model + # Handle both ModelConfig objects and dicts (can happen with model_construct) + m_alias = ( + model.alias if isinstance(model, ModelConfig) else model.get("alias") + ) + if m_alias == self.active_model: + return ( + model + if isinstance(model, ModelConfig) + else ModelConfig.model_validate(model) + ) raise ValueError( f"Active model '{self.active_model}' not found in configuration." ) def get_provider_for_model(self, model: ModelConfig) -> ProviderConfigUnion: # Merge DEFAULT_PROVIDERS with configured providers - providers_map: dict[str, ProviderConfigUnion] = {} + providers_map: dict[str, Any] = {} for p in DEFAULT_PROVIDERS: providers_map[p.name] = p for p in self.providers: - providers_map[p.name] = p + p_name = p.name if not isinstance(p, dict) else p.get("name") + providers_map[p_name] = p + + m_provider = ( + model.provider if isinstance(model, ModelConfig) else model.get("provider") + ) + provider = providers_map.get(m_provider) - provider = providers_map.get(model.provider) if provider is None: + m_name = getattr(model, "name", None) or ( + model.get("name") if isinstance(model, dict) else "unknown" + ) raise ValueError( - f"Provider '{model.provider}' for model '{model.name}' not found in configuration." + f"Provider '{m_provider}' for model '{m_name}' not found in configuration." ) - return provider + + return ( + provider + if not isinstance(provider, dict) + else PROVIDER_CONFIG_ADAPTER.validate_python(provider) + ) @classmethod def settings_customise_sources( @@ -458,7 +480,7 @@ def settings_customise_sources( Note: dotenv_settings is intentionally excluded. API keys and other non-config environment variables are stored in .env but loaded manually - into os.environ for use by providers. Only VIBE_* prefixed environment + into os.environ for use by providers. Only REVIBE_* prefixed environment variables (via env_settings) and TOML config are used for Pydantic settings. """ return ( @@ -513,21 +535,12 @@ def _expand_skill_paths(cls, v: Any) -> list[Path]: return [] return [Path(p).expanduser().resolve() for p in v] - @field_validator("workdir", mode="before") + @field_validator("models", mode="before") @classmethod - def _expand_workdir(cls, v: Any) -> Path | None: - if v is None or (isinstance(v, str) and not v.strip()): - return None - - if isinstance(v, str): - v = Path(v).expanduser().resolve() - elif isinstance(v, Path): - v = v.expanduser().resolve() - if not v.is_dir(): - raise ValueError( - f"Tried to set {v} as working directory, path doesn't exist" - ) - return v + def _validate_models(cls, v: Any) -> list[ModelConfig]: + if not isinstance(v, list): + return list(DEFAULT_MODELS) + return [ModelConfig.model_validate(item) for item in v] @field_validator("tools", mode="before") @classmethod @@ -548,13 +561,15 @@ def _normalize_tool_configs(cls, v: Any) -> dict[str, BaseToolConfig]: @model_validator(mode="after") def _validate_model_uniqueness(self) -> VibeConfig: - seen_aliases: set[str] = set() + provider_aliases: dict[str, set[str]] = {} for model in self.models: - if model.alias in seen_aliases: + if model.provider not in provider_aliases: + provider_aliases[model.provider] = set() + if model.alias in provider_aliases[model.provider]: raise ValueError( - f"Duplicate model alias found: '{model.alias}'. Aliases must be unique." + f"Duplicate model alias found for provider '{model.provider}': '{model.alias}'. Aliases must be unique within each provider." ) - seen_aliases.add(model.alias) + provider_aliases[model.provider].add(model.alias) return self @model_validator(mode="after") diff --git a/revibe/core/llm/backend/qwen/handler.py b/revibe/core/llm/backend/qwen/handler.py index d8ced61..4b308ba 100644 --- a/revibe/core/llm/backend/qwen/handler.py +++ b/revibe/core/llm/backend/qwen/handler.py @@ -15,14 +15,12 @@ from collections.abc import AsyncGenerator import json -import os import types from typing import TYPE_CHECKING, Any, ClassVar import httpx from revibe.core.llm.backend.qwen.oauth import QwenOAuthManager -from revibe.core.llm.backend.qwen.types import QWEN_DEFAULT_BASE_URL from revibe.core.llm.exceptions import BackendErrorBuilder from revibe.core.types import ( AvailableTool, @@ -100,10 +98,7 @@ def parse(self, text: str) -> tuple[str, str]: class QwenBackend: supported_formats: ClassVar[list[str]] = ["native", "xml"] - """Backend for Qwen Code API (Alibaba Cloud DashScope). - - Supports both OAuth authentication (for Qwen CLI users) and - API key authentication (for direct DashScope access). + """Backend for Qwen Code API. Features: - OpenAI-compatible chat completions API @@ -131,15 +126,8 @@ def __init__( self._client: httpx.AsyncClient | None = None self._owns_client = True - # Determine authentication mode - self._api_key = ( - os.getenv(provider.api_key_env_var) if provider.api_key_env_var else None - ) - # OAuth manager for Qwen CLI authentication - self._oauth_manager: QwenOAuthManager | None = None - if not self._api_key: - self._oauth_manager = QwenOAuthManager(oauth_path) + self._oauth_manager = QwenOAuthManager(oauth_path) async def __aenter__(self) -> QwenBackend: self._client = httpx.AsyncClient( @@ -173,33 +161,24 @@ async def _get_auth_headers(self, force_refresh: bool = False) -> dict[str, str] Args: force_refresh: If True, forces a token refresh for OAuth. - Returns headers with either API key or OAuth token. + Returns headers with OAuth token. """ headers = {"Content-Type": "application/json"} - if self._api_key: - headers["Authorization"] = f"Bearer {self._api_key}" - elif self._oauth_manager: - access_token, _ = await self._oauth_manager.ensure_authenticated( - force_refresh=force_refresh - ) - headers["Authorization"] = f"Bearer {access_token}" + access_token, _ = await self._oauth_manager.ensure_authenticated( + force_refresh=force_refresh + ) + headers["Authorization"] = f"Bearer {access_token}" return headers async def _get_base_url(self) -> str: """Get the API base URL. - Returns URL from provider config, OAuth credentials, or default. + Returns URL from OAuth credentials or default. """ - if self._api_key and self._provider.api_base: - return self._provider.api_base.rstrip("/") - - if self._oauth_manager: - _, base_url = await self._oauth_manager.ensure_authenticated() - return base_url.rstrip("/") - - return QWEN_DEFAULT_BASE_URL.rstrip("/") + _, base_url = await self._oauth_manager.ensure_authenticated() + return base_url.rstrip("/") def _prepare_messages(self, messages: list[LLMMessage]) -> list[dict[str, Any]]: """Convert LLMMessages to OpenAI-compatible format.""" @@ -276,7 +255,7 @@ async def complete( extra_headers=extra_headers, ) - async def _complete_with_retry( + async def _complete_with_retry( # noqa: PLR0914 self, *, model: ModelConfig, @@ -434,7 +413,117 @@ async def complete_streaming( ): yield chunk - async def _complete_streaming_with_retry( + def _build_streaming_payload( + self, + model: ModelConfig, + messages: list[LLMMessage], + temperature: float, + tools: list[AvailableTool] | None, + max_tokens: int | None, + tool_choice: StrToolChoice | AvailableTool | None, + ) -> dict[str, Any]: + """Build the payload for streaming requests.""" + payload: dict[str, Any] = { + "model": model.name, + "messages": self._prepare_messages(messages), + "temperature": temperature, + "stream": True, + "stream_options": {"include_usage": True}, + } + + if tools: + payload["tools"] = self._prepare_tools(tools) + if tool_choice: + payload["tool_choice"] = self._prepare_tool_choice(tool_choice) + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + return payload + + def _parse_sse_line(self, line: str) -> tuple[str, str] | None: + """Parse an SSE line and return (key, value) if valid.""" + if not line.strip(): + return None + + if ":" not in line: + return None + + delim_index = line.find(":") + key = line[:delim_index].strip() + # Value starts after colon, with optional leading space + value = line[delim_index + 1 :].lstrip() + + return key, value + + def _handle_chunk_data( + self, + chunk_data: dict[str, Any], + delta: dict[str, Any], + usage: dict[str, Any] | None, + thinking_parser: ThinkingBlockParser, + full_content: str, + ) -> tuple[str, str, list[ToolCall] | None]: + """Handle chunk data and extract content, reasoning, and tool calls.""" + content = "" + reasoning_content = "" + tool_calls = None + + # Handle content with potential thinking blocks + if delta.get("content"): + new_text = delta["content"] + + # Handle cumulative content (some providers send full content) + if new_text.startswith(full_content): + new_text = new_text[len(full_content) :] + + if new_text: + # Parse thinking blocks + regular, thinking = thinking_parser.parse(new_text) + content = regular + reasoning_content = thinking + + # Handle native reasoning_content field + if delta.get("reasoning_content"): + reasoning_content = delta["reasoning_content"] + + # Parse tool calls + if delta.get("tool_calls"): + tool_calls = [ + ToolCall( + id=tc.get("id"), + index=tc.get("index"), + function=FunctionCall( + name=tc.get("function", {}).get("name"), + arguments=tc.get("function", {}).get("arguments"), + ), + ) + for tc in delta["tool_calls"] + ] + + return content, reasoning_content, tool_calls + + def _create_llm_chunk( + self, + content: str, + reasoning_content: str, + tool_calls: list[ToolCall] | None, + usage: dict[str, Any] | None, + ) -> LLMChunk: + """Create an LLMChunk from the parsed data.""" + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + content=content if content else None, + reasoning_content=reasoning_content if reasoning_content else None, + tool_calls=tool_calls, + ), + usage=LLMUsage( + prompt_tokens=usage.get("prompt_tokens", 0) if usage else 0, + completion_tokens=usage.get("completion_tokens", 0) if usage else 0, + ), + ) + + async def _complete_streaming_with_retry( # noqa: PLR0914 self, *, model: ModelConfig, @@ -455,20 +544,9 @@ async def _complete_streaming_with_retry( base_url = await self._get_base_url() url = f"{base_url}/chat/completions" - payload: dict[str, Any] = { - "model": model.name, - "messages": self._prepare_messages(messages), - "temperature": temperature, - "stream": True, - "stream_options": {"include_usage": True}, - } - - if tools: - payload["tools"] = self._prepare_tools(tools) - if tool_choice: - payload["tool_choice"] = self._prepare_tool_choice(tool_choice) - if max_tokens is not None: - payload["max_tokens"] = max_tokens + payload = self._build_streaming_payload( + model, messages, temperature, tools, max_tokens, tool_choice + ) thinking_parser = ThinkingBlockParser() full_content = "" @@ -483,132 +561,42 @@ async def _complete_streaming_with_retry( ) as response: response.raise_for_status() - # Check if response is actually a streaming response content_type = response.headers.get("content-type", "") if "text/event-stream" not in content_type: - # Non-streaming response - might be an error - body = await response.aread() - body_text = body.decode("utf-8") - if body_text: - try: - error_data = json.loads(body_text) - error_msg = ( - error_data.get("error", {}).get("message") - or error_data.get("message") - or error_data.get("detail") - or str(error_data) - ) - raise ValueError(f"API returned error: {error_msg}") - except json.JSONDecodeError: - raise ValueError( - f"Unexpected API response: {body_text[:200]}" - ) + await self._handle_non_streaming_response(response) return async for line in response.aiter_lines(): - if not line.strip(): + parsed = self._parse_sse_line(line) + if not parsed: continue - # SSE format: "field: value" - colon followed by optional space - if ":" not in line: - # Could be a raw JSON error response - try: - error_data = json.loads(line) - if "error" in error_data or "message" in error_data: - error_msg = ( - error_data.get("error", {}).get("message") - if isinstance(error_data.get("error"), dict) - else error_data.get("error") - or error_data.get("message") - or str(error_data) - ) - raise ValueError(f"API error: {error_msg}") - except json.JSONDecodeError: - pass - continue - - delim_index = line.find(":") - key = line[:delim_index].strip() - # Value starts after colon, with optional leading space - value = line[delim_index + 1 :].lstrip() - + key, value = parsed if key != "data": continue if not value or value == "[DONE]": continue - try: - chunk_data = json.loads(value) - except json.JSONDecodeError: - # Skip malformed JSON lines + chunk_data = self._parse_chunk_data(value) + if chunk_data is None: continue - # Check for error in the chunk if "error" in chunk_data: - error_info = chunk_data["error"] - error_msg = ( - error_info.get("message") - if isinstance(error_info, dict) - else str(error_info) - ) - raise ValueError(f"API error: {error_msg}") + self._handle_chunk_error(chunk_data) choices = chunk_data.get("choices", []) delta = choices[0].get("delta", {}) if choices else {} usage = chunk_data.get("usage") - content = "" - reasoning_content = "" + content, reasoning_content, tool_calls = self._handle_chunk_data( + chunk_data, delta, usage, thinking_parser, full_content + ) - # Handle content with potential thinking blocks if delta.get("content"): - new_text = delta["content"] - - # Handle cumulative content (some providers send full content) - if new_text.startswith(full_content): - new_text = new_text[len(full_content) :] full_content = delta["content"] - if new_text: - # Parse thinking blocks - regular, thinking = thinking_parser.parse(new_text) - content = regular - reasoning_content = thinking - - # Handle native reasoning_content field - if delta.get("reasoning_content"): - reasoning_content = delta["reasoning_content"] - - # Parse tool calls - tool_calls = None - if delta.get("tool_calls"): - tool_calls = [ - ToolCall( - id=tc.get("id"), - index=tc.get("index"), - function=FunctionCall( - name=tc.get("function", {}).get("name"), - arguments=tc.get("function", {}).get("arguments"), - ), - ) - for tc in delta["tool_calls"] - ] - - yield LLMChunk( - message=LLMMessage( - role=Role.assistant, - content=content if content else None, - reasoning_content=reasoning_content - if reasoning_content - else None, - tool_calls=tool_calls, - ), - usage=LLMUsage( - prompt_tokens=usage.get("prompt_tokens", 0) if usage else 0, - completion_tokens=usage.get("completion_tokens", 0) - if usage - else 0, - ), + yield self._create_llm_chunk( + content, reasoning_content, tool_calls, usage ) except httpx.HTTPStatusError as e: @@ -653,6 +641,41 @@ async def _complete_streaming_with_retry( tool_choice=tool_choice, ) from e + async def _handle_non_streaming_response(self, response: httpx.Response) -> None: + """Handle non-streaming response, raising appropriate errors.""" + body = await response.aread() + body_text = body.decode("utf-8") + if not body_text: + return + try: + error_data = json.loads(body_text) + error_msg = ( + error_data.get("error", {}).get("message") + or error_data.get("message") + or error_data.get("detail") + or str(error_data) + ) + raise ValueError(f"API returned error: {error_msg}") + except json.JSONDecodeError: + raise ValueError(f"Unexpected API response: {body_text[:200]}") + + def _parse_chunk_data(self, value: str) -> dict[str, Any] | None: + """Parse chunk data from SSE value, returning None on JSON error.""" + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + def _handle_chunk_error(self, chunk_data: dict[str, Any]) -> None: + """Handle error in chunk data.""" + error_info = chunk_data["error"] + error_msg = ( + error_info.get("message") + if isinstance(error_info, dict) + else str(error_info) + ) + raise ValueError(f"API error: {error_msg}") + async def count_tokens( self, *, diff --git a/revibe/core/model_config.py b/revibe/core/model_config.py index 0829432..1591b70 100644 --- a/revibe/core/model_config.py +++ b/revibe/core/model_config.py @@ -14,6 +14,7 @@ class ModelConfig(BaseModel): - "xml": Uses XML-based tool calling in prompts Models default to supporting both formats. """ + name: str provider: str alias: str @@ -157,7 +158,7 @@ def _default_alias_to_name(cls, data: Any) -> Any: ModelConfig( name="openai/gpt-oss-120b", provider="groq", - alias="gpt-oss-120b", + alias="gpt-oss-120b-groq", input_price=0.15, output_price=0.60, context=131072, @@ -175,16 +176,107 @@ def _default_alias_to_name(cls, data: Any) -> Any: ModelConfig( name="llama-3.3-70b-versatile", provider="groq", - alias="llama-3.3-70b", + alias="llama-3.3-70b-groq", input_price=0.59, output_price=0.79, context=131072, max_output=32768, ), ModelConfig( - name="zai-org/GLM-4.7-FP8", + name="zai-org/GLM-4.7", provider="huggingface", alias="glm-4.7", + input_price=0.6, + output_price=2.2, + context=204800, + ), + ModelConfig( + name="MiniMaxAI/MiniMax-M2.1", + provider="huggingface", + alias="minimax-m2.1", + input_price=0.3, + output_price=1.2, + context=204800, + ), + ModelConfig( + name="XiaomiMiMo/MiMo-V2-Flash", + provider="huggingface", + alias="mimo-v2-flash", + input_price=0.098, + output_price=0.293, + context=262144, + ), + ModelConfig( + name="deepseek-ai/DeepSeek-V3.2", + provider="huggingface", + alias="deepseek-v3.2", + input_price=0.269, + output_price=0.4, + context=163840, + ), + ModelConfig( + name="MiniMaxAI/MiniMax-M2", + provider="huggingface", + alias="minimax-m2", + input_price=0.24, + output_price=0.96, + context=204800, + ), + ModelConfig( + name="zai-org/GLM-4.6V-Flash", + provider="huggingface", + alias="glm-4.6v-flash", + input_price=0.3, + output_price=0.9, + context=131072, + ), + ModelConfig( + name="moonshotai/Kimi-K2-Thinking", + provider="huggingface", + alias="kimi-k2-thinking", + input_price=0.48, + output_price=2.0, + context=262144, + ), + ModelConfig( + name="moonshotai/Kimi-K2-Instruct-0905", + provider="huggingface", + alias="kimi-k2-instruct", + input_price=0.48, + output_price=2.0, + context=262144, + ), + ModelConfig( + name="Qwen/Qwen3-Coder-30B-A3B-Instruct", + provider="huggingface", + alias="qwen3-coder-30b", + input_price=0.1, + output_price=0.3, + context=262144, + ), + ModelConfig( + name="deepseek-ai/DeepSeek-V3.2-Exp", + provider="huggingface", + alias="deepseek-v3.2-exp", + input_price=0.216, + output_price=0.328, + context=163840, + ), + ModelConfig( + name="MiniMaxAI/MiniMax-M1-80k", + provider="huggingface", + alias="minimax-m1-80k", + input_price=0.44, + output_price=1.76, + context=1000000, + ), + ModelConfig( + name="Qwen/Qwen3-Coder-480B-A35B-Instruct", + provider="huggingface", + alias="qwen3-coder-480b", + input_price=0.24, + output_price=1.04, + context=262144, ), # Cerebras models ModelConfig( @@ -208,7 +300,7 @@ def _default_alias_to_name(cls, data: Any) -> Any: ModelConfig( name="llama-3.3-70b", provider="cerebras", - alias="llama-3.3-70b", + alias="llama-3.3-70b-cerebras", input_price=0.85, output_price=1.20, context=128000, @@ -226,7 +318,7 @@ def _default_alias_to_name(cls, data: Any) -> Any: ModelConfig( name="gpt-oss-120b", provider="cerebras", - alias="gpt-oss-120b", + alias="gpt-oss-120b-cerebras", input_price=0.35, output_price=0.75, context=131072, @@ -250,5 +342,5 @@ def _default_alias_to_name(cls, data: Any) -> Any: output_price=0.0, context=1000000, max_output=65536, - ) + ), ] diff --git a/revibe/core/paths/config_paths.py b/revibe/core/paths/config_paths.py index fc151f5..05f98fc 100644 --- a/revibe/core/paths/config_paths.py +++ b/revibe/core/paths/config_paths.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Literal -from revibe.core.paths.global_paths import VIBE_HOME, GlobalPath +from revibe.core.paths.global_paths import REVIBE_HOME, GlobalPath from revibe.core.trusted_folders import trusted_folders_manager _config_paths_locked: bool = True @@ -21,20 +21,20 @@ def _resolve_config_path(basename: str, type: Literal["file", "dir"]) -> Path: cwd = Path.cwd() is_folder_trusted = trusted_folders_manager.is_trusted(cwd) if not is_folder_trusted: - return VIBE_HOME.path / basename + return REVIBE_HOME.path / basename if type == "file": - if (candidate := cwd / ".vibe" / basename).is_file(): + if (candidate := cwd / ".revibe" / basename).is_file(): return candidate elif type == "dir": - if (candidate := cwd / ".vibe" / basename).is_dir(): + if (candidate := cwd / ".revibe" / basename).is_dir(): return candidate - return VIBE_HOME.path / basename + return REVIBE_HOME.path / basename def resolve_local_tools_dir(dir: Path) -> Path | None: if not trusted_folders_manager.is_trusted(dir): return None - if (candidate := dir / ".vibe" / "tools").is_dir(): + if (candidate := dir / ".revibe" / "tools").is_dir(): return candidate return None @@ -42,7 +42,7 @@ def resolve_local_tools_dir(dir: Path) -> Path | None: def resolve_local_skills_dir(dir: Path) -> Path | None: if not trusted_folders_manager.is_trusted(dir): return None - if (candidate := dir / ".vibe" / "skills").is_dir(): + if (candidate := dir / ".revibe" / "skills").is_dir(): return candidate return None @@ -57,4 +57,4 @@ def unlock_config_paths() -> None: AGENT_DIR = ConfigPath(lambda: _resolve_config_path("agents", "dir")) PROMPT_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir")) INSTRUCTIONS_FILE = ConfigPath(lambda: _resolve_config_path("instructions.md", "file")) -HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file")) +HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("revibehistory", "file")) diff --git a/revibe/core/paths/global_paths.py b/revibe/core/paths/global_paths.py index 3fefdbe..83ed2ce 100644 --- a/revibe/core/paths/global_paths.py +++ b/revibe/core/paths/global_paths.py @@ -16,23 +16,23 @@ def path(self) -> Path: return self._resolver() -_DEFAULT_VIBE_HOME = Path.home() / ".vibe" +_DEFAULT_REVIBE_HOME = Path.home() / ".revibe" -def _get_vibe_home() -> Path: - if vibe_home := os.getenv("VIBE_HOME"): - return Path(vibe_home).expanduser().resolve() - return _DEFAULT_VIBE_HOME +def _get_revibe_home() -> Path: + if revibe_home := os.getenv("REVIBE_HOME"): + return Path(revibe_home).expanduser().resolve() + return _DEFAULT_REVIBE_HOME -VIBE_HOME = GlobalPath(_get_vibe_home) -GLOBAL_CONFIG_FILE = GlobalPath(lambda: VIBE_HOME.path / "config.toml") -GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env") -GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools") -GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills") -SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session") -TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml") -LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs") -LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibe.log") +REVIBE_HOME = GlobalPath(_get_revibe_home) +GLOBAL_CONFIG_FILE = GlobalPath(lambda: REVIBE_HOME.path / "config.toml") +GLOBAL_ENV_FILE = GlobalPath(lambda: REVIBE_HOME.path / ".env") +GLOBAL_TOOLS_DIR = GlobalPath(lambda: REVIBE_HOME.path / "tools") +GLOBAL_SKILLS_DIR = GlobalPath(lambda: REVIBE_HOME.path / "skills") +SESSION_LOG_DIR = GlobalPath(lambda: REVIBE_HOME.path / "logs" / "session") +TRUSTED_FOLDERS_FILE = GlobalPath(lambda: REVIBE_HOME.path / "trusted_folders.toml") +LOG_DIR = GlobalPath(lambda: REVIBE_HOME.path / "logs") +LOG_FILE = GlobalPath(lambda: REVIBE_HOME.path / "revibe.log") DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins") diff --git a/revibe/core/system_prompt.py b/revibe/core/system_prompt.py index 5561159..881f35a 100644 --- a/revibe/core/system_prompt.py +++ b/revibe/core/system_prompt.py @@ -408,6 +408,57 @@ def _get_available_skills_section(skill_manager: SkillManager | None) -> str: return "\n".join(lines) +def _get_tool_prompts_section( + tool_manager: ToolManager, config: VibeConfig +) -> str | None: + active_tools = get_active_tool_classes(tool_manager, config) + + from revibe.core.config import ToolFormat + + use_xml_prompts = config.tool_format == ToolFormat.XML + + tool_prompts = [ + tool_class.get_xml_tool_prompt() + if use_xml_prompts + else tool_class.get_tool_prompt() + for tool_class in active_tools + ] + tool_prompts = [p for p in tool_prompts if p] + return "\n---\n".join(tool_prompts) if tool_prompts else None + + +def _get_xml_tool_section(tool_manager: ToolManager, config: VibeConfig) -> str | None: + from revibe.core.config import ToolFormat + + if config.tool_format != ToolFormat.XML: + return None + + from revibe import VIBE_ROOT + from revibe.core.llm.format import XMLToolFormatHandler + + xml_handler = XMLToolFormatHandler() + tool_defs = xml_handler.get_tool_definitions_xml(tool_manager, config) + if not tool_defs: + return None + + xml_prompt_path = ( + VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "xml_tools.md" + ) + xml_prompt_template = xml_prompt_path.read_text(encoding="utf-8") + return xml_prompt_template.format(tool_definitions=tool_defs) + + +def _get_project_context_section(config: VibeConfig) -> str: + is_dangerous, reason = is_dangerous_directory() + if is_dangerous: + template = UtilityPrompt.DANGEROUS_DIRECTORY.read() + return template.format(reason=reason.lower(), abs_path=Path(".").resolve()) + else: + return ProjectContextProvider( + config=config.project_context, root_path=config.effective_workdir + ).get_full_context() + + def get_universal_system_prompt( tool_manager: ToolManager, config: VibeConfig, @@ -423,23 +474,9 @@ def get_universal_system_prompt( if config.include_prompt_detail: sections.append(_get_os_system_prompt()) - tool_prompts = [] - active_tools = get_active_tool_classes(tool_manager, config) - - # Import ToolFormat here to check format mode - from revibe.core.config import ToolFormat - use_xml_prompts = config.tool_format == ToolFormat.XML - - for tool_class in active_tools: - # Use XML prompts when in XML mode, otherwise use standard prompts - if use_xml_prompts: - prompt = tool_class.get_xml_tool_prompt() - else: - prompt = tool_class.get_tool_prompt() - if prompt: - tool_prompts.append(prompt) - if tool_prompts: - sections.append("\n---\n".join(tool_prompts)) + tool_section = _get_tool_prompts_section(tool_manager, config) + if tool_section: + sections.append(tool_section) user_instructions = config.instructions.strip() or _load_user_instructions() if user_instructions.strip(): @@ -449,32 +486,12 @@ def get_universal_system_prompt( if skills_section: sections.append(skills_section) - # Add XML tool definitions if using XML format - from revibe.core.config import ToolFormat - if config.tool_format == ToolFormat.XML: - from revibe import VIBE_ROOT - from revibe.core.llm.format import XMLToolFormatHandler - xml_handler = XMLToolFormatHandler() - tool_defs = xml_handler.get_tool_definitions_xml(tool_manager, config) - if tool_defs: - xml_prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "xml_tools.md" - xml_prompt_template = xml_prompt_path.read_text(encoding="utf-8") - xml_prompt = xml_prompt_template.format(tool_definitions=tool_defs) - sections.append(xml_prompt) + xml_section = _get_xml_tool_section(tool_manager, config) + if xml_section: + sections.append(xml_section) if config.include_project_context: - is_dangerous, reason = is_dangerous_directory() - if is_dangerous: - template = UtilityPrompt.DANGEROUS_DIRECTORY.read() - context = template.format( - reason=reason.lower(), abs_path=Path(".").resolve() - ) - else: - context = ProjectContextProvider( - config=config.project_context, root_path=config.effective_workdir - ).get_full_context() - - sections.append(context) + sections.append(_get_project_context_section(config)) project_doc = _load_project_doc( config.effective_workdir, config.project_context.max_doc_bytes diff --git a/revibe/core/tools/builtins/bash.py b/revibe/core/tools/builtins/bash.py index 1a114b3..f40c4ec 100644 --- a/revibe/core/tools/builtins/bash.py +++ b/revibe/core/tools/builtins/bash.py @@ -178,9 +178,10 @@ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: if not isinstance(event.args, BashArgs): return ToolCallDisplay(summary="Bash") + MAX_COMMAND_DISPLAY_LENGTH = 30 command = event.args.command.strip() - if len(command) > 30: - command = command[:27] + "..." + if len(command) > MAX_COMMAND_DISPLAY_LENGTH: + command = command[:MAX_COMMAND_DISPLAY_LENGTH - 3] + "..." summary = f"Bash ({command})" return ToolCallDisplay(summary=summary) diff --git a/revibe/core/tools/builtins/grep.py b/revibe/core/tools/builtins/grep.py index 2ef7a0f..56643e2 100644 --- a/revibe/core/tools/builtins/grep.py +++ b/revibe/core/tools/builtins/grep.py @@ -21,7 +21,7 @@ from revibe.core.types import ToolCallEvent, ToolResultEvent -class GrepBackend(StrEnum): +class FindBackend(StrEnum): RIPGREP = auto() GNU_GREP = auto() @@ -66,9 +66,9 @@ class GrepToolConfig(BaseToolConfig): ], description="List of glob patterns to exclude from search (dirs should end with /).", ) - codeignore_file: str = Field( - default=".vibeignore", - description="Name of the file to read for additional exclusion patterns.", + ignore_file: str = Field( + default=".revibeignore", + description="Path to a file containing ignore patterns (glob format).", ) @@ -95,20 +95,20 @@ class GrepResult(BaseModel): ) -class Grep( +class Find( BaseTool[GrepArgs, GrepResult, GrepToolConfig, GrepState], ToolUIData[GrepArgs, GrepResult], ): description: ClassVar[str] = ( "Recursively search files for a regex pattern using ripgrep (rg) or grep. " - "Respects .gitignore and .codeignore files by default when using ripgrep." + "Respects .gitignore and .ignore files by default when using ripgrep." ) - def _detect_backend(self) -> GrepBackend: + def _detect_backend(self) -> FindBackend: if shutil.which("rg"): - return GrepBackend.RIPGREP + return FindBackend.RIPGREP if shutil.which("grep"): - return GrepBackend.GNU_GREP + return FindBackend.GNU_GREP raise ToolError( "Neither ripgrep (rg) nor grep is installed. " "Please install ripgrep: https://github.com/BurntSushi/ripgrep#installation" @@ -141,7 +141,7 @@ def _validate_args(self, args: GrepArgs) -> None: def _collect_exclude_patterns(self) -> list[str]: patterns = list(self.config.exclude_patterns) - codeignore_path = self.config.effective_workdir / self.config.codeignore_file + codeignore_path = self.config.effective_workdir / self.config.ignore_file if codeignore_path.is_file(): patterns.extend(self._load_codeignore_patterns(codeignore_path)) @@ -161,9 +161,9 @@ def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]: return patterns def _build_command( - self, args: GrepArgs, exclude_patterns: list[str], backend: GrepBackend + self, args: GrepArgs, exclude_patterns: list[str], backend: FindBackend ) -> list[str]: - if backend == GrepBackend.RIPGREP: + if backend == FindBackend.RIPGREP: return self._build_ripgrep_command(args, exclude_patterns) return self._build_gnu_grep_command(args, exclude_patterns) @@ -274,13 +274,14 @@ def _parse_output(self, stdout: str, max_matches: int) -> GrepResult: @classmethod def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: if not isinstance(event.args, GrepArgs): - return ToolCallDisplay(summary="Grep") + return ToolCallDisplay(summary="Find") + MAX_PATTERN_DISPLAY_LENGTH = 20 pattern = event.args.pattern - if len(pattern) > 20: - pattern = pattern[:17] + "..." + if len(pattern) > MAX_PATTERN_DISPLAY_LENGTH: + pattern = pattern[: MAX_PATTERN_DISPLAY_LENGTH - 3] + "..." - summary = f"Grep ({pattern})" + summary = f"Find ({pattern})" return ToolCallDisplay(summary=summary) @classmethod @@ -302,4 +303,4 @@ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: @classmethod def get_status_text(cls) -> str: - return "Searching files" + return "Finding files" diff --git a/revibe/core/tools/builtins/prompts/grep.md b/revibe/core/tools/builtins/prompts/grep.md index 06ab685..b063134 100644 --- a/revibe/core/tools/builtins/prompts/grep.md +++ b/revibe/core/tools/builtins/prompts/grep.md @@ -1,35 +1,53 @@ -# Grep Tool – Codebase Search Assistant +# 🔍 FIND TOOL - Your Primary Search Command -Use `grep` for fast, recursive regex searches across the project. It automatically leverages `rg` (ripgrep) when available and falls back to GNU `grep` otherwise. It already ignores common junk directories (`.git`, `.venv`, `node_modules`, etc.) and respects `.gitignore` plus `.vibeignore` entries. +## 🚫 CRITICAL: NEVER USE BASH FOR SEARCHING + +**DO NOT use `bash` tool with `grep`, `find`, `rg`, `ack`, or any shell search commands.** This `find` tool is specifically designed for all search operations and is far superior to shell commands. + +## Why Use This Tool (NOT Bash) +- ✅ **Cross-platform** - Works identically on Windows, macOS, Linux +- ✅ **Smart ignores** - Automatically respects `.gitignore`, `.revibeignore` +- ✅ **Fast & safe** - Uses ripgrep when available, with built-in timeouts +- ✅ **Structured output** - Returns clean, parseable results +- ✅ **No shell injection risks** - Safe parameter handling +- ✅ **Better error handling** - Clear error messages and truncation detection ## Arguments -- `pattern` *(str, required)* – Regex pattern (smart-case). Empty strings are rejected. -- `path` *(str, default ".")* – File or directory to search. -- `max_matches` *(int | None)* – Cap the number of matches (default 100). Request a larger window if needed. -- `use_default_ignore` *(bool, default True)* – Set to `false` to bypass `.gitignore`/`.ignore` rules. - -## When to Use -- Locate function or class definitions before editing. -- See how a symbol, constant, or error string is used across the repo. -- Discover todos, feature flags, or configuration references. -- Investigate build/test failures by searching logs or stack traces. - -## Tips for Better Results -1. Narrow `path` when possible (`src/feature`, `tests/unit`). -2. Use anchors or word boundaries for precision (e.g., `pattern="\bMyClass\b"`). -3. If output is truncated (`was_truncated=True`), rerun with a higher `max_matches` or narrower `path`. -4. Disable default ignore rules (`use_default_ignore=False`) only when you truly need to search generated or vendored code. - -## Example Calls +- `pattern` *(str, required)* – Regex pattern to search for +- `path` *(str, default ".")* – Directory/file to search in +- `max_matches` *(int, default 100)* – Maximum results to return +- `use_default_ignore` *(bool, default True)* – Respect .gitignore rules + +## When to Use This Tool +**ALWAYS use this tool for:** +- Finding function/class definitions +- Searching for variable or method usage +- Looking for TODO comments or error messages +- Finding configuration references +- Searching log files or test outputs +- Any text search across files + +## Common Search Patterns ```python -# Find all usages of a helper -grep(pattern="def build_payload", path="revibe/core") +# Find a function definition +find(pattern=r"def my_function", path="src") + +# Search for class usage with word boundaries +find(pattern=r"\bMyClass\b", path=".") -# Search entire repo for TODOs, including ignored files -grep(pattern="TODO", path=".", use_default_ignore=False) +# Find all TODO comments +find(pattern="TODO", path=".", max_matches=50) -# Look for specific error messages with more results -grep(pattern="ConnectionError", path="logs", max_matches=250) +# Search for error messages in logs +find(pattern="ERROR.*connection", path="logs") + +# Find configuration keys +find(pattern="API_KEY", path="config") ``` -`grep` returns `matches`, `match_count`, and `was_truncated`. If `was_truncated` is true, adjust your query and search again. \ No newline at end of file +## Output Format +Returns: `matches` (string), `match_count` (int), `was_truncated` (bool) + +If `was_truncated=True`, increase `max_matches` or narrow the search path. + +## ⚠️ Reminder: This tool replaces ALL bash search commands \ No newline at end of file diff --git a/revibe/core/tools/builtins/prompts/grep_xml.md b/revibe/core/tools/builtins/prompts/grep_xml.md index 1784e15..95e36ac 100644 --- a/revibe/core/tools/builtins/prompts/grep_xml.md +++ b/revibe/core/tools/builtins/prompts/grep_xml.md @@ -1,23 +1,31 @@ -# Grep Tool – XML Format Guide +# 🚫 STOP! USE THIS TOOL FOR ALL SEARCHING -## ⚠️ ALWAYS USE THIS TOOL FOR SEARCHING +## ⚠️ ABSOLUTELY FORBIDDEN: Do NOT use bash tool for searching -**NEVER use `bash` with `grep`, `find`, `rg`, `Select-String`, or any shell search commands.** -This `grep` tool is cross-platform, faster, and automatically handles: -- `.gitignore` rules -- Binary file exclusions -- Common junk directories (node_modules, .git, __pycache__, etc.) +**NEVER, EVER use the `bash` tool with:** +- `grep`, `find`, `rg`, `ack`, `ag` +- `cat file | grep` +- `find . -name "*.py" | xargs grep` +- Any shell search commands -Use `grep` for fast, recursive regex searches across the project. +This `find` tool is your ONLY option for searching. It is designed to be superior in every way. -## XML Tool Call Format +## Why This Tool Beats Bash Searching +- 🚀 **Faster** - Uses ripgrep (fastest search tool available) +- 🛡️ **Safer** - No shell injection vulnerabilities +- 🌐 **Cross-platform** - Works identically everywhere +- 🎯 **Smart filtering** - Auto-ignores junk files and respects .gitignore +- 📊 **Structured results** - Clean, parseable output +- ⏱️ **Timeout protection** - Won't hang your session +- 🔍 **Better regex** - Full regex support with smart case sensitivity +## XML Format (REQUIRED) ```xml -grep +find -your regex pattern -directory or file to search +your_regex_pattern +. 100 true @@ -25,35 +33,44 @@ Use `grep` for fast, recursive regex searches across the project. ``` ## Parameters -- `pattern` *(required)* – Regex pattern (smart-case) -- `path` *(optional, default ".")* – File or directory to search -- `max_matches` *(optional, default 100)* – Cap number of matches -- `use_default_ignore` *(optional, default true)* – Use .gitignore rules +- `pattern` *(REQUIRED)* – The regex pattern to search for +- `path` *(default ".")* – Directory or file to search +- `max_matches` *(default 100)* – How many results to return +- `use_default_ignore` *(default true)* – Respect .gitignore rules -## When to Use (PREFER THIS OVER BASH) -- **Finding files containing text** → Use `grep`, NOT `bash` with `find | xargs grep` -- **Searching for patterns** → Use `grep`, NOT `bash` with shell grep commands -- **Locating function/class definitions** → Use `grep` -- **Finding symbol usage across codebase** → Use `grep` -- **Discovering TODOs, feature flags, config references** → Use `grep` -- **Investigating errors in logs** → Use `grep` -- **Finding files by name patterns** → Use `grep` with filename patterns +## When to Use This Tool (MANDATORY) +**You MUST use this tool for ALL searching:** +- Finding function definitions: `find(pattern="def function_name")` +- Finding class usage: `find(pattern="\\bClassName\\b")` +- Searching for TODOs: `find(pattern="TODO")` +- Finding error messages: `find(pattern="ERROR")` +- Looking for configuration: `find(pattern="API_KEY")` +- Any text search in files ## Example XML Calls ```xml - + + +find + +def process_data +src + + + + -grep +find -def build_payload -revibe/core +\bUserModel\b +. - + -grep +find TODO . @@ -61,17 +78,20 @@ Use `grep` for fast, recursive regex searches across the project. - + -grep +find -\bToolManager\b -revibe +ERROR.*timeout +logs ``` -## Tips -- Narrow `path` for faster, focused results -- Use word boundaries `\b` for precision -- If `was_truncated=True`, increase `max_matches` or narrow scope +## Critical Rules +- **ALWAYS** use this tool instead of bash searching +- If results are truncated (`was_truncated=true`), increase `max_matches` +- Use word boundaries (`\b`) for exact matches +- Narrow `path` for faster, more focused results + +## 🚫 FINAL WARNING: Using bash for searching will be incorrect and inefficient diff --git a/revibe/core/tools/builtins/prompts/todo.md b/revibe/core/tools/builtins/prompts/todo.md index 3c1c36a..38e1092 100644 --- a/revibe/core/tools/builtins/prompts/todo.md +++ b/revibe/core/tools/builtins/prompts/todo.md @@ -1,75 +1,64 @@ # Todo Tool – Lightweight Task Tracker -Use the `todo` tool to capture, update, and report progress on multi-step assignments. Maintaining a live checklist demonstrates planning discipline and makes it easy to resume work after interruptions. +Create and manage structured task lists for complex coding sessions. -## Interface Recap -- `action: "read"` – Return the current todo list. -- `action: "write"` – Replace the entire list with the provided `todos` array. You **must** send every task you want to keep; omissions are treated as deletions. +## When to Use +- Task has 3+ steps or multiple components +- User requests a todo list explicitly +- Breaking down user requirements into actionable items +- Tracking progress on multi-step implementations -Each todo entry requires: +## When NOT to Use +- Single, trivial tasks +- Purely informational queries +- Tasks completable in <3 steps + +## Interface +- `action: "read"` – Return current todo list +- `action: "write"` – Replace entire list with provided `todos` array + +## Todo Fields | Field | Type | Notes | | ----- | ---- | ----- | -| `id` | str | Unique identifier ("1", "setup", etc.). | -| `content` | str | Clear, actionable description. | -| `status` | `pending` \| `in_progress` \| `completed` \| `cancelled` | Default `pending`. Only one task should be `in_progress`. | -| `priority` | `low` \| `medium` \| `high` | Default `medium`. | +| `id` | str | Unique identifier | +| `content` | str | Clear, actionable description | +| `status` | `pending` \| `in_progress` \| `completed` \| `cancelled` | Only one `in_progress` | +| `priority` | `low` \| `medium` \| `high` | Default `medium` | -## When to Create / Update Todos -- User provides multiple requirements or a numbered list. -- Work spans more than a couple of straightforward steps. -- You need to track investigations, blockers, or follow-ups. -- After finishing a task to log completion and next actions. - -## Good Habits -1. **Initialize early:** After understanding the request, capture the major steps before diving into code. -2. **One task in progress:** Switch statuses proactively as you start/finish work. -3. **Reflect reality:** If you discover new subtasks or blockers, add them immediately. -4. **Keep descriptions precise:** Include file names, modules, or acceptance criteria so the intent is clear to reviewers. -5. **Close the loop:** Mark tasks `completed` only when changes are merged/tested according to the acceptance criteria. +## Best Practices +1. Initialize todos early after understanding requirements +2. Keep only one task `in_progress` at a time +3. Update todos as you discover new subtasks +4. Mark tasks `completed` only when fully done +5. Use clear descriptions with file names/modules ## Examples -### Reading current list -```json -{ - "action": "read" -} -``` -### Creating a plan +### Read current list ```json -{ - "action": "write", - "todos": [ - {"id": "1", "content": "Audit builtin tool prompts", "status": "pending", "priority": "high"}, - {"id": "2", "content": "Improve bash + grep prompts", "status": "pending", "priority": "high"}, - {"id": "3", "content": "Update remaining tool prompts", "status": "pending", "priority": "medium"} - ] -} +{"action": "read"} ``` -### Marking progress and adding discoveries +### Create plan ```json { "action": "write", "todos": [ - {"id": "1", "content": "Audit builtin tool prompts", "status": "completed", "priority": "high"}, - {"id": "2", "content": "Improve bash + grep prompts", "status": "in_progress", "priority": "high"}, - {"id": "3", "content": "Update remaining tool prompts", "status": "pending", "priority": "medium"}, - {"id": "4", "content": "Document testing expectations", "status": "pending", "priority": "medium"} + {"id": "1", "content": "Review existing code", "status": "pending", "priority": "high"}, + {"id": "2", "content": "Implement feature X", "status": "in_progress", "priority": "high"}, + {"id": "3", "content": "Write tests", "status": "pending", "priority": "medium"} ] } ``` -### Wrapping up +### Update progress ```json { "action": "write", "todos": [ - {"id": "2", "content": "Improve bash + grep prompts", "status": "completed", "priority": "high"}, - {"id": "3", "content": "Update remaining tool prompts", "status": "completed", "priority": "medium"}, - {"id": "4", "content": "Document testing expectations", "status": "completed", "priority": "medium"} + {"id": "1", "content": "Review existing code", "status": "completed", "priority": "high"}, + {"id": "2", "content": "Implement feature X", "status": "completed", "priority": "high"}, + {"id": "3", "content": "Write tests", "status": "in_progress", "priority": "medium"} ] } -``` - -Maintain this list diligently to show deliberate planning and ensure no requirement is overlooked. \ No newline at end of file +``` \ No newline at end of file diff --git a/revibe/core/tools/builtins/prompts/todo_xml.md b/revibe/core/tools/builtins/prompts/todo_xml.md index b96dcdb..a8c568f 100644 --- a/revibe/core/tools/builtins/prompts/todo_xml.md +++ b/revibe/core/tools/builtins/prompts/todo_xml.md @@ -1,6 +1,17 @@ # Todo Tool – XML Format Guide -Use the `todo` tool to track multi-step assignments with a live checklist. +Create and manage structured task lists for complex coding sessions. + +## When to Use +- Task has 3+ steps or multiple components +- User requests a todo list explicitly +- Breaking down user requirements into actionable items +- Tracking progress on multi-step implementations + +## When NOT to Use +- Single, trivial tasks +- Purely informational queries +- Tasks completable in <3 steps ## XML Tool Call Format @@ -36,9 +47,16 @@ Use the `todo` tool to track multi-step assignments with a live checklist. | ----- | ---- | ----- | | `id` | str | Unique identifier | | `content` | str | Clear, actionable description | -| `status` | `pending` \| `in_progress` \| `completed` \| `cancelled` | Default `pending` | +| `status` | `pending` \| `in_progress` \| `completed` \| `cancelled` | Only one `in_progress` | | `priority` | `low` \| `medium` \| `high` | Default `medium` | +## Best Practices +- Initialize todos early after understanding requirements +- Keep only one task `in_progress` at a time +- Update todos as you discover new subtasks +- Mark tasks `completed` only when fully done +- Use clear descriptions with file names/modules + ## Example XML Calls ```xml @@ -76,9 +94,3 @@ Use the `todo` tool to track multi-step assignments with a live checklist. ``` - -## Best Practices -- Initialize todos early after understanding requirements -- Keep only one task `in_progress` at a time -- Update todos as you discover new subtasks -- Mark tasks `completed` only when fully done diff --git a/revibe/setup/onboarding/__init__.py b/revibe/setup/onboarding/__init__.py index 502a087..0149c73 100644 --- a/revibe/setup/onboarding/__init__.py +++ b/revibe/setup/onboarding/__init__.py @@ -52,4 +52,5 @@ def run_onboarding(app: App | None = None) -> None: f"You may need to set it manually in {GLOBAL_ENV_FILE.path}[/]\n" ) case "completed": - pass + rprint("\n[green]Setup completed![/]") + rprint("[dim]Use 'revibe' to start using ReVibe.[/]") diff --git a/revibe/setup/onboarding/screens/api_key.py b/revibe/setup/onboarding/screens/api_key.py index 0ede38b..fb7cd40 100644 --- a/revibe/setup/onboarding/screens/api_key.py +++ b/revibe/setup/onboarding/screens/api_key.py @@ -4,15 +4,16 @@ from typing import ClassVar from dotenv import set_key +from pydantic import TypeAdapter from textual.app import ComposeResult from textual.binding import Binding, BindingType from textual.containers import Center, Horizontal, Vertical -from textual.events import MouseUp +from textual.timer import Timer from textual.validation import Length -from textual.widgets import Input, Link, Static +from textual.widgets import Button, Input, Link, Static -from revibe.cli.clipboard import copy_selection_to_clipboard -from revibe.core.config import VibeConfig +from revibe.core.config import DEFAULT_PROVIDERS, ProviderConfigUnion, VibeConfig +from revibe.core.model_config import DEFAULT_MODELS, ModelConfig from revibe.core.paths.global_paths import GLOBAL_ENV_FILE from revibe.setup.onboarding.base import OnboardingScreen @@ -21,10 +22,17 @@ "openai": ("https://platform.openai.com/api-keys", "OpenAI Platform"), "anthropic": ("https://console.anthropic.com/settings/keys", "Anthropic Console"), "groq": ("https://console.groq.com/keys", "Groq Console"), + "huggingface": ("https://huggingface.co/settings/tokens", "Hugging Face Settings"), + "cerebras": ( + "https://cloud.cerebras.ai/platform/api-keys", + "Cerebras Cloud Platform", + ), } -CONFIG_DOCS_URL = ( - "https://github.com/OEvortex/revibe?tab=readme-ov-file#configuration" -) +CONFIG_DOCS_URL = "https://github.com/OEvortex/revibe?tab=readme-ov-file#configuration" + + +MODEL_CONFIG_ADAPTER = TypeAdapter(list[ModelConfig]) +PROVIDER_ADAPTER = TypeAdapter(list[ProviderConfigUnion]) def _save_api_key_to_env_file(env_key: str, api_key: str) -> None: @@ -32,10 +40,33 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None: set_key(GLOBAL_ENV_FILE.path, env_key, api_key) +GRADIENT_COLORS = [ + "#ff6b00", + "#ff7b00", + "#ff8c00", + "#ff9d00", + "#ffae00", + "#ffbf00", + "#ffae00", + "#ff9d00", + "#ff8c00", + "#ff7b00", +] + + +def _apply_gradient(text: str, offset: int) -> str: + result = [] + for i, char in enumerate(text): + color = GRADIENT_COLORS[(i + offset) % len(GRADIENT_COLORS)] + result.append(f"[bold {color}]{char}[/]") + return "".join(result) + + class ApiKeyScreen(OnboardingScreen): BINDINGS: ClassVar[list[BindingType]] = [ Binding("ctrl+c", "cancel", "Cancel", show=False), Binding("escape", "cancel", "Cancel", show=False), + Binding("enter", "finish", "Finish", show=False), ] NEXT_SCREEN = None @@ -44,6 +75,8 @@ def __init__(self) -> None: super().__init__() # Provider will be loaded when screen is shown self.provider = None + self._gradient_offset = 0 + self._gradient_timer: Timer | None = None def _load_config(self) -> VibeConfig: """Load config, handling missing API key since we're in setup. @@ -54,6 +87,25 @@ def _load_config(self) -> VibeConfig: from revibe.core.config import TomlFileSettingsSource toml_data = TomlFileSettingsSource(VibeConfig).toml_data + + if "models" in toml_data: + toml_data["models"] = [ModelConfig(**item) for item in toml_data["models"]] + else: + toml_data["models"] = list(DEFAULT_MODELS) + + # Merge default models if not present + existing_keys = {(m.name, m.provider) for m in toml_data["models"]} + for m in DEFAULT_MODELS: + if (m.name, m.provider) not in existing_keys: + toml_data["models"].append(m) + + if "providers" in toml_data: + toml_data["providers"] = PROVIDER_ADAPTER.validate_python( + toml_data["providers"] + ) + else: + toml_data["providers"] = list(DEFAULT_PROVIDERS) + return VibeConfig.model_construct(**toml_data) def on_show(self) -> None: @@ -84,6 +136,21 @@ def _compose_config_docs(self) -> ComposeResult: classes="link-row", ) + def _compose_no_api_key_content(self) -> ComposeResult: + if not self.provider: + return + yield Static( + f"{self.provider.name.capitalize()} does not require an API key.", + id="no-api-key-message", + ) + if self.provider.name == "qwencode": + yield Static( + "Please install qwen-code if not installed: `npm install -g @qwen-code/qwen-code@latest`\n" + "then use `/auth` in qwen to authenticate, then you can close qwen and use qwencode provider in ReVibe", + id="qwen-instructions", + ) + yield Static("", id="feedback") + def compose(self) -> ComposeResult: # Ensure provider is loaded (in case on_show hasn't been called yet) if self.provider is None: @@ -91,7 +158,16 @@ def compose(self) -> ComposeResult: active_model = config.get_active_model() self.provider = config.get_provider_for_model(active_model) - provider_name = self.provider.name.capitalize() + # Skip API key input for providers that don't require it + if not getattr(self.provider, "api_key_env_var", ""): + with Vertical(id="api-key-outer"): + yield Static("", classes="spacer") + yield Center(Static("", id="api-key-title")) + with Center(): + with Vertical(id="api-key-content"): + yield from self._compose_no_api_key_content() + yield Static("", classes="spacer") + return self.input_widget = Input( password=True, @@ -105,20 +181,32 @@ def compose(self) -> ComposeResult: yield Center(Static("One last thing...", id="api-key-title")) with Center(): with Vertical(id="api-key-content"): - yield from self._compose_provider_link(provider_name) - yield Static( - "...and paste it below to finish the setup:", id="paste-hint" - ) - yield Center(Horizontal(self.input_widget, id="input-box")) - yield Static("", id="feedback") + yield from self._compose_no_api_key_content() yield Static("", classes="spacer") yield Vertical( Vertical(*self._compose_config_docs(), id="config-docs-group"), id="config-docs-section", ) + def _start_gradient_animation(self) -> None: + self._gradient_timer = self.set_interval(0.08, self._animate_gradient) + + def _animate_gradient(self) -> None: + self._gradient_offset = (self._gradient_offset + 1) % len(GRADIENT_COLORS) + title_widget = self.query_one("#api-key-title", Static) + title_widget.update(self._render_title()) + + def _render_title(self) -> str: + title = "Setup Completed" + return _apply_gradient(title, self._gradient_offset) + def on_mount(self) -> None: - self.input_widget.focus() + title_widget = self.query_one("#api-key-title", Static) + if title_widget: + title_widget.update(self._render_title()) + self._start_gradient_animation() + if hasattr(self, "input_widget") and self.input_widget: + self.input_widget.focus() def on_input_changed(self, event: Input.Changed) -> None: feedback = self.query_one("#feedback", Static) @@ -157,5 +245,9 @@ def _save_and_finish(self, api_key: str) -> None: return self.app.exit("completed") - def on_mouse_up(self, event: MouseUp) -> None: - copy_selection_to_clipboard(self.app) + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "continue-button": + self.app.exit("completed") + + def action_finish(self) -> None: + self.app.exit("completed") diff --git a/revibe/setup/trusted_folders/trust_folder_dialog.py b/revibe/setup/trusted_folders/trust_folder_dialog.py index d77774f..901f9f8 100644 --- a/revibe/setup/trusted_folders/trust_folder_dialog.py +++ b/revibe/setup/trusted_folders/trust_folder_dialog.py @@ -56,26 +56,25 @@ def compose(self) -> ComposeResult: id="trust-dialog-path", classes="trust-dialog-path", ) - yield Static( - "A .vibe/ directory was found here. Should Vibe load custom configuration and tools from it?", - id="trust-dialog-message", - classes="trust-dialog-message", - ) - - with Horizontal(id="trust-options-container"): - options = ["Yes", "No"] - for idx, text in enumerate(options): - widget = Static(f" {idx + 1}. {text}", classes="trust-option") - self.option_widgets.append(widget) - yield widget - - yield Static("← → navigate Enter select", classes="trust-dialog-help") - - yield Static( - f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}", - id="trust-dialog-save-info", - classes="trust-dialog-save-info", - ) + yield Static( + "A .revibe/ directory was found here. Should ReVibe load custom configuration and tools from it?", + id="title", + ) + + with Horizontal(id="trust-options-container"): + options = ["Yes", "No"] + for idx, text in enumerate(options): + widget = Static(f" {idx + 1}. {text}", classes="trust-option") + self.option_widgets.append(widget) + yield widget + + yield Static("← → navigate Enter select", classes="trust-dialog-help") + + yield Static( + f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}", + id="trust-dialog-save-info", + classes="trust-dialog-save-info", + ) async def on_mount(self) -> None: self.selected_option = 1 # Default to "No" diff --git a/scripts/install.sh b/scripts/install.sh index e8030f7..dd14d09 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -82,45 +82,27 @@ function install_vibe() { info "Installing revibe from GitHub repository using uv..." uv tool install revibe - success "REVIBE installed successfully! (commands: vibe, vibe-acp)" + success "REVIBE installed successfully! (commands: revibe, revibe-acp)" } +# --- Main Script --- + function main() { - echo - echo "██████████████████░░" - echo "██████████████████░░" - echo "████ ██████ ████░░" - echo "████ ██ ████░░" - echo "████ ████░░" - echo "████ ██ ██ ████░░" - echo "██ ██ ██░░" - echo "██████████████████░░" - echo "██████████████████░░" - echo echo "Starting REVIBE installation..." - echo - - check_platform - - check_uv_installed - - if [[ "$UV_INSTALLED" == "false" ]]; then - install_uv - fi - + + check_uv install_vibe - - if command -v vibe &> /dev/null; then - success "Installation completed successfully!" - echo - echo "You can now run vibe with:" - echo " vibe" - echo - echo "Or for ACP mode:" - echo " vibe-acp" + + if command -v revibe &> /dev/null; then + echo "" + success "Installation complete!" + echo "You can now run revibe with:" + echo " revibe" + echo "" + echo "Or start the ACP server for your IDE with:" + echo " revibe-acp" else - error "Installation completed but 'vibe' command not found" - error "Please check your installation and PATH settings" + error "Installation completed but 'revibe' command not found" exit 1 fi } diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index 5dd3723..10c5c4b 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -5,7 +5,7 @@ import json import os from pathlib import Path -from typing import Any, cast +from typing import Any from acp import ( InitializeRequest, @@ -64,7 +64,7 @@ def deep_merge(target: dict, source: dict) -> None: def _create_vibe_home_dir(tmp_path: Path, *sections: dict[str, Any]) -> Path: """Create a temporary vibe home directory with a minimal config file.""" - vibe_home = tmp_path / ".vibe" + vibe_home = tmp_path / ".revibe" vibe_home.mkdir() config_file = vibe_home / "config.toml" @@ -198,7 +198,7 @@ async def get_acp_agent_process( env = dict(current_env) env.update(mock_env) env["MISTRAL_API_KEY"] = "mock" - env["VIBE_HOME"] = str(vibe_home) + env["REVIBE_HOME"] = str(vibe_home) process = await asyncio.create_subprocess_exec( *cmd, diff --git a/tests/acp/test_content.py b/tests/acp/test_content.py index 8c554c4..75589c5 100644 --- a/tests/acp/test_content.py +++ b/tests/acp/test_content.py @@ -37,7 +37,7 @@ def __init__(self, *args, **kwargs) -> None: kwargs["backend"] = backend super().__init__(*args, **kwargs) - patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() + patch("revibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() vibe_acp_agent: VibeAcpAgent | None = None diff --git a/tests/acp/test_multi_session.py b/tests/acp/test_multi_session.py index b5f1c40..fcfb180 100644 --- a/tests/acp/test_multi_session.py +++ b/tests/acp/test_multi_session.py @@ -49,7 +49,7 @@ def __init__(self, *args, **kwargs) -> None: self.backend = backend self.config = config - patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() + patch("revibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() vibe_acp_agent: VibeAcpAgent | None = None diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index 47c492b..e8bfe73 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -45,7 +45,7 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **{**kwargs, "backend": backend}) self.config = config - patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() + patch("revibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() vibe_acp_agent: VibeAcpAgent | None = None diff --git a/tests/acp/test_set_mode.py b/tests/acp/test_set_mode.py index 6107b8f..f4f045f 100644 --- a/tests/acp/test_set_mode.py +++ b/tests/acp/test_set_mode.py @@ -33,7 +33,7 @@ def __init__(self, *args, **kwargs) -> None: kwargs['backend'] = backend super().__init__(*args, **kwargs) - patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() + patch("revibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() vibe_acp_agent: VibeAcpAgent | None = None diff --git a/tests/acp/test_set_model.py b/tests/acp/test_set_model.py index 3960639..1b3ed85 100644 --- a/tests/acp/test_set_model.py +++ b/tests/acp/test_set_model.py @@ -62,7 +62,7 @@ def __init__(self, *args, **kwargs) -> None: except ValueError: pass - patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() + patch("revibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start() vibe_acp_agent: VibeAcpAgent | None = None @@ -148,7 +148,7 @@ async def test_set_model_saves_to_config(self, acp_agent: VibeAcpAgent) -> None: ) session_id = session_response.sessionId - with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save: + with patch("revibe.acp.acp_agent.VibeConfig.save_updates") as mock_save: response = await acp_agent.setSessionModel( SetSessionModelRequest(sessionId=session_id, modelId="devstral-small") ) @@ -165,7 +165,7 @@ async def test_set_model_does_not_save_on_invalid_model( ) session_id = session_response.sessionId - with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save: + with patch("revibe.acp.acp_agent.VibeConfig.save_updates") as mock_save: response = await acp_agent.setSessionModel( SetSessionModelRequest( sessionId=session_id, modelId="non-existent-model" diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py index b314af6..ba6229d 100644 --- a/tests/cli/test_clipboard.py +++ b/tests/cli/test_clipboard.py @@ -86,7 +86,7 @@ def test_copy_selection_to_clipboard_no_notification( mock_app.notify.assert_not_called() -@patch("vibe.cli.clipboard._get_copy_fns") +@patch("revibe.cli.clipboard._get_copy_fns") def test_copy_selection_to_clipboard_success( mock_get_copy_fns: MagicMock, mock_app: MagicMock ) -> None: @@ -106,7 +106,7 @@ def test_copy_selection_to_clipboard_success( ) -@patch("vibe.cli.clipboard._get_copy_fns") +@patch("revibe.cli.clipboard._get_copy_fns") def test_copy_selection_to_clipboard_tries_all( mock_get_copy_fns: MagicMock, mock_app: MagicMock ) -> None: @@ -130,7 +130,7 @@ def test_copy_selection_to_clipboard_tries_all( ) -@patch("vibe.cli.clipboard._get_copy_fns") +@patch("revibe.cli.clipboard._get_copy_fns") def test_copy_selection_to_clipboard_all_methods_fail( mock_get_copy_fns: MagicMock, mock_app: MagicMock ) -> None: @@ -165,7 +165,7 @@ 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: + with patch("revibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns: mock_copy_fn = MagicMock() mock_get_copy_fns.return_value = [mock_copy_fn] copy_selection_to_clipboard(mock_app) @@ -185,7 +185,7 @@ 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: + with patch("revibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns: mock_copy_fn = MagicMock() mock_get_copy_fns.return_value = [mock_copy_fn] copy_selection_to_clipboard(mock_app) @@ -230,7 +230,7 @@ def test_copy_osc52_with_tmux( handle.write.assert_called_once_with(expected_seq) -@patch("vibe.cli.clipboard.subprocess.run") +@patch("revibe.cli.clipboard.subprocess.run") def test_copy_x11_clipboard(mock_subprocess: MagicMock) -> None: test_text = "test text" @@ -243,7 +243,7 @@ def test_copy_x11_clipboard(mock_subprocess: MagicMock) -> None: ) -@patch("vibe.cli.clipboard.subprocess.run") +@patch("revibe.cli.clipboard.subprocess.run") def test_copy_wayland_clipboard(mock_subprocess: MagicMock) -> None: test_text = "test text" @@ -254,7 +254,7 @@ def test_copy_wayland_clipboard(mock_subprocess: MagicMock) -> None: ) -@patch("vibe.cli.clipboard.shutil.which") +@patch("revibe.cli.clipboard.shutil.which") def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> None: mock_which.return_value = None @@ -266,7 +266,7 @@ def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> N assert copy_fns[2] == mock_app.copy_to_clipboard -@patch("vibe.cli.clipboard.shutil.which") +@patch("revibe.cli.clipboard.shutil.which") def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None: def which_side_effect(cmd: str) -> str | None: return "/usr/bin/xclip" if cmd == "xclip" else None @@ -282,7 +282,7 @@ def which_side_effect(cmd: str) -> str | None: assert copy_fns[3] == mock_app.copy_to_clipboard -@patch("vibe.cli.clipboard.shutil.which") +@patch("revibe.cli.clipboard.shutil.which") def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None: def which_side_effect(cmd: str) -> str | None: return "/usr/bin/wl-copy" if cmd == "wl-copy" else None @@ -298,7 +298,7 @@ def which_side_effect(cmd: str) -> str | None: assert copy_fns[3] == mock_app.copy_to_clipboard -@patch("vibe.cli.clipboard.shutil.which") +@patch("revibe.cli.clipboard.shutil.which") def test_get_copy_fns_with_both_system_tools( mock_which: MagicMock, mock_app: App ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 8536043..dec8cc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,13 +45,13 @@ def tmp_working_directory( def config_dir( monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory ) -> Path: - tmp_path = tmp_path_factory.mktemp("vibe") - config_dir = tmp_path / ".vibe" + tmp_path = tmp_path_factory.mktemp("revibe") + config_dir = tmp_path / ".revibe" config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.toml" config_file.write_text(tomli_w.dumps(get_base_config()), encoding="utf-8") - monkeypatch.setattr(global_paths, "_DEFAULT_VIBE_HOME", config_dir) + monkeypatch.setattr(global_paths, "_DEFAULT_REVIBE_HOME", config_dir) return config_dir diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index 5ee74ce..6504f1a 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -5,7 +5,7 @@ import pytest from revibe.core.paths.config_paths import CONFIG_FILE -from revibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, VIBE_HOME +from revibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, REVIBE_HOME from revibe.core.trusted_folders import trusted_folders_manager @@ -14,7 +14,7 @@ def test_resolves_local_config_when_exists_and_folder_is_trusted( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) - local_config_dir = tmp_path / ".vibe" + local_config_dir = tmp_path / ".revibe" local_config_dir.mkdir() local_config = local_config_dir / "config.toml" local_config.write_text('active_model = "test"', encoding="utf-8") @@ -29,7 +29,7 @@ def test_resolves_local_config_when_exists_and_folder_is_not_trusted( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) - local_config_dir = tmp_path / ".vibe" + local_config_dir = tmp_path / ".revibe" local_config_dir.mkdir() local_config = local_config_dir / "config.toml" local_config.write_text('active_model = "test"', encoding="utf-8") @@ -41,13 +41,13 @@ def test_falls_back_to_global_config_when_local_missing( ) -> None: monkeypatch.chdir(tmp_path) # Ensure no local config exists - assert not (tmp_path / ".vibe" / "config.toml").exists() + assert not (tmp_path / ".revibe" / "config.toml").exists() assert CONFIG_FILE.path == GLOBAL_CONFIG_FILE.path - def test_respects_vibe_home_env_var( + def test_respects_revibe_home_env_var( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - assert VIBE_HOME.path != tmp_path - monkeypatch.setenv("VIBE_HOME", str(tmp_path)) - assert VIBE_HOME.path == tmp_path + assert REVIBE_HOME.path != tmp_path + monkeypatch.setenv("REVIBE_HOME", str(tmp_path)) + assert REVIBE_HOME.path == tmp_path diff --git a/tests/mock/mock_entrypoint.py b/tests/mock/mock_entrypoint.py index 40ec75d..5792d60 100644 --- a/tests/mock/mock_entrypoint.py +++ b/tests/mock/mock_entrypoint.py @@ -44,19 +44,19 @@ async def mock_complete_streaming(*args, **kwargs) -> AsyncGenerator[LLMChunk]: yield next(chunk_iterable) patch( - "vibe.core.llm.backend.mistral.MistralBackend.complete", + "revibe.core.llm.backend.mistral.MistralBackend.complete", side_effect=mock_complete, ).start() patch( - "vibe.core.llm.backend.generic.GenericBackend.complete", + "revibe.core.llm.backend.openai.OpenAIBackend.complete", side_effect=mock_complete, ).start() patch( - "vibe.core.llm.backend.mistral.MistralBackend.complete_streaming", + "revibe.core.llm.backend.mistral.MistralBackend.complete_streaming", side_effect=mock_complete_streaming, ).start() patch( - "vibe.core.llm.backend.generic.GenericBackend.complete_streaming", + "revibe.core.llm.backend.openai.OpenAIBackend.complete_streaming", side_effect=mock_complete_streaming, ).start() diff --git a/tests/onboarding/test_api_key_screen.py b/tests/onboarding/test_api_key_screen.py new file mode 100644 index 0000000..382772c --- /dev/null +++ b/tests/onboarding/test_api_key_screen.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from types import MethodType +from unittest.mock import Mock + +import pytest + +from revibe.core.config import GenericProviderConfig as ProviderConfig, VibeConfig +from revibe.core.model_config import ModelConfig +from revibe.setup.onboarding.screens.api_key import ApiKeyScreen + + +class TestApiKeyScreen: + def test_on_show_skips_when_provider_has_no_api_key_env_var(self) -> None: + """Test that ApiKeyScreen exits immediately when provider doesn't require API key.""" + # Create a mock config with a provider that has empty api_key_env_var + provider = ProviderConfig( + name="ollama", + api_base="http://127.0.0.1:11434/v1", + api_key_env_var="", # No API key required + ) + model = ModelConfig(name="llama3.2", provider="ollama", alias="llama3.2") + config = VibeConfig(models=[model], providers=[provider]) + + screen = ApiKeyScreen() + screen._load_config = MethodType(lambda self: config, screen) # Mock the config loading # type: ignore[invalid-assignment] + + screen.app = Mock() + screen.app.exit = Mock() + + # Call on_show + screen.on_show() + + screen.app.exit.assert_called_with("completed") + + def test_on_show_does_not_skip_when_api_key_required(self) -> None: + """Test that ApiKeyScreen does not exit when provider requires API key.""" + # Create a mock config with a provider that requires API key + provider = ProviderConfig( + name="openai", + api_base="https://api.openai.com/v1", + api_key_env_var="OPENAI_API_KEY", + ) + model = ModelConfig(name="gpt-4", provider="openai", alias="gpt-4") + config = VibeConfig(models=[model], providers=[provider]) + + screen = ApiKeyScreen() + screen._load_config = MethodType(lambda self: config, screen) # Mock the config loading # type: ignore[invalid-assignment] + + # Mock the app.exit method + exit_called = False + + def mock_exit(value): + nonlocal exit_called + exit_called = True + + screen.app = type("MockApp", (), {"exit": mock_exit})() + + # Call on_show + screen.on_show() + + # Should not have exited + assert not exit_called + # Provider should be set + assert screen.provider is not None + assert screen.provider.name == "openai" + + @pytest.mark.parametrize("provider_name", ["ollama", "llamacpp", "qwencode"]) + def test_skips_for_local_providers(self, provider_name: str) -> None: + """Test that ApiKeyScreen skips for local providers without API key env vars.""" + # These providers have empty api_key_env_var in DEFAULT_PROVIDERS + from revibe.core.config import DEFAULT_PROVIDERS + + model = ModelConfig( + name="test-model", provider=provider_name, alias="test-model" + ) + config = VibeConfig(models=[model], providers=DEFAULT_PROVIDERS) + + screen = ApiKeyScreen() + screen._load_config = MethodType(lambda self: config, screen) # type: ignore[invalid-assignment] + + exit_called = False + exit_value = None + + def mock_exit(value): + nonlocal exit_called, exit_value + exit_called = True + exit_value = value + + screen.app = type("MockApp", (), {"exit": mock_exit})() + + screen.on_show() + + assert exit_called + assert exit_value == "completed" diff --git a/tests/stubs/fake_backend.py b/tests/stubs/fake_backend.py index 794a6e7..7f7a15c 100644 --- a/tests/stubs/fake_backend.py +++ b/tests/stubs/fake_backend.py @@ -14,8 +14,6 @@ class FakeBackend: - supported_formats: list[str] = ["native", "xml"] - """Minimal async backend stub to drive Agent.act without network. Provide a finite sequence of LLMResult objects to be returned by @@ -41,6 +39,7 @@ def __init__( A sequence of sequences of chunks is considered a list of streams: each completion will output a stream (either streaming or in an aggregated way) """ + self.supported_formats = ["native", "xml"] self._requests_messages: list[list[LLMMessage]] = [] self._requests_extra_headers: list[dict[str, str] | None] = [] self._count_tokens_calls: list[list[LLMMessage]] = [] diff --git a/tests/test_api_key_input.py b/tests/test_api_key_input.py index 0062fca..06910b6 100644 --- a/tests/test_api_key_input.py +++ b/tests/test_api_key_input.py @@ -11,9 +11,7 @@ def test_api_key_input_widget_exists() -> None: provider = ProviderConfig( - name="test", - api_base="https://example.com", - api_key_env_var="TEST_API_KEY", + name="test", api_base="https://example.com", api_key_env_var="TEST_API_KEY" ) widget = ApiKeyInput(provider) assert widget.provider.name == "test" @@ -22,9 +20,7 @@ def test_api_key_input_widget_exists() -> None: def test_api_key_input_messages() -> None: provider = ProviderConfig( - name="groq", - api_base="https://api.groq.com", - api_key_env_var="GROQ_API_KEY", + name="groq", api_base="https://api.groq.com", api_key_env_var="GROQ_API_KEY" ) widget = ApiKeyInput(provider) @@ -47,9 +43,7 @@ def temp_env_file(tmp_path: Path) -> Path: def test_save_api_key_to_env_file(temp_env_file: Path) -> None: provider = ProviderConfig( - name="groq", - api_base="https://api.groq.com", - api_key_env_var="GROQ_API_KEY", + name="groq", api_base="https://api.groq.com", api_key_env_var="GROQ_API_KEY" ) # Ensure env file doesn't exist initially @@ -88,24 +82,14 @@ def test_update_existing_api_key(temp_env_file: Path) -> None: assert "old-key-123" not in content -def test_append_new_api_key(temp_env_file: Path) -> None: +def test_api_key_input_handles_no_env_var_provider() -> None: provider = ProviderConfig( - name="anthropic", - api_base="https://api.anthropic.com", - api_key_env_var="ANTHROPIC_API_KEY", + name="ollama", + api_base="http://127.0.0.1:11434/v1", + api_key_env_var="", # No API key required ) + widget = ApiKeyInput(provider) - # Create env file with one existing key - initial_content = "OPENAI_API_KEY=sk-test-123\n" - temp_env_file.write_text(initial_content, encoding="utf-8") - - # Append new key - new_key = "sk-ant-test-456" - env_var_line = f"{provider.api_key_env_var}={new_key}" - existing_content = temp_env_file.read_text(encoding="utf-8") - temp_env_file.write_text(existing_content + env_var_line + "\n", encoding="utf-8") - - # Verify both keys are present - content = temp_env_file.read_text(encoding="utf-8") - assert "OPENAI_API_KEY=sk-test-123" in content - assert "ANTHROPIC_API_KEY=sk-ant-test-456" in content + # Check that the provider is set correctly + assert widget.provider.name == "ollama" + assert widget.provider.api_key_env_var == "" diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py index c46f81e..6f44b3e 100644 --- a/tests/test_cli_programmatic_preload.py +++ b/tests/test_cli_programmatic_preload.py @@ -29,7 +29,7 @@ def test_run_programmatic_preload_streaming_is_batched( ) -> None: spy = SpyStreamingFormatter() monkeypatch.setattr( - "vibe.core.programmatic.create_formatter", lambda *_args, **_kwargs: spy + "revibe.core.programmatic.create_formatter", lambda *_args, **_kwargs: spy ) with mock_backend_factory( @@ -94,7 +94,7 @@ def test_run_programmatic_ignores_system_messages_in_previous( ) -> None: spy = SpyStreamingFormatter() monkeypatch.setattr( - "vibe.core.programmatic.create_formatter", lambda *_args, **_kwargs: spy + "revibe.core.programmatic.create_formatter", lambda *_args, **_kwargs: spy ) with mock_backend_factory( diff --git a/tests/update_notifier/test_filesystem_update_cache_repository.py b/tests/update_notifier/test_filesystem_update_cache_repository.py index 3d57e3a..52dafd0 100644 --- a/tests/update_notifier/test_filesystem_update_cache_repository.py +++ b/tests/update_notifier/test_filesystem_update_cache_repository.py @@ -37,7 +37,7 @@ async def test_returns_none_when_cache_file_is_missing(tmp_path: Path) -> None: @pytest.mark.asyncio async def test_returns_none_when_cache_file_is_corrupted(tmp_path: Path) -> None: - cache_dir = tmp_path / ".vibe" + cache_dir = tmp_path / ".revibe" cache_dir.mkdir() (cache_dir / "update_cache.json").write_text("{not-json") repository = FileSystemUpdateCacheRepository(base_path=tmp_path) @@ -66,7 +66,7 @@ async def test_overwrites_existing_cache(tmp_path: Path) -> None: @pytest.mark.asyncio async def test_silently_ignores_errors_when_writing_cache_fails(tmp_path: Path) -> None: - cache_dir = tmp_path / ".vibe" + cache_dir = tmp_path / ".revibe" cache_dir.mkdir() (cache_dir / "update_cache.json").mkdir() repository = FileSystemUpdateCacheRepository(base_path=tmp_path) diff --git a/uv.lock b/uv.lock index 13c95ca..43cd1eb 100644 --- a/uv.lock +++ b/uv.lock @@ -1236,7 +1236,7 @@ wheels = [ [[package]] name = "revibe" -version = "1.4.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" },