Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Kracekumar <kracethekingmaker@gmail.com>
This commit is contained in:
Quentin 2025-12-14 00:54:42 +01:00 committed by Mathias Gesbert
parent 661588de0c
commit d8dbeeb31e
91 changed files with 4521 additions and 873 deletions

View file

@ -1,11 +1,11 @@
name: Build and upload name: Build and upload
on: on:
workflow_dispatch: release:
push: types: [published]
branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch:
jobs: jobs:
configure: configure:
@ -95,3 +95,30 @@ jobs:
with: with:
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_windows.outputs.version }} name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_windows.outputs.version }}
path: dist/vibe-acp.exe path: dist/vibe-acp.exe
attach-to-release:
needs: build-and-upload
runs-on: ubuntu-latest
if: github.event_name == 'release'
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
path: artifacts
- name: Zip artifacts like GitHub UI
run: |
mkdir release-assets
for dir in artifacts/*; do
name=$(basename "$dir")
(cd artifacts && zip -r "../release-assets/${name}.zip" "$name")
done
- name: Attach binaries to release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
files: release-assets/*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,2 +1,6 @@
[default] [default]
extend-ignore-re = ["(?m)^.*(#|//)\\s*typos:disable-line$", "datas"] extend-ignore-re = ["(?m)^.*(#|//)\\s*typos:disable-line$", "datas"]
[default.extend-words]
iterm = "iterm"
ITERM = "ITERM"

20
.vscode/launch.json vendored
View file

@ -1,15 +1,12 @@
{ {
"version": "1.1.3", "version": "1.2.0",
"configurations": [ "configurations": [
{ {
"name": "ACP Server", "name": "ACP Server",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "vibe/acp/entrypoint.py", "program": "vibe/acp/entrypoint.py",
"args": [ "args": [],
"--workdir",
"${workspaceFolder}"
],
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false "justMyCode": false
}, },
@ -18,10 +15,7 @@
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "pytest", "module": "pytest",
"args": [ "args": ["-v", "-s"],
"-v",
"-s"
],
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false, "justMyCode": false,
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
@ -34,13 +28,7 @@
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "pytest", "module": "pytest",
"args": [ "args": ["-k", "${input:test_identifier}", "-vvv", "-s", "--no-header"],
"-k",
"${input:test_identifier}",
"-vvv",
"-s",
"--no-header"
],
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false, "justMyCode": false,
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",

View file

@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2025-12-18
### Added
- Modular mode system
- Trusted folder mechanism for local .vibe directories
- Document public setup for vibe-acp in zed, jetbrains and neovim
- `--version` flag
### Changed
- Improve UI based on feedback
- Remove unnecessary logging and flushing for better performance
- Update textual
- Update nix flake
- Automate binary attachment to GitHub releases
### Fixed
- Prevent segmentation fault on exit by shutting down thread pools
- Fix extra spacing with assistant message
## [1.1.3] - 2025-12-12 ## [1.1.3] - 2025-12-12
### Added ### Added

View file

@ -304,6 +304,10 @@ This affects where Vibe looks for:
- `tools/` - Custom tools - `tools/` - Custom tools
- `logs/` - Session logsRetryTo run code, enable code execution and file creation in Settings > Capabilities. - `logs/` - Session logsRetryTo run code, enable code execution and file creation in Settings > Capabilities.
## Editors/IDEs
Mistral Vibe can be used in text editors and IDEs that support [Agent Client Protocol](https://agentclientprotocol.com/overview/clients). See the [ACP Setup documentation](docs/acp-setup.md) for setup instructions for various editors and IDEs.
## Resources ## Resources
- [CHANGELOG](CHANGELOG.md) - See what's new in each version - [CHANGELOG](CHANGELOG.md) - See what's new in each version

View file

@ -1,7 +1,7 @@
id = "mistral-vibe" id = "mistral-vibe"
name = "Mistral Vibe" name = "Mistral Vibe"
description = "Mistral's open-source coding assistant" description = "Mistral's open-source coding assistant"
version = "1.1.3" version = "1.2.0"
schema_version = 1 schema_version = 1
authors = ["Mistral AI"] authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe" repository = "https://github.com/mistralai/mistral-vibe"
@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg" icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64] [agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-darwin-aarch64-1.1.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.0/vibe-acp-darwin-aarch64-1.2.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64] [agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-darwin-x86_64-1.1.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.0/vibe-acp-darwin-x86_64-1.2.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64] [agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-linux-aarch64-1.1.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.0/vibe-acp-linux-aarch64-1.2.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64] [agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-linux-x86_64-1.1.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.0/vibe-acp-linux-x86_64-1.2.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64] [agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-windows-aarch64-1.1.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.0/vibe-acp-windows-aarch64-1.2.0.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64] [agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-windows-x86_64-1.1.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.0/vibe-acp-windows-x86_64-1.2.0.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"

7
docs/README.md Normal file
View file

@ -0,0 +1,7 @@
# Mistral Vibe Documentation
Welcome to the Mistral Vibe documentation! For basic setup, see the [main README](https://github.com/mistral-vibe/mistral-vibe#readme).
## Available Documentation
- **[ACP Setup](acp-setup.md)** - Setup instructions for using Mistral Vibe with various editors and IDEs that support the Agent Client Protocol.

58
docs/acp-setup.md Normal file
View file

@ -0,0 +1,58 @@
# ACP Setup
Mistral Vibe can be used in text editors and IDEs that support [Agent Client Protocol](https://agentclientprotocol.com/overview/clients). Mistral Vibe includes the `vibe-acp` tool.
Once you have set up `vibe` with the API keys, you are ready to use `vibe-acp` in your editor. Below are the setup instructions for some editors that support ACP.
## Zed
For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https://zed.dev/extensions/mistral-vibe). Alternatively, you can set up a local install as follows:
1. Go to `~/.config/zed/settings.json` and, under the `agent_servers` JSON object, add the following key-value pair to invoke the `vibe-acp` command. Here is the snippet:
```json
{
"agent_servers": {
"Mistral Vibe": {
"type": "custom",
"command": "vibe-acp",
"args": [],
"env": {}
}
}
}
```
2. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation.
## JetBrains IDEs
1. Add the following snippet to your JetBrains IDE acp.json ([documentation](https://www.jetbrains.com/help/ai-assistant/acp.html)):
```json
{
"agent_servers": {
"Mistral Vibe": {
"command": "vibe-acp",
}
}
}
```
2. In the AI Chat agent selector, select the new Mistral Vibe agent and start the conversation.
## Neovim (using avante.nvim)
Add Mistral Vibe in the acp_providers section of your configuration
```lua
{
acp_providers = {
["mistral-vibe"] = {
command = "vibe-acp",
env = {
MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY"), -- necessary if you setup Mistral Vibe manually
},
}
}
}
```

24
flake.lock generated
View file

@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1763283776, "lastModified": 1765472234,
"narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=", "narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a", "rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -47,11 +47,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1761781027, "lastModified": 1763662255,
"narHash": "sha256-YDvxPAm2WnxrznRqWwHLjryBGG5Ey1ATEJXrON+TWt8=", "narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "build-system-pkgs", "repo": "build-system-pkgs",
"rev": "795a980d25301e5133eca37adae37283ec3c8e66", "rev": "042904167604c681a090c07eb6967b4dd4dae88c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -67,11 +67,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1763017646, "lastModified": 1764134915,
"narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=", "narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "pyproject.nix", "repo": "pyproject.nix",
"rev": "47bd6f296502842643078d66128f7b5e5370790c", "rev": "2c8df1383b32e5443c921f61224b198a2282a657",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -114,11 +114,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1763349549, "lastModified": 1765631794,
"narHash": "sha256-GQKYN9j8HOh09RW2I739tyu87ygcsAmpJJ32FspWVJ8=", "narHash": "sha256-90d//IZ4GXipNsngO4sb2SAPbIC/a2P+IAdAWOwpcOM=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "uv2nix", "repo": "uv2nix",
"rev": "071b718279182c5585f74939c2902c202f93f588", "rev": "4cca323a547a1aaa9b94929c4901bed5343eafe8",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "1.1.3" version = "1.2.0"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View file

@ -131,7 +131,7 @@ Examples:
) )
# Update vibe/core/__init__.py # Update vibe/core/__init__.py
update_hard_values_files( update_hard_values_files(
"vibe/core/__init__.py", "vibe/__init__.py",
[(f'__version__ = "{current_version}"', f'__version__ = "{new_version}"')], [(f'__version__ = "{current_version}"', f'__version__ = "{new_version}"')],
) )
# Update tests/acp/test_initialize.py # Update tests/acp/test_initialize.py

View file

@ -83,6 +83,9 @@ def _create_vibe_home_dir(tmp_path: Path, *sections: dict[str, Any]) -> Path:
with config_file.open("wb") as f: with config_file.open("wb") as f:
tomli_w.dump(base_config_dict, f) tomli_w.dump(base_config_dict, f)
trusted_folters_file = vibe_home / "trusted_folders.toml"
trusted_folters_file.write_text("trusted = []\nuntrusted = []", encoding="utf-8")
return vibe_home return vibe_home

View file

@ -41,7 +41,7 @@ class TestACPInitialize:
), ),
) )
assert response.agentInfo == Implementation( assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.3" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.0"
) )
assert response.authMethods == [] assert response.authMethods == []
@ -63,7 +63,7 @@ class TestACPInitialize:
), ),
) )
assert response.agentInfo == Implementation( assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.3" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.0"
) )
assert response.authMethods is not None assert response.authMethods is not None

View file

@ -9,9 +9,9 @@ import pytest
from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent from vibe.acp.acp_agent import VibeAcpAgent
from vibe.acp.utils import VibeSessionMode
from vibe.core.agent import Agent from vibe.core.agent import Agent
from vibe.core.config import ModelConfig, VibeConfig from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@ -101,18 +101,21 @@ class TestACPNewSession:
assert session_response.modes is not None assert session_response.modes is not None
assert session_response.modes.currentModeId is not None assert session_response.modes.currentModeId is not None
assert session_response.modes.availableModes is not None assert session_response.modes.availableModes is not None
assert len(session_response.modes.availableModes) == 2 assert len(session_response.modes.availableModes) == 4
assert session_response.modes.currentModeId == VibeSessionMode.APPROVAL_REQUIRED assert session_response.modes.currentModeId == AgentMode.DEFAULT.value
assert session_response.modes.availableModes[0].id == AgentMode.DEFAULT.value
assert session_response.modes.availableModes[0].name == "Default"
assert ( assert (
session_response.modes.availableModes[0].id session_response.modes.availableModes[1].id == AgentMode.AUTO_APPROVE.value
== VibeSessionMode.APPROVAL_REQUIRED
)
assert session_response.modes.availableModes[0].name == "Approval Required"
assert (
session_response.modes.availableModes[1].id == VibeSessionMode.AUTO_APPROVE
) )
assert session_response.modes.availableModes[1].name == "Auto Approve" assert session_response.modes.availableModes[1].name == "Auto Approve"
assert session_response.modes.availableModes[2].id == AgentMode.PLAN.value
assert session_response.modes.availableModes[2].name == "Plan"
assert (
session_response.modes.availableModes[3].id == AgentMode.ACCEPT_EDITS.value
)
assert session_response.modes.availableModes[3].name == "Accept Edits"
@pytest.mark.skip(reason="TODO: Fix this test") @pytest.mark.skip(reason="TODO: Fix this test")
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -9,8 +9,8 @@ import pytest
from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent from vibe.acp.acp_agent import VibeAcpAgent
from vibe.acp.utils import VibeSessionMode
from vibe.core.agent import Agent from vibe.core.agent import Agent
from vibe.core.modes import AgentMode
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@ -49,7 +49,7 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
class TestACPSetMode: class TestACPSetMode:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_mode_to_approval_required(self, acp_agent: VibeAcpAgent) -> None: async def test_set_mode_to_default(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession( session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[]) NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
) )
@ -59,21 +59,18 @@ class TestACPSetMode:
) )
assert acp_session is not None assert acp_session is not None
acp_session.agent.auto_approve = True await acp_session.agent.switch_mode(AgentMode.AUTO_APPROVE)
acp_session.mode_id = VibeSessionMode.AUTO_APPROVE
response = await acp_agent.setSessionMode( response = await acp_agent.setSessionMode(
SetSessionModeRequest( SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.DEFAULT.value)
sessionId=session_id, modeId=VibeSessionMode.APPROVAL_REQUIRED
)
) )
assert response is not None assert response is not None
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent.auto_approve is False assert acp_session.agent.auto_approve is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_mode_to_AUTO_APPROVE(self, acp_agent: VibeAcpAgent) -> None: async def test_set_mode_to_auto_approve(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession( session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[]) NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
) )
@ -83,19 +80,67 @@ class TestACPSetMode:
) )
assert acp_session is not None assert acp_session is not None
assert acp_session.mode_id == VibeSessionMode.APPROVAL_REQUIRED assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent.auto_approve is False assert acp_session.agent.auto_approve is False
response = await acp_agent.setSessionMode( response = await acp_agent.setSessionMode(
SetSessionModeRequest( SetSessionModeRequest(
sessionId=session_id, modeId=VibeSessionMode.AUTO_APPROVE sessionId=session_id, modeId=AgentMode.AUTO_APPROVE.value
) )
) )
assert response is not None assert response is not None
assert acp_session.mode_id == VibeSessionMode.AUTO_APPROVE assert acp_session.agent.mode == AgentMode.AUTO_APPROVE
assert acp_session.agent.auto_approve is True assert acp_session.agent.auto_approve is True
@pytest.mark.asyncio
async def test_set_mode_to_plan(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.PLAN.value)
)
assert response is not None
assert acp_session.agent.mode == AgentMode.PLAN
assert (
acp_session.agent.auto_approve is True
) # Plan mode auto-approves read-only tools
@pytest.mark.asyncio
async def test_set_mode_to_accept_edits(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(
sessionId=session_id, modeId=AgentMode.ACCEPT_EDITS.value
)
)
assert response is not None
assert acp_session.agent.mode == AgentMode.ACCEPT_EDITS
assert (
acp_session.agent.auto_approve is False
) # Accept Edits mode doesn't auto-approve all
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_mode_invalid_mode_returns_none( async def test_set_mode_invalid_mode_returns_none(
self, acp_agent: VibeAcpAgent self, acp_agent: VibeAcpAgent
@ -109,7 +154,7 @@ class TestACPSetMode:
) )
assert acp_session is not None assert acp_session is not None
initial_mode_id = acp_session.mode_id initial_mode = acp_session.agent.mode
initial_auto_approve = acp_session.agent.auto_approve initial_auto_approve = acp_session.agent.auto_approve
response = await acp_agent.setSessionMode( response = await acp_agent.setSessionMode(
@ -117,7 +162,7 @@ class TestACPSetMode:
) )
assert response is None assert response is None
assert acp_session.mode_id == initial_mode_id assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve == initial_auto_approve assert acp_session.agent.auto_approve == initial_auto_approve
@pytest.mark.asyncio @pytest.mark.asyncio
@ -131,15 +176,15 @@ class TestACPSetMode:
) )
assert acp_session is not None assert acp_session is not None
initial_mode_id = VibeSessionMode.APPROVAL_REQUIRED initial_mode = AgentMode.DEFAULT
assert acp_session.mode_id == initial_mode_id assert acp_session.agent.mode == initial_mode
response = await acp_agent.setSessionMode( response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode_id) SetSessionModeRequest(sessionId=session_id, modeId=initial_mode.value)
) )
assert response is not None assert response is not None
assert acp_session.mode_id == initial_mode_id assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve is False assert acp_session.agent.auto_approve is False
@pytest.mark.asyncio @pytest.mark.asyncio
@ -153,7 +198,7 @@ class TestACPSetMode:
) )
assert acp_session is not None assert acp_session is not None
initial_mode_id = acp_session.mode_id initial_mode = acp_session.agent.mode
initial_auto_approve = acp_session.agent.auto_approve initial_auto_approve = acp_session.agent.auto_approve
response = await acp_agent.setSessionMode( response = await acp_agent.setSessionMode(
@ -161,5 +206,5 @@ class TestACPSetMode:
) )
assert response is None assert response is None
assert acp_session.mode_id == initial_mode_id assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve == initial_auto_approve assert acp_session.agent.auto_approve == initial_auto_approve

View file

@ -105,7 +105,7 @@ def test_on_text_change_clears_suggestions_when_no_matches() -> None:
assert view.reset_count >= 1 assert view.reset_count >= 1
def test_on_text_change_limits_the_number_of_results_to_five_and_preserve_insertion_order() -> ( def test_on_text_change_limits_the_number_of_results_and_preserves_insertion_order() -> (
None None
): ):
controller, view = make_controller(prefix="/") controller, view = make_controller(prefix="/")
@ -113,13 +113,15 @@ def test_on_text_change_limits_the_number_of_results_to_five_and_preserve_insert
controller.on_text_changed("/", cursor_index=1) controller.on_text_changed("/", cursor_index=1)
suggestions, selected_index = view.suggestion_events[-1] suggestions, selected_index = view.suggestion_events[-1]
assert len(suggestions) == 5 assert len(suggestions) == 7
assert [suggestion.alias for suggestion in suggestions] == [ assert [suggestion.alias for suggestion in suggestions] == [
"/config", "/config",
"/compact", "/compact",
"/help", "/help",
"/summarize", "/summarize",
"/logpath", "/logpath",
"/exit",
"/vim",
] ]

View file

@ -29,13 +29,13 @@ async def test_popup_appears_with_matching_suggestions(vibe_app: VibeApp) -> Non
chat_input = vibe_app.query_one(ChatInputContainer) chat_input = vibe_app.query_one(ChatInputContainer)
popup = vibe_app.query_one(CompletionPopup) popup = vibe_app.query_one(CompletionPopup)
await pilot.press(*"/sum") await pilot.press(*"/com")
popup_content = str(popup.render()) popup_content = str(popup.render())
assert popup.styles.display == "block" assert popup.styles.display == "block"
assert "/summarize" in popup_content assert "/compact" in popup_content
assert "Compact conversation history by summarizing" in popup_content assert "Compact conversation history by summarizing" in popup_content
assert chat_input.value == "/sum" assert chat_input.value == "/com"
@pytest.mark.asyncio @pytest.mark.asyncio
@ -88,11 +88,11 @@ async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) -
await pilot.press(*"/c") await pilot.press(*"/c")
ensure_selected_command(popup, "/cfg")
await pilot.press("down")
ensure_selected_command(popup, "/config") ensure_selected_command(popup, "/config")
await pilot.press("down")
ensure_selected_command(popup, "/clear")
await pilot.press("up") await pilot.press("up")
ensure_selected_command(popup, "/cfg") ensure_selected_command(popup, "/config")
@pytest.mark.asyncio @pytest.mark.asyncio
@ -100,13 +100,13 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) ->
async with vibe_app.run_test() as pilot: async with vibe_app.run_test() as pilot:
popup = vibe_app.query_one(CompletionPopup) popup = vibe_app.query_one(CompletionPopup)
await pilot.press(*"/st") await pilot.press(*"/co")
ensure_selected_command(popup, "/stats") ensure_selected_command(popup, "/config")
await pilot.press("down") await pilot.press("down")
ensure_selected_command(popup, "/status") ensure_selected_command(popup, "/compact")
await pilot.press("up") await pilot.press("up")
ensure_selected_command(popup, "/stats") ensure_selected_command(popup, "/config")
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -7,7 +7,8 @@ from typing import Any
import pytest import pytest
import tomli_w import tomli_w
from vibe.core import config_path from vibe.core.paths import global_paths
from vibe.core.paths.config_paths import unlock_config_paths
def get_base_config() -> dict[str, Any]: def get_base_config() -> dict[str, Any]:
@ -31,6 +32,15 @@ def get_base_config() -> dict[str, Any]:
} }
@pytest.fixture(autouse=True)
def tmp_working_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
) -> Path:
tmp_working_directory = tmp_path_factory.mktemp("test_cwd")
monkeypatch.chdir(tmp_working_directory)
return tmp_working_directory
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def config_dir( def config_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
@ -41,10 +51,15 @@ def config_dir(
config_file = config_dir / "config.toml" config_file = config_dir / "config.toml"
config_file.write_text(tomli_w.dumps(get_base_config()), encoding="utf-8") config_file.write_text(tomli_w.dumps(get_base_config()), encoding="utf-8")
monkeypatch.setattr(config_path, "_DEFAULT_VIBE_HOME", config_dir) monkeypatch.setattr(global_paths, "_DEFAULT_VIBE_HOME", config_dir)
return config_dir return config_dir
@pytest.fixture(autouse=True)
def _unlock_config_paths():
unlock_config_paths()
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None: def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "mock") monkeypatch.setenv("MISTRAL_API_KEY", "mock")

View file

@ -4,28 +4,41 @@ from pathlib import Path
import pytest import pytest
from vibe.core.config_path import CONFIG_FILE, GLOBAL_CONFIG_FILE, VIBE_HOME from vibe.core.paths.config_paths import CONFIG_FILE
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager
class TestResolveConfigFile: class TestResolveConfigFile:
def test_resolves_local_config_when_exists( def test_resolves_local_config_when_exists_and_folder_is_trusted(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""Test that local .vibe/config.toml is found when it exists."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
local_config_dir = tmp_path / ".vibe" local_config_dir = tmp_path / ".vibe"
local_config_dir.mkdir() local_config_dir.mkdir()
local_config = local_config_dir / "config.toml" local_config = local_config_dir / "config.toml"
local_config.write_text('active_model = "test"', encoding="utf-8") local_config.write_text('active_model = "test"', encoding="utf-8")
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
assert CONFIG_FILE.path == local_config assert CONFIG_FILE.path == local_config
assert CONFIG_FILE.path.is_file() assert CONFIG_FILE.path.is_file()
assert CONFIG_FILE.path.read_text(encoding="utf-8") == 'active_model = "test"' assert CONFIG_FILE.path.read_text(encoding="utf-8") == 'active_model = "test"'
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.mkdir()
local_config = local_config_dir / "config.toml"
local_config.write_text('active_model = "test"', encoding="utf-8")
assert CONFIG_FILE.path == GLOBAL_CONFIG_FILE.path
def test_falls_back_to_global_config_when_local_missing( def test_falls_back_to_global_config_when_local_missing(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""Test that global config is returned when local config doesn't exist."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
# Ensure no local config exists # Ensure no local config exists
assert not (tmp_path / ".vibe" / "config.toml").exists() assert not (tmp_path / ".vibe" / "config.toml").exists()
@ -35,7 +48,6 @@ class TestResolveConfigFile:
def test_respects_vibe_home_env_var( def test_respects_vibe_home_env_var(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""Test that VIBE_HOME environment variable affects VIBE_HOME.path."""
assert VIBE_HOME.path != tmp_path assert VIBE_HOME.path != tmp_path
monkeypatch.setenv("VIBE_HOME", str(tmp_path)) monkeypatch.setenv("VIBE_HOME", str(tmp_path))
assert VIBE_HOME.path == tmp_path assert VIBE_HOME.path == tmp_path

View file

@ -0,0 +1,205 @@
from __future__ import annotations
from pathlib import Path
import tomllib
from unittest.mock import patch
import pytest
import tomli_w
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import TrustedFoldersManager
class TestTrustedFoldersManager:
def test_initializes_with_empty_lists_when_file_does_not_exist(
self, tmp_path: Path
) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
assert not trusted_file.is_file()
manager = TrustedFoldersManager()
assert manager.is_trusted(tmp_path) is None
assert trusted_file.is_file()
def test_loads_existing_file(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
data = {"trusted": [str(tmp_path.resolve())], "untrusted": []}
with trusted_file.open("wb") as f:
tomli_w.dump(data, f)
manager = TrustedFoldersManager()
assert manager.is_trusted(tmp_path) is True
def test_handles_corrupted_file(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
trusted_file.write_text("invalid toml content {[", encoding="utf-8")
manager = TrustedFoldersManager()
assert manager.is_trusted(tmp_path) is None
assert trusted_file.is_file()
def test_normalizes_paths_to_absolute(
self, tmp_working_directory, monkeypatch: pytest.MonkeyPatch
) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(Path("."))
assert manager.is_trusted(tmp_working_directory) is True
assert manager.is_trusted(Path(".")) is True
def test_expands_user_home_in_paths(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
manager = TrustedFoldersManager()
manager.add_trusted(Path("~/test"))
assert manager.is_trusted(tmp_path / "test") is True
def test_is_trusted_returns_true_for_trusted_path(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
def test_is_trusted_returns_false_for_untrusted_path(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
assert manager.is_trusted(tmp_path) is False
def test_is_trusted_returns_none_for_unknown_path(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
assert manager.is_trusted(tmp_path) is None
def test_add_trusted_adds_path_to_trusted_list(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
with trusted_file.open("rb") as f:
data = tomllib.load(f)
assert str(tmp_path.resolve()) in data["trusted"]
def test_add_trusted_removes_path_from_untrusted(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
assert manager.is_trusted(tmp_path) is False
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
with trusted_file.open("rb") as f:
data = tomllib.load(f)
assert str(tmp_path.resolve()) not in data["untrusted"]
assert str(tmp_path.resolve()) in data["trusted"]
def test_add_trusted_idempotent(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
manager.add_trusted(tmp_path)
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
with trusted_file.open("rb") as f:
data = tomllib.load(f)
assert data["trusted"].count(str(tmp_path.resolve())) == 1
def test_add_untrusted_adds_path_to_untrusted_list(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
assert manager.is_trusted(tmp_path) is False
with trusted_file.open("rb") as f:
data = tomllib.load(f)
assert str(tmp_path.resolve()) in data["untrusted"]
def test_add_untrusted_removes_path_from_trusted(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
manager.add_untrusted(tmp_path)
assert manager.is_trusted(tmp_path) is False
with trusted_file.open("rb") as f:
data = tomllib.load(f)
assert str(tmp_path.resolve()) not in data["trusted"]
assert str(tmp_path.resolve()) in data["untrusted"]
def test_add_untrusted_idempotent(self, tmp_path: Path) -> None:
trusted_file = TRUSTED_FOLDERS_FILE.path
manager = TrustedFoldersManager()
manager.add_untrusted(tmp_path)
manager.add_untrusted(tmp_path)
manager.add_untrusted(tmp_path)
assert manager.is_trusted(tmp_path) is False
with trusted_file.open("rb") as f:
data = tomllib.load(f)
assert data["untrusted"].count(str(tmp_path.resolve())) == 1
def test_persistence_across_instances(self, tmp_path: Path) -> None:
manager1 = TrustedFoldersManager()
manager1.add_trusted(tmp_path)
manager2 = TrustedFoldersManager()
assert manager2.is_trusted(tmp_path) is True
def test_handles_multiple_paths(self, tmp_path: Path) -> None:
trusted1 = tmp_path / "trusted1"
trusted2 = tmp_path / "trusted2"
untrusted1 = tmp_path / "untrusted1"
untrusted2 = tmp_path / "untrusted2"
for p in [trusted1, trusted2, untrusted1, untrusted2]:
p.mkdir()
manager = TrustedFoldersManager()
manager.add_trusted(trusted1)
manager.add_trusted(trusted2)
manager.add_untrusted(untrusted1)
manager.add_untrusted(untrusted2)
assert manager.is_trusted(trusted1) is True
assert manager.is_trusted(trusted2) is True
assert manager.is_trusted(untrusted1) is False
assert manager.is_trusted(untrusted2) is False
def test_handles_switching_between_trusted_and_untrusted(
self, tmp_path: Path
) -> None:
manager = TrustedFoldersManager()
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
manager.add_untrusted(tmp_path)
assert manager.is_trusted(tmp_path) is False
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True
def test_handles_missing_file_during_save(self, tmp_path: Path) -> None:
manager = TrustedFoldersManager()
def mock_open(*args, **kwargs):
raise OSError("Permission denied")
with patch("pathlib.Path.open", side_effect=mock_open):
manager.add_trusted(tmp_path)
assert manager.is_trusted(tmp_path) is True

View file

@ -14,12 +14,15 @@ from unittest.mock import patch
from pydantic import ValidationError from pydantic import ValidationError
from tests import TESTS_ROOT from vibe.core.paths.config_paths import unlock_config_paths
from tests.mock.utils import MOCK_DATA_ENV_VAR
from vibe.core.types import LLMChunk
if __name__ == "__main__":
unlock_config_paths()
from tests import TESTS_ROOT
from tests.mock.utils import MOCK_DATA_ENV_VAR
from vibe.core.types import LLMChunk
def mock_llm_output() -> None:
sys.path.insert(0, str(TESTS_ROOT)) sys.path.insert(0, str(TESTS_ROOT))
# Apply mocking before importing any vibe modules # Apply mocking before importing any vibe modules
@ -57,10 +60,6 @@ def mock_llm_output() -> None:
side_effect=mock_complete_streaming, side_effect=mock_complete_streaming,
).start() ).start()
if __name__ == "__main__":
mock_llm_output()
from vibe.acp.entrypoint import main from vibe.acp.entrypoint import main
main() main()

View file

@ -10,7 +10,7 @@ from textual.geometry import Size
from textual.pilot import Pilot from textual.pilot import Pilot
from textual.widgets import Input from textual.widgets import Input
from vibe.core.config_path import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE
from vibe.setup.onboarding import OnboardingApp from vibe.setup.onboarding import OnboardingApp
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

View file

@ -37,7 +37,7 @@ class BaseSnapshotTestApp(VibeApp):
self.agent = Agent( self.agent = Agent(
config, config,
auto_approve=self.auto_approve, mode=self._current_agent_mode,
enable_streaming=self.enable_streaming, enable_streaming=self.enable_streaming,
backend=FakeBackend(), backend=FakeBackend(),
) )

View file

@ -24,7 +24,7 @@ class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
super().__init__(config=config) super().__init__(config=config)
self.agent = Agent( self.agent = Agent(
config, config,
auto_approve=self.auto_approve, mode=self._current_agent_mode,
enable_streaming=self.enable_streaming, enable_streaming=self.enable_streaming,
backend=fake_backend, backend=fake_backend,
) )

View file

@ -0,0 +1,84 @@
from __future__ import annotations
from textual.pilot import Pilot
from tests.snapshots.snap_compare import SnapCompare
def test_snapshot_default_mode(snap_compare: SnapCompare) -> None:
"""Test that default mode is displayed correctly at startup."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
assert snap_compare(
"base_snapshot_test_app.py:BaseSnapshotTestApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_cycle_to_plan_mode(snap_compare: SnapCompare) -> None:
"""Test that shift+tab cycles from default to plan mode."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("shift+tab") # default -> plan
await pilot.pause(0.1)
assert snap_compare(
"base_snapshot_test_app.py:BaseSnapshotTestApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_cycle_to_accept_edits_mode(snap_compare: SnapCompare) -> None:
"""Test that shift+tab cycles from plan to accept edits mode."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("shift+tab") # default -> plan
await pilot.press("shift+tab") # plan -> accept edits
await pilot.pause(0.1)
assert snap_compare(
"base_snapshot_test_app.py:BaseSnapshotTestApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_cycle_to_auto_approve_mode(snap_compare: SnapCompare) -> None:
"""Test that shift+tab cycles to auto approve mode."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("shift+tab") # default -> plan
await pilot.press("shift+tab") # plan -> accept edits
await pilot.press("shift+tab") # accept edits -> auto approve
await pilot.pause(0.1)
assert snap_compare(
"base_snapshot_test_app.py:BaseSnapshotTestApp",
terminal_size=(120, 36),
run_before=run_before,
)
def test_snapshot_cycle_wraps_to_default(snap_compare: SnapCompare) -> None:
"""Test that shift+tab cycles back to default mode after auto approve."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("shift+tab") # default -> plan
await pilot.press("shift+tab") # plan -> accept edits
await pilot.press("shift+tab") # accept edits -> auto approve
await pilot.press("shift+tab") # auto approve -> default (wrap)
await pilot.pause(0.1)
assert snap_compare(
"base_snapshot_test_app.py:BaseSnapshotTestApp",
terminal_size=(120, 36),
run_before=run_before,
)

View file

@ -18,6 +18,7 @@ from vibe.core.middleware import (
MiddlewareResult, MiddlewareResult,
ResetReason, ResetReason,
) )
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoArgs from vibe.core.tools.builtins.todo import TodoArgs
from vibe.core.types import ( from vibe.core.types import (
@ -187,7 +188,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
), ),
backend=backend, backend=backend,
auto_approve=True, mode=AgentMode.AUTO_APPROVE,
enable_streaming=True, enable_streaming=True,
) )
@ -229,7 +230,7 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
), ),
backend=backend, backend=backend,
auto_approve=True, mode=AgentMode.AUTO_APPROVE,
enable_streaming=True, enable_streaming=True,
) )
@ -274,7 +275,7 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
), ),
backend=backend, backend=backend,
auto_approve=True, mode=AgentMode.AUTO_APPROVE,
enable_streaming=True, enable_streaming=True,
) )
@ -366,7 +367,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)}, tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
), ),
backend=backend, backend=backend,
auto_approve=False, mode=AgentMode.DEFAULT,
enable_streaming=True, enable_streaming=True,
) )
middleware = CountingMiddleware() middleware = CountingMiddleware()
@ -392,7 +393,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
assert events[-1].skipped is True assert events[-1].skipped is True
assert events[-1].skip_reason is not None assert events[-1].skip_reason is not None
assert "<user_cancellation>" in events[-1].skip_reason assert "<user_cancellation>" in events[-1].skip_reason
assert agent.interaction_logger.save_interaction.await_count == 2 assert agent.interaction_logger.save_interaction.await_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -14,6 +14,7 @@ from vibe.core.config import (
SessionLoggingConfig, SessionLoggingConfig,
VibeConfig, VibeConfig,
) )
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import ( from vibe.core.types import (
AgentStats, AgentStats,
@ -193,7 +194,7 @@ class TestReloadPreservesStats:
mock_llm_chunk(content="Done", finish_reason="stop"), mock_llm_chunk(content="Done", finish_reason="stop"),
]) ])
config = make_config(enabled_tools=["todo"]) config = make_config(enabled_tools=["todo"])
agent = Agent(config, auto_approve=True, backend=backend) agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
async for _ in agent.act("Check todos"): async for _ in agent.act("Check todos"):
pass pass
@ -257,9 +258,7 @@ class TestReloadPreservesStats:
assert agent.stats.context_tokens == 0 assert agent.stats.context_tokens == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reload_resets_context_tokens_when_system_prompt_changes( async def test_reload_preserves_context_tokens_when_messages_exist(self) -> None:
self,
) -> None:
backend = FakeBackend([ backend = FakeBackend([
mock_llm_chunk(content="Response", finish_reason="stop") mock_llm_chunk(content="Response", finish_reason="stop")
]) ])
@ -267,13 +266,14 @@ class TestReloadPreservesStats:
config2 = make_config(system_prompt_id="cli") config2 = make_config(system_prompt_id="cli")
agent = Agent(config1, backend=backend) agent = Agent(config1, backend=backend)
[_ async for _ in agent.act("Hello")] [_ async for _ in agent.act("Hello")]
assert agent.stats.context_tokens > 0 original_context_tokens = agent.stats.context_tokens
assert original_context_tokens > 0
assert len(agent.messages) > 1 assert len(agent.messages) > 1
await agent.reload_with_initial_messages(config=config2) await agent.reload_with_initial_messages(config=config2)
assert len(agent.messages) > 1 assert len(agent.messages) > 1
assert agent.stats.context_tokens == 0 assert agent.stats.context_tokens == original_context_tokens
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reload_updates_pricing_from_new_model(self, monkeypatch) -> None: async def test_reload_updates_pricing_from_new_model(self, monkeypatch) -> None:
@ -464,7 +464,7 @@ class TestCompactStatsHandling:
mock_llm_chunk(content="<summary>", finish_reason="stop"), mock_llm_chunk(content="<summary>", finish_reason="stop"),
]) ])
config = make_config(enabled_tools=["todo"]) config = make_config(enabled_tools=["todo"])
agent = Agent(config, auto_approve=True, backend=backend) agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
async for _ in agent.act("Check todos"): async for _ in agent.act("Check todos"):
pass pass

View file

@ -11,6 +11,7 @@ from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_tool import FakeTool from tests.stubs.fake_tool import FakeTool
from vibe.core.agent import Agent from vibe.core.agent import Agent
from vibe.core.config import SessionLoggingConfig, VibeConfig from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoItem from vibe.core.tools.builtins.todo import TodoItem
from vibe.core.types import ( from vibe.core.types import (
@ -57,10 +58,9 @@ def make_agent(
backend: FakeBackend, backend: FakeBackend,
approval_callback: SyncApprovalCallback | None = None, approval_callback: SyncApprovalCallback | None = None,
) -> Agent: ) -> Agent:
mode = AgentMode.AUTO_APPROVE if auto_approve else AgentMode.DEFAULT
agent = Agent( agent = Agent(
make_config(todo_permission=todo_permission), make_config(todo_permission=todo_permission), mode=mode, backend=backend
auto_approve=auto_approve,
backend=backend,
) )
if approval_callback: if approval_callback:
agent.set_approval_callback(approval_callback) agent.set_approval_callback(approval_callback)
@ -403,7 +403,7 @@ async def test_tool_call_can_be_interrupted(
) )
agent = Agent( agent = Agent(
config, config,
auto_approve=True, mode=AgentMode.AUTO_APPROVE,
backend=FakeBackend([ backend=FakeBackend([
mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call]), mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call]),
mock_llm_chunk(content="Tool execution completed.", finish_reason="stop"), mock_llm_chunk(content="Tool execution completed.", finish_reason="stop"),
@ -432,7 +432,7 @@ async def test_tool_call_can_be_interrupted(
async def test_fill_missing_tool_responses_inserts_placeholders() -> None: async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
agent = Agent( agent = Agent(
make_config(), make_config(),
auto_approve=True, mode=AgentMode.AUTO_APPROVE,
backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]), backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]),
) )
tool_calls_messages = [ tool_calls_messages = [
@ -472,7 +472,7 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
async def test_ensure_assistant_after_tool_appends_understood() -> None: async def test_ensure_assistant_after_tool_appends_understood() -> None:
agent = Agent( agent = Agent(
make_config(), make_config(),
auto_approve=True, mode=AgentMode.AUTO_APPROVE,
backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]), backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]),
) )
tool_msg = LLMMessage( tool_msg = LLMMessage(

View file

@ -126,7 +126,6 @@ def test_run_programmatic_ignores_system_messages_in_previous(
content="Second system message that should be ignored.", content="Second system message that should be ignored.",
), ),
], ],
auto_approve=True,
) )
roles = [r for r, _ in spy.emitted] roles = [r for r, _ in spy.emitted]

126
tests/test_middleware.py Normal file
View file

@ -0,0 +1,126 @@
from __future__ import annotations
import pytest
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.middleware import (
PLAN_MODE_REMINDER,
ConversationContext,
MiddlewareAction,
MiddlewarePipeline,
PlanModeMiddleware,
)
from vibe.core.modes import AgentMode
from vibe.core.types import AgentStats
def make_context() -> ConversationContext:
config = VibeConfig(session_logging=SessionLoggingConfig(enabled=False))
return ConversationContext(messages=[], stats=AgentStats(), config=config)
class TestPlanModeMiddleware:
@pytest.mark.asyncio
async def test_injects_reminder_when_plan_mode_active(self) -> None:
middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert result.message == PLAN_MODE_REMINDER
@pytest.mark.asyncio
async def test_does_not_inject_when_default_mode(self) -> None:
middleware = PlanModeMiddleware(lambda: AgentMode.DEFAULT)
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
assert result.message is None
@pytest.mark.asyncio
async def test_does_not_inject_when_auto_approve_mode(self) -> None:
middleware = PlanModeMiddleware(lambda: AgentMode.AUTO_APPROVE)
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
assert result.message is None
@pytest.mark.asyncio
async def test_does_not_inject_when_accept_edits_mode(self) -> None:
middleware = PlanModeMiddleware(lambda: AgentMode.ACCEPT_EDITS)
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
assert result.message is None
@pytest.mark.asyncio
async def test_after_turn_always_continues(self) -> None:
middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
ctx = make_context()
result = await middleware.after_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
async def test_dynamically_checks_mode(self) -> None:
current_mode = AgentMode.DEFAULT
middleware = PlanModeMiddleware(lambda: current_mode)
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
current_mode = AgentMode.PLAN
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
current_mode = AgentMode.AUTO_APPROVE
result = await middleware.before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE
@pytest.mark.asyncio
async def test_custom_reminder(self) -> None:
custom_reminder = "Custom plan mode reminder"
middleware = PlanModeMiddleware(
lambda: AgentMode.PLAN, reminder=custom_reminder
)
ctx = make_context()
result = await middleware.before_turn(ctx)
assert result.message == custom_reminder
def test_reset_does_nothing(self) -> None:
middleware = PlanModeMiddleware(lambda: AgentMode.PLAN)
middleware.reset()
class TestMiddlewarePipelineWithPlanMode:
@pytest.mark.asyncio
async def test_pipeline_includes_plan_mode_injection(self) -> None:
pipeline = MiddlewarePipeline()
pipeline.add(PlanModeMiddleware(lambda: AgentMode.PLAN))
ctx = make_context()
result = await pipeline.run_before_turn(ctx)
assert result.action == MiddlewareAction.INJECT_MESSAGE
assert PLAN_MODE_REMINDER in (result.message or "")
@pytest.mark.asyncio
async def test_pipeline_skips_injection_when_not_plan_mode(self) -> None:
pipeline = MiddlewarePipeline()
pipeline.add(PlanModeMiddleware(lambda: AgentMode.DEFAULT))
ctx = make_context()
result = await pipeline.run_before_turn(ctx)
assert result.action == MiddlewareAction.CONTINUE

323
tests/test_modes.py Normal file
View file

@ -0,0 +1,323 @@
from __future__ import annotations
import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent import Agent
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.llm.format import get_active_tool_classes
from vibe.core.modes import (
MODE_CONFIGS,
PLAN_MODE_TOOLS,
AgentMode,
ModeConfig,
ModeSafety,
get_mode_order,
next_mode,
)
from vibe.core.tools.base import ToolPermission
from vibe.core.types import (
FunctionCall,
LLMChunk,
LLMMessage,
LLMUsage,
Role,
ToolCall,
ToolResultEvent,
)
class TestModeSafety:
def test_safety_enum_values(self) -> None:
assert ModeSafety.SAFE == "safe"
assert ModeSafety.NEUTRAL == "neutral"
assert ModeSafety.DESTRUCTIVE == "destructive"
assert ModeSafety.YOLO == "yolo"
def test_default_mode_is_neutral(self) -> None:
assert AgentMode.DEFAULT.safety == ModeSafety.NEUTRAL
def test_auto_approve_mode_is_yolo(self) -> None:
assert AgentMode.AUTO_APPROVE.safety == ModeSafety.YOLO
def test_plan_mode_is_safe(self) -> None:
assert AgentMode.PLAN.safety == ModeSafety.SAFE
def test_accept_edits_mode_is_destructive(self) -> None:
assert AgentMode.ACCEPT_EDITS.safety == ModeSafety.DESTRUCTIVE
class TestAgentMode:
def test_all_modes_have_configs(self) -> None:
for mode in AgentMode:
assert mode in MODE_CONFIGS
def test_display_name_property(self) -> None:
assert AgentMode.DEFAULT.display_name == "Default"
assert AgentMode.AUTO_APPROVE.display_name == "Auto Approve"
assert AgentMode.PLAN.display_name == "Plan"
assert AgentMode.ACCEPT_EDITS.display_name == "Accept Edits"
def test_description_property(self) -> None:
assert "approval" in AgentMode.DEFAULT.description.lower()
assert "auto" in AgentMode.AUTO_APPROVE.description.lower()
assert "read-only" in AgentMode.PLAN.description.lower()
assert "edits" in AgentMode.ACCEPT_EDITS.description.lower()
def test_auto_approve_property(self) -> None:
assert AgentMode.DEFAULT.auto_approve is False
assert AgentMode.AUTO_APPROVE.auto_approve is True
assert AgentMode.PLAN.auto_approve is True
assert AgentMode.ACCEPT_EDITS.auto_approve is False
def test_from_string_valid(self) -> None:
assert AgentMode.from_string("default") == AgentMode.DEFAULT
assert AgentMode.from_string("AUTO_APPROVE") == AgentMode.AUTO_APPROVE
assert AgentMode.from_string("Plan") == AgentMode.PLAN
assert AgentMode.from_string("accept_edits") == AgentMode.ACCEPT_EDITS
def test_from_string_invalid(self) -> None:
assert AgentMode.from_string("invalid") is None
assert AgentMode.from_string("") is None
class TestModeConfigOverrides:
def test_default_mode_has_no_overrides(self) -> None:
assert AgentMode.DEFAULT.config_overrides == {}
def test_auto_approve_mode_has_no_overrides(self) -> None:
assert AgentMode.AUTO_APPROVE.config_overrides == {}
def test_plan_mode_restricts_tools(self) -> None:
overrides = AgentMode.PLAN.config_overrides
assert "enabled_tools" in overrides
assert overrides["enabled_tools"] == PLAN_MODE_TOOLS
def test_accept_edits_mode_sets_tool_permissions(self) -> None:
overrides = AgentMode.ACCEPT_EDITS.config_overrides
assert "tools" in overrides
tools_config = overrides["tools"]
assert "write_file" in tools_config
assert "search_replace" in tools_config
assert tools_config["write_file"]["permission"] == "always"
assert tools_config["search_replace"]["permission"] == "always"
class TestModeCycling:
def test_get_mode_order_includes_all_modes(self) -> None:
order = get_mode_order()
assert len(order) == 4
assert AgentMode.DEFAULT in order
assert AgentMode.AUTO_APPROVE in order
assert AgentMode.PLAN in order
assert AgentMode.ACCEPT_EDITS in order
def test_next_mode_cycles_through_all(self) -> None:
order = get_mode_order()
current = order[0]
visited = [current]
for _ in range(len(order) - 1):
current = next_mode(current)
visited.append(current)
assert len(set(visited)) == len(order)
def test_next_mode_wraps_around(self) -> None:
order = get_mode_order()
last_mode = order[-1]
first_mode = order[0]
assert next_mode(last_mode) == first_mode
class TestModeConfig:
def test_mode_config_defaults(self) -> None:
config = ModeConfig(display_name="Test", description="Test mode")
assert config.safety == ModeSafety.NEUTRAL
assert config.auto_approve is False
assert config.config_overrides == {}
def test_mode_config_frozen(self) -> None:
config = ModeConfig(display_name="Test", description="Test mode")
with pytest.raises(AttributeError):
config.display_name = "Changed" # pyright: ignore[reportAttributeAccessIssue]
class TestAgentSwitchMode:
@pytest.fixture
def base_config(self) -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
include_project_context=False,
include_prompt_detail=False,
)
@pytest.fixture
def backend(self) -> FakeBackend:
return FakeBackend([
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Test response"),
finish_reason="stop",
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
)
])
@pytest.mark.asyncio
async def test_switch_to_plan_mode_restricts_tools(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
initial_tools = get_active_tool_classes(agent.tool_manager, agent.config)
initial_tool_names = {t.get_name() for t in initial_tools}
assert len(initial_tool_names) > len(PLAN_MODE_TOOLS)
await agent.switch_mode(AgentMode.PLAN)
plan_tools = get_active_tool_classes(agent.tool_manager, agent.config)
plan_tool_names = {t.get_name() for t in plan_tools}
assert plan_tool_names == set(PLAN_MODE_TOOLS)
assert agent.mode == AgentMode.PLAN
@pytest.mark.asyncio
async def test_switch_from_plan_to_normal_restores_tools(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
plan_config = VibeConfig.model_validate({
**base_config.model_dump(),
**AgentMode.PLAN.config_overrides,
})
agent = Agent(plan_config, mode=AgentMode.PLAN, backend=backend)
plan_tools = get_active_tool_classes(agent.tool_manager, agent.config)
assert len(plan_tools) == len(PLAN_MODE_TOOLS)
await agent.switch_mode(AgentMode.DEFAULT)
normal_tools = get_active_tool_classes(agent.tool_manager, agent.config)
assert len(normal_tools) > len(PLAN_MODE_TOOLS)
assert agent.mode == AgentMode.DEFAULT
@pytest.mark.asyncio
async def test_switch_mode_preserves_conversation_history(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
user_msg = LLMMessage(role=Role.user, content="Hello")
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there")
agent.messages.append(user_msg)
agent.messages.append(assistant_msg)
await agent.switch_mode(AgentMode.PLAN)
assert len(agent.messages) == 3 # system + user + assistant
assert agent.messages[1].content == "Hello"
assert agent.messages[2].content == "Hi there"
@pytest.mark.asyncio
async def test_switch_to_same_mode_is_noop(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = Agent(base_config, mode=AgentMode.DEFAULT, backend=backend)
original_config = agent.config
await agent.switch_mode(AgentMode.DEFAULT)
assert agent.config is original_config
assert agent.mode == AgentMode.DEFAULT
class TestAcceptEditsMode:
def test_accept_edits_config_sets_write_file_always(self) -> None:
overrides = AgentMode.ACCEPT_EDITS.config_overrides
assert overrides["tools"]["write_file"]["permission"] == "always"
def test_accept_edits_config_sets_search_replace_always(self) -> None:
overrides = AgentMode.ACCEPT_EDITS.config_overrides
assert overrides["tools"]["search_replace"]["permission"] == "always"
def test_accept_edits_mode_not_auto_approve(self) -> None:
assert AgentMode.ACCEPT_EDITS.auto_approve is False
@pytest.mark.asyncio
async def test_accept_edits_mode_auto_approves_write_file(self) -> None:
backend = FakeBackend([])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
enabled_tools=["write_file"],
**AgentMode.ACCEPT_EDITS.config_overrides,
)
agent = Agent(config, mode=AgentMode.ACCEPT_EDITS, backend=backend)
perm = agent.tool_manager.get_tool_config("write_file").permission
assert perm == ToolPermission.ALWAYS
@pytest.mark.asyncio
async def test_accept_edits_mode_requires_approval_for_other_tools(self) -> None:
backend = FakeBackend([])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
enabled_tools=["bash"],
**AgentMode.ACCEPT_EDITS.config_overrides,
)
agent = Agent(config, mode=AgentMode.ACCEPT_EDITS, backend=backend)
perm = agent.tool_manager.get_tool_config("bash").permission
assert perm == ToolPermission.ASK
class TestPlanModeToolRestriction:
@pytest.mark.asyncio
async def test_plan_mode_only_exposes_read_tools_to_llm(self) -> None:
backend = FakeBackend([
LLMChunk(
message=LLMMessage(role=Role.assistant, content="ok"),
finish_reason="stop",
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
)
])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
**AgentMode.PLAN.config_overrides,
)
agent = Agent(config, mode=AgentMode.PLAN, backend=backend)
active_tools = get_active_tool_classes(agent.tool_manager, agent.config)
tool_names = {t.get_name() for t in active_tools}
assert "bash" not in tool_names
assert "write_file" not in tool_names
assert "search_replace" not in tool_names
for plan_tool in PLAN_MODE_TOOLS:
assert plan_tool in tool_names
@pytest.mark.asyncio
async def test_plan_mode_rejects_non_plan_tool_call(self) -> None:
tool_call = ToolCall(
id="call_1",
function=FunctionCall(name="bash", arguments='{"command": "ls"}'),
)
backend = FakeBackend([
mock_llm_chunk(content="Let me run bash", tool_calls=[tool_call]),
mock_llm_chunk(content="Tool not available", finish_reason="stop"),
])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
**AgentMode.PLAN.config_overrides,
)
agent = Agent(config, mode=AgentMode.PLAN, backend=backend)
events = [ev async for ev in agent.act("Run ls")]
tool_result = next((e for e in events if isinstance(e, ToolResultEvent)), None)
assert tool_result is not None
assert tool_result.error is not None
assert (
"not found" in tool_result.error.lower()
or "error" in tool_result.error.lower()
)

View file

@ -21,6 +21,7 @@ from vibe.cli.update_notifier import (
VersionUpdateGatewayError, VersionUpdateGatewayError,
) )
from vibe.core.config import SessionLoggingConfig, VibeConfig from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.modes import AgentMode
async def _wait_for_notification( async def _wait_for_notification(
@ -66,7 +67,7 @@ class VibeAppFactory(Protocol):
notifier: FakeVersionUpdateGateway, notifier: FakeVersionUpdateGateway,
update_cache_repository: FakeUpdateCacheRepository | None = None, update_cache_repository: FakeUpdateCacheRepository | None = None,
config: VibeConfig | None = None, config: VibeConfig | None = None,
auto_approve: bool = False, initial_mode: AgentMode = AgentMode.DEFAULT,
current_version: str = "0.1.0", current_version: str = "0.1.0",
) -> VibeApp: ... ) -> VibeApp: ...
@ -81,12 +82,12 @@ def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeApp
update_cache_repository: FakeUpdateCacheRepository update_cache_repository: FakeUpdateCacheRepository
| None = update_cache_repository, | None = update_cache_repository,
config: VibeConfig | None = None, config: VibeConfig | None = None,
auto_approve: bool = False, initial_mode: AgentMode = AgentMode.DEFAULT,
current_version: str = "0.1.0", current_version: str = "0.1.0",
) -> VibeApp: ) -> VibeApp:
return VibeApp( return VibeApp(
config=config or vibe_config_with_update_checks_enabled, config=config or vibe_config_with_update_checks_enabled,
auto_approve=auto_approve, initial_mode=initial_mode,
version_update_notifier=notifier, version_update_notifier=notifier,
update_cache_repository=update_cache_repository, update_cache_repository=update_cache_repository,
current_version=current_version, current_version=current_version,

8
uv.lock generated
View file

@ -661,7 +661,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "1.1.3" version = "1.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },
@ -1512,7 +1512,7 @@ wheels = [
[[package]] [[package]]
name = "textual" name = "textual"
version = "6.7.1" version = "6.9.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] }, { name = "markdown-it-py", extra = ["linkify"] },
@ -1522,9 +1522,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ab/00/9520327698acb6d8ae120b311ef1901840d55a6c41580e377f36261daf7a/textual-6.7.1.tar.gz", hash = "sha256:2a5acb0ab316a7ba9e74b0a291fab8933d681d7cf6f4e1eeb45c39a731b094cf", size = 1580916, upload-time = "2025-12-01T20:57:25.578Z" } sdist = { url = "https://files.pythonhosted.org/packages/97/8c/774b53a1256fe2649e708b634334d5ce68568d178e55cf3099265c639051/textual-6.9.0.tar.gz", hash = "sha256:49201129a21f65cc16003ce3855cd941a4de7d58eac9489d0e390ba501d712b6", size = 1582278, upload-time = "2025-12-14T17:15:43.03Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/7a/7f3ea5e6f26d546ee4bd107df8fc9eef9f149dab0f6f15e1fc9f9413231f/textual-6.7.1-py3-none-any.whl", hash = "sha256:b92977ac5941dd37b6b7dc0ac021850ce8d9bf2e123c5bab7ff2016f215272e0", size = 713993, upload-time = "2025-12-01T20:57:23.698Z" }, { url = "https://files.pythonhosted.org/packages/9d/3c/76b607e07d5d63d32d4c4e2803f392913e8e07d5a489fa46f5c25e554ea9/textual-6.9.0-py3-none-any.whl", hash = "sha256:5ded2824fcfc7311b09ac440abb6fe248b3c1431d0403dc0d096795c4f18303c", size = 714791, upload-time = "2025-12-14T17:15:41.287Z" },
] ]
[[package]] [[package]]

View file

@ -3,3 +3,4 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
VIBE_ROOT = Path(__file__).parent VIBE_ROOT = Path(__file__).parent
__version__ = "1.2.0"

View file

@ -46,17 +46,23 @@ from acp.schema import (
) )
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT from vibe import VIBE_ROOT, __version__
from vibe.acp.tools.base import BaseAcpTool from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import ( from vibe.acp.tools.session_update import (
tool_call_session_update, tool_call_session_update,
tool_result_session_update, tool_result_session_update,
) )
from vibe.acp.utils import TOOL_OPTIONS, ToolOption, VibeSessionMode from vibe.acp.utils import (
from vibe.core import __version__ TOOL_OPTIONS,
ToolOption,
acp_to_agent_mode,
get_all_acp_session_modes,
is_valid_acp_mode,
)
from vibe.core.agent import Agent as VibeAgent from vibe.core.agent import Agent as VibeAgent
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import ( from vibe.core.types import (
ApprovalResponse, ApprovalResponse,
@ -72,7 +78,6 @@ class AcpSession(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
id: str id: str
agent: VibeAgent agent: VibeAgent
mode_id: VibeSessionMode = VibeSessionMode.APPROVAL_REQUIRED
task: asyncio.Task[None] | None = None task: asyncio.Task[None] | None = None
@ -154,9 +159,11 @@ class VibeAcpAgent(AcpAgent):
async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: async def newSession(self, params: NewSessionRequest) -> NewSessionResponse:
capability_disabled_tools = self._get_disabled_tools_from_capabilities() capability_disabled_tools = self._get_disabled_tools_from_capabilities()
load_api_keys_from_env() load_api_keys_from_env()
cwd = Path(params.cwd)
try: try:
config = VibeConfig.load( config = VibeConfig.load(
workdir=Path(params.cwd), workdir=cwd,
tool_paths=[str(VIBE_ROOT / "acp" / "tools" / "builtins")], tool_paths=[str(VIBE_ROOT / "acp" / "tools" / "builtins")],
disabled_tools=capability_disabled_tools, disabled_tools=capability_disabled_tools,
) )
@ -165,7 +172,7 @@ class VibeAcpAgent(AcpAgent):
"message": "You must be authenticated before creating a new session" "message": "You must be authenticated before creating a new session"
}) from e }) from e
agent = VibeAgent(config=config, auto_approve=False, enable_streaming=True) agent = VibeAgent(config=config, mode=AgentMode.DEFAULT, enable_streaming=True)
# NOTE: For now, we pin session.id to agent.session_id right after init time. # NOTE: For now, we pin session.id to agent.session_id right after init time.
# We should just use agent.session_id everywhere, but it can still change during # We should just use agent.session_id everywhere, but it can still change during
# session lifetime (e.g. agent.compact is called). # session lifetime (e.g. agent.compact is called).
@ -188,8 +195,8 @@ class VibeAcpAgent(AcpAgent):
], ],
), ),
modes=SessionModeState( modes=SessionModeState(
currentModeId=session.mode_id, currentModeId=session.agent.mode.value,
availableModes=VibeSessionMode.get_all_acp_session_modes(), availableModes=get_all_acp_session_modes(),
), ),
) )
return response return response
@ -280,11 +287,21 @@ class VibeAcpAgent(AcpAgent):
) -> SetSessionModeResponse | None: ) -> SetSessionModeResponse | None:
session = self._get_session(params.sessionId) session = self._get_session(params.sessionId)
if not VibeSessionMode.is_valid(params.modeId): if not is_valid_acp_mode(params.modeId):
return None return None
session.mode_id = VibeSessionMode(params.modeId) new_mode = acp_to_agent_mode(params.modeId)
session.agent.auto_approve = params.modeId == VibeSessionMode.AUTO_APPROVE if new_mode is None:
return None
await session.agent.switch_mode(new_mode)
if new_mode.auto_approve:
session.agent.approval_callback = None
else:
session.agent.set_approval_callback(
self._create_approval_callback(session.id)
)
return SetSessionModeResponse() return SetSessionModeResponse()

View file

@ -4,8 +4,8 @@ import argparse
from dataclasses import dataclass from dataclasses import dataclass
import sys import sys
from vibe.acp.acp_agent import run_acp_server from vibe import __version__
from vibe.setup.onboarding import run_onboarding from vibe.core.paths.config_paths import unlock_config_paths
# Configure line buffering for subprocess communication # Configure line buffering for subprocess communication
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue] sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
@ -20,12 +20,20 @@ class Arguments:
def parse_arguments() -> Arguments: def parse_arguments() -> Arguments:
parser = argparse.ArgumentParser(description="Run Mistral Vibe in ACP mode") parser = argparse.ArgumentParser(description="Run Mistral Vibe in ACP mode")
parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit") parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
args = parser.parse_args() args = parser.parse_args()
return Arguments(setup=args.setup) return Arguments(setup=args.setup)
def main() -> None: def main() -> None:
unlock_config_paths()
from vibe.acp.acp_agent import run_acp_server
from vibe.setup.onboarding import run_onboarding
args = parse_arguments() args = parse_arguments()
if args.setup: if args.setup:
run_onboarding() run_onboarding()

View file

@ -1,47 +1,11 @@
from __future__ import annotations from __future__ import annotations
import enum
from enum import StrEnum from enum import StrEnum
from typing import Literal, cast from typing import Literal, cast
from acp.schema import PermissionOption, SessionMode from acp.schema import PermissionOption, SessionMode
from vibe.core.modes import MODE_CONFIGS, AgentMode
class VibeSessionMode(enum.StrEnum):
APPROVAL_REQUIRED = enum.auto()
AUTO_APPROVE = enum.auto()
def to_acp_session_mode(self) -> SessionMode:
match self:
case self.APPROVAL_REQUIRED:
return SessionMode(
id=VibeSessionMode.APPROVAL_REQUIRED,
name="Approval Required",
description="Requires user approval for tool executions",
)
case self.AUTO_APPROVE:
return SessionMode(
id=VibeSessionMode.AUTO_APPROVE,
name="Auto Approve",
description="Automatically approves all tool executions",
)
@classmethod
def from_acp_session_mode(cls, session_mode: SessionMode) -> VibeSessionMode | None:
if not cls.is_valid(session_mode.id):
return None
return cls(session_mode.id)
@classmethod
def is_valid(cls, mode_id: str) -> bool:
try:
return cls(mode_id).to_acp_session_mode() is not None
except (ValueError, KeyError):
return False
@classmethod
def get_all_acp_session_modes(cls) -> list[SessionMode]:
return [mode.to_acp_session_mode() for mode in cls]
class ToolOption(StrEnum): class ToolOption(StrEnum):
@ -68,3 +32,22 @@ TOOL_OPTIONS = [
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE), kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
), ),
] ]
def agent_mode_to_acp(mode: AgentMode) -> SessionMode:
config = MODE_CONFIGS[mode]
return SessionMode(
id=mode.value, name=config.display_name, description=config.description
)
def acp_to_agent_mode(mode_id: str) -> AgentMode | None:
return AgentMode.from_string(mode_id)
def is_valid_acp_mode(mode_id: str) -> bool:
return AgentMode.from_string(mode_id) is not None
def get_all_acp_session_modes() -> list[SessionMode]:
return [agent_mode_to_acp(mode) for mode in AgentMode]

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import atexit
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from threading import Lock from threading import Lock
@ -171,3 +172,6 @@ class PathCompletionController:
self._view.replace_completion_range(start, end, completion) self._view.replace_completion_range(start, end, completion)
self.reset() self.reset()
return True return True
atexit.register(PathCompletionController._executor.shutdown)

View file

@ -5,7 +5,7 @@ from textual import events
from vibe.cli.autocompletion.base import CompletionResult, CompletionView from vibe.cli.autocompletion.base import CompletionResult, CompletionView
from vibe.core.autocompletion.completers import CommandCompleter from vibe.core.autocompletion.completers import CommandCompleter
MAX_SUGGESTIONS_COUNT = 5 MAX_SUGGESTIONS_COUNT = 10
class SlashCommandController: class SlashCommandController:

186
vibe/cli/cli.py Normal file
View file

@ -0,0 +1,186 @@
from __future__ import annotations
import argparse
import sys
from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.config import (
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_api_keys_from_env,
)
from vibe.core.interaction_logger import InteractionLogger
from vibe.core.modes import AgentMode
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.types import LLMMessage, OutputFormat
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
def get_initial_mode(args: argparse.Namespace) -> AgentMode:
if args.plan:
return AgentMode.PLAN
if args.auto_approve:
return AgentMode.AUTO_APPROVE
return AgentMode.DEFAULT
def get_prompt_from_stdin() -> str | None:
if sys.stdin.isatty():
return None
try:
if content := sys.stdin.read().strip():
sys.stdin = sys.__stdin__ = open("/dev/tty")
return content
except KeyboardInterrupt:
pass
except OSError:
return None
return None
def load_config_or_exit(
agent: str | None = None, mode: AgentMode = AgentMode.DEFAULT
) -> VibeConfig:
try:
return VibeConfig.load(agent, **mode.config_overrides)
except MissingAPIKeyError:
run_onboarding()
return VibeConfig.load(agent, **mode.config_overrides)
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
except ValueError as e:
rprint(f"[yellow]{e}[/]")
sys.exit(1)
def bootstrap_config_files() -> None:
if not CONFIG_FILE.path.exists():
try:
VibeConfig.save_updates(VibeConfig.create_default())
except Exception as e:
rprint(f"[yellow]Could not create default config file: {e}[/]")
if not INSTRUCTIONS_FILE.path.exists():
try:
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
INSTRUCTIONS_FILE.path.touch()
except Exception as e:
rprint(f"[yellow]Could not create instructions file: {e}[/]")
if not HISTORY_FILE.path.exists():
try:
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
except Exception as e:
rprint(f"[yellow]Could not create history file: {e}[/]")
def load_session(
args: argparse.Namespace, config: VibeConfig
) -> list[LLMMessage] | None:
if not args.continue_session and not args.resume:
return None
if not config.session_logging.enabled:
rprint(
"[red]Session logging is disabled. "
"Enable it in config to use --continue or --resume[/]"
)
sys.exit(1)
session_to_load = None
if args.continue_session:
session_to_load = InteractionLogger.find_latest_session(config.session_logging)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
else:
session_to_load = InteractionLogger.find_session_by_id(
args.resume, config.session_logging
)
if not session_to_load:
rprint(
f"[red]Session '{args.resume}' not found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
try:
loaded_messages, _ = InteractionLogger.load_session(session_to_load)
return loaded_messages
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
def run_cli(args: argparse.Namespace) -> None:
load_api_keys_from_env()
if args.setup:
run_onboarding()
sys.exit(0)
try:
bootstrap_config_files()
initial_mode = get_initial_mode(args)
config = load_config_or_exit(args.agent, initial_mode)
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
loaded_messages = load_session(args, config)
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print(
"Error: No prompt provided for programmatic mode", file=sys.stderr
)
sys.exit(1)
output_format = OutputFormat(
args.output if hasattr(args, "output") else "text"
)
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt,
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
mode=initial_mode,
)
if final_response:
print(final_response)
sys.exit(0)
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
run_textual_ui(
config,
initial_mode=initial_mode,
enable_streaming=True,
initial_prompt=args.initial_prompt or stdin_prompt,
loaded_messages=loaded_messages,
)
except (KeyboardInterrupt, EOFError):
rprint("\n[dim]Bye![/]")
sys.exit(0)

View file

@ -17,46 +17,51 @@ class CommandRegistry:
excluded_commands = [] excluded_commands = []
self.commands = { self.commands = {
"help": Command( "help": Command(
aliases=frozenset(["/help", "/h"]), aliases=frozenset(["/help"]),
description="Show help message", description="Show help message",
handler="_show_help", handler="_show_help",
), ),
"status": Command(
aliases=frozenset(["/status", "/stats"]),
description="Display agent statistics",
handler="_show_status",
),
"config": Command( "config": Command(
aliases=frozenset(["/config", "/cfg", "/theme", "/model"]), aliases=frozenset(["/config", "/theme", "/model"]),
description="Edit config settings", description="Edit config settings",
handler="_show_config", handler="_show_config",
), ),
"reload": Command( "reload": Command(
aliases=frozenset(["/reload", "/r"]), aliases=frozenset(["/reload"]),
description="Reload configuration from disk", description="Reload configuration from disk",
handler="_reload_config", handler="_reload_config",
), ),
"clear": Command( "clear": Command(
aliases=frozenset(["/clear", "/reset"]), aliases=frozenset(["/clear"]),
description="Clear conversation history", description="Clear conversation history",
handler="_clear_history", handler="_clear_history",
), ),
"log": Command( "log": Command(
aliases=frozenset(["/log", "/logpath"]), aliases=frozenset(["/log"]),
description="Show path to current interaction log file", description="Show path to current interaction log file",
handler="_show_log_path", handler="_show_log_path",
), ),
"compact": Command( "compact": Command(
aliases=frozenset(["/compact", "/summarize"]), aliases=frozenset(["/compact"]),
description="Compact conversation history by summarizing", description="Compact conversation history by summarizing",
handler="_compact_history", handler="_compact_history",
), ),
"exit": Command( "exit": Command(
aliases=frozenset(["/exit", "/quit", "/q"]), aliases=frozenset(["/exit"]),
description="Exit the application", description="Exit the application",
handler="_exit_app", handler="_exit_app",
exits=True, exits=True,
), ),
"terminal-setup": Command(
aliases=frozenset(["/terminal-setup"]),
description="Configure Shift+Enter for newlines",
handler="_setup_terminal",
),
"status": Command(
aliases=frozenset(["/status"]),
description="Display agent statistics",
handler="_show_status",
),
} }
for command in excluded_commands: for command in excluded_commands:

View file

@ -1,27 +1,25 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from pathlib import Path
import sys import sys
from rich import print as rprint from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui from vibe import __version__
from vibe.core.config import ( from vibe.core.paths.config_paths import unlock_config_paths
MissingAPIKeyError, from vibe.core.trusted_folders import trusted_folders_manager
MissingPromptFileError, from vibe.setup.trusted_folders.trust_folder_dialog import (
VibeConfig, TrustDialogQuitException,
load_api_keys_from_env, ask_trust_folder,
) )
from vibe.core.config_path import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
from vibe.core.interaction_logger import InteractionLogger
from vibe.core.programmatic import run_programmatic
from vibe.core.types import OutputFormat, ResumeSessionInfo
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
def parse_arguments() -> argparse.Namespace: def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI") parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI")
parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
)
parser.add_argument( parser.add_argument(
"initial_prompt", "initial_prompt",
nargs="?", nargs="?",
@ -41,7 +39,13 @@ def parse_arguments() -> argparse.Namespace:
"--auto-approve", "--auto-approve",
action="store_true", action="store_true",
default=False, default=False,
help="Automatically approve all tool executions.", help="Start in auto-approve mode: never ask for approval before running tools.",
)
parser.add_argument(
"--plan",
action="store_true",
default=False,
help="Start in plan mode: read-only tools for exploration and planning.",
) )
parser.add_argument( parser.add_argument(
"--max-turns", "--max-turns",
@ -99,160 +103,41 @@ def parse_arguments() -> argparse.Namespace:
return parser.parse_args() return parser.parse_args()
def get_prompt_from_stdin() -> str | None: def check_and_resolve_trusted_folder() -> None:
if sys.stdin.isatty(): cwd = Path.cwd()
return None if not (cwd / ".vibe").exists():
return
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
if is_folder_trusted is not None:
return
try: try:
if content := sys.stdin.read().strip(): is_folder_trusted = ask_trust_folder(cwd)
sys.stdin = sys.__stdin__ = open("/dev/tty") except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
return content sys.exit(0)
except KeyboardInterrupt: except Exception as e:
pass rprint(f"[yellow]Error showing trust dialog: {e}[/]")
except OSError: return
return None
return None if is_folder_trusted is True:
trusted_folders_manager.add_trusted(cwd)
elif is_folder_trusted is False:
trusted_folders_manager.add_untrusted(cwd)
def load_config_or_exit(agent: str | None = None) -> VibeConfig: def main() -> None:
try:
return VibeConfig.load(agent)
except MissingAPIKeyError:
run_onboarding()
return VibeConfig.load(agent)
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
except ValueError as e:
rprint(f"[yellow]{e}[/]")
sys.exit(1)
def main() -> None: # noqa: PLR0912, PLR0915
load_api_keys_from_env()
args = parse_arguments() args = parse_arguments()
if args.setup: is_interactive = args.prompt is None
run_onboarding() if is_interactive:
sys.exit(0) check_and_resolve_trusted_folder()
try: unlock_config_paths()
if not CONFIG_FILE.path.exists():
try:
VibeConfig.save_updates(VibeConfig.create_default())
except Exception as e:
rprint(f"[yellow]Could not create default config file: {e}[/]")
if not INSTRUCTIONS_FILE.path.exists(): from vibe.cli.cli import run_cli
try:
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
INSTRUCTIONS_FILE.path.touch()
except Exception as e:
rprint(f"[yellow]Could not create instructions file: {e}[/]")
if not HISTORY_FILE.path.exists(): run_cli(args)
try:
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
except Exception as e:
rprint(f"[yellow]Could not create history file: {e}[/]")
config = load_config_or_exit(args.agent)
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
loaded_messages = None
session_info = None
if args.continue_session or args.resume:
if not config.session_logging.enabled:
rprint(
"[red]Session logging is disabled. "
"Enable it in config to use --continue or --resume[/]"
)
sys.exit(1)
session_to_load = None
if args.continue_session:
session_to_load = InteractionLogger.find_latest_session(
config.session_logging
)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
else:
session_to_load = InteractionLogger.find_session_by_id(
args.resume, config.session_logging
)
if not session_to_load:
rprint(
f"[red]Session '{args.resume}' not found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
try:
loaded_messages, metadata = InteractionLogger.load_session(
session_to_load
)
session_id = metadata.get("session_id", "unknown")[:8]
session_time = metadata.get("start_time", "unknown time")
session_info = ResumeSessionInfo(
type="continue" if args.continue_session else "resume",
session_id=session_id,
session_time=session_time,
)
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print(
"Error: No prompt provided for programmatic mode", file=sys.stderr
)
sys.exit(1)
output_format = OutputFormat(
args.output if hasattr(args, "output") else "text"
)
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt,
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
)
if final_response:
print(final_response)
sys.exit(0)
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
run_textual_ui(
config,
auto_approve=args.auto_approve,
enable_streaming=True,
initial_prompt=args.initial_prompt or stdin_prompt,
loaded_messages=loaded_messages,
session_info=session_info,
)
except (KeyboardInterrupt, EOFError):
rprint("\n[dim]Bye![/]")
sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":

400
vibe/cli/terminal_setup.py Normal file
View file

@ -0,0 +1,400 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import json
import os
from pathlib import Path
import platform
import subprocess
from typing import Any
class Terminal(Enum):
VSCODE = "vscode"
CURSOR = "cursor"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
GHOSTTY = "ghostty"
UNKNOWN = "unknown"
@dataclass
class SetupResult:
success: bool
terminal: Terminal
message: str
requires_restart: bool = False
def _is_cursor() -> bool:
path_indicators = [
"VSCODE_GIT_ASKPASS_NODE",
"VSCODE_GIT_ASKPASS_MAIN",
"VSCODE_IPC_HOOK_CLI",
"VSCODE_NLS_CONFIG",
]
for var in path_indicators:
val = os.environ.get(var, "").lower()
if "cursor" in val:
return True
return False
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
if term_program == "vscode":
if _is_cursor():
return Terminal.CURSOR
return Terminal.VSCODE
term_map = {
"iterm.app": Terminal.ITERM2,
"wezterm": Terminal.WEZTERM,
"ghostty": Terminal.GHOSTTY,
}
if term_program in term_map:
return term_map[term_program]
if os.environ.get("WEZTERM_PANE"):
return Terminal.WEZTERM
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
return Terminal.GHOSTTY
return Terminal.UNKNOWN
def _get_vscode_keybindings_path() -> Path | None:
system = platform.system()
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / "Code" / "User"
elif system == "Linux":
base = Path.home() / ".config" / "Code" / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / "Code" / "User"
else:
return None
else:
return None
return base / "keybindings.json"
def _get_cursor_keybindings_path() -> Path | None:
system = platform.system()
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / "Cursor" / "User"
elif system == "Linux":
base = Path.home() / ".config" / "Cursor" / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / "Cursor" / "User"
else:
return None
else:
return None
return base / "keybindings.json"
def _parse_keybindings(content: str) -> list[dict[str, Any]]:
content = content.strip()
if not content or content.startswith("//"):
return []
lines = [line for line in content.split("\n") if not line.strip().startswith("//")]
clean_content = "\n".join(lines)
try:
return json.loads(clean_content)
except json.JSONDecodeError:
return []
def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
"""Setup keybindings for VSCode or Cursor."""
if terminal == Terminal.CURSOR:
keybindings_path = _get_cursor_keybindings_path()
editor_name = "Cursor"
else:
keybindings_path = _get_vscode_keybindings_path()
editor_name = "VSCode"
if keybindings_path is None:
return SetupResult(
success=False,
terminal=terminal,
message=f"Could not determine keybindings path for {editor_name}",
)
new_binding = {
"key": "shift+enter",
"command": "workbench.action.terminal.sendSequence",
"args": {"text": "\u001b[13;2u"},
"when": "terminalFocus",
}
try:
keybindings = _read_existing_keybindings(keybindings_path)
if _has_shift_enter_binding(keybindings):
return SetupResult(
success=True,
terminal=terminal,
message=f"Shift+Enter already configured in {editor_name}",
)
keybindings.append(new_binding)
keybindings_path.write_text(json.dumps(keybindings, indent=2) + "\n")
return SetupResult(
success=True,
terminal=terminal,
message=f"Added Shift+Enter binding to {keybindings_path}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=terminal,
message=f"Failed to configure {editor_name}: {e}",
)
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = keybindings_path.read_text()
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []
def _has_shift_enter_binding(keybindings: list[dict[str, Any]]) -> bool:
for binding in keybindings:
if (
binding.get("key") == "shift+enter"
and binding.get("command") == "workbench.action.terminal.sendSequence"
and binding.get("when") == "terminalFocus"
):
return True
return False
def _setup_iterm2() -> SetupResult:
if platform.system() != "Darwin":
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message="iTerm2 is only available on macOS",
)
plist_key = "0xd-0x20000-0x24"
plist_value = """<dict>
<key>Text</key>
<string>\\n</string>
<key>Action</key>
<integer>12</integer>
<key>Version</key>
<integer>1</integer>
<key>Keycode</key>
<integer>13</integer>
<key>Modifiers</key>
<integer>131072</integer>
</dict>"""
try:
result = subprocess.run(
["defaults", "read", "com.googlecode.iterm2", "GlobalKeyMap"],
capture_output=True,
text=True,
)
if plist_key in result.stdout:
return SetupResult(
success=True,
terminal=Terminal.ITERM2,
message="Shift+Enter already configured in iTerm2",
)
subprocess.run(
[
"defaults",
"write",
"com.googlecode.iterm2",
"GlobalKeyMap",
"-dict-add",
plist_key,
plist_value,
],
check=True,
capture_output=True,
)
return SetupResult(
success=True,
terminal=Terminal.ITERM2,
message="Added Shift+Enter binding to iTerm2 preferences",
requires_restart=True,
)
except subprocess.CalledProcessError as e:
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message=f"Failed to configure iTerm2: {e.stderr}",
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message=f"Failed to configure iTerm2: {e}",
)
def _setup_wezterm() -> SetupResult:
wezterm_config = Path.home() / ".wezterm.lua"
key_binding = """{
key = "Enter",
mods = "SHIFT",
action = wezterm.action.SendString("\\x1b[13;2u"),
}"""
try:
if wezterm_config.exists():
content = wezterm_config.read_text()
if 'mods = "SHIFT"' in content and 'key = "Enter"' in content:
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message="Shift+Enter already configured in WezTerm",
)
if "keys = {" in content:
content = content.replace("keys = {", f"keys = {{\n {key_binding},")
else:
return SetupResult(
success=False,
terminal=Terminal.WEZTERM,
message="Please manually add the following to your .wezterm.lua:\n\n"
f" keys = {{\n {key_binding}\n }}",
)
else:
content = f"""local wezterm = require 'wezterm'
return {{
keys = {{
{key_binding}
}},
}}
"""
wezterm_config.write_text(content)
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message=f"Added Shift+Enter binding to {wezterm_config}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.WEZTERM,
message=f"Failed to configure WezTerm: {e}",
)
def _setup_ghostty() -> SetupResult:
system = platform.system()
if system == "Darwin":
config_path = (
Path.home()
/ "Library"
/ "Application Support"
/ "com.mitchellh.ghostty"
/ "config"
)
elif system == "Linux":
xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
config_path = Path(xdg_config) / "ghostty" / "config"
else:
return SetupResult(
success=False,
terminal=Terminal.GHOSTTY,
message="Ghostty configuration path unknown for this OS",
)
keybind_line = "keybind = shift+enter=text:\\x1b[13;2u"
try:
if config_path.exists():
content = config_path.read_text()
if "shift+enter" in content.lower():
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message="Shift+Enter already configured in Ghostty",
)
if not content.endswith("\n"):
content += "\n"
content += keybind_line + "\n"
else:
config_path.parent.mkdir(parents=True, exist_ok=True)
content = keybind_line + "\n"
config_path.write_text(content)
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message=f"Added Shift+Enter binding to {config_path}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.GHOSTTY,
message=f"Failed to configure Ghostty: {e}",
)
def setup_terminal() -> SetupResult:
terminal = detect_terminal()
match terminal:
case Terminal.VSCODE:
return _setup_vscode_like_terminal(Terminal.VSCODE)
case Terminal.CURSOR:
return _setup_vscode_like_terminal(Terminal.CURSOR)
case Terminal.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:
return _setup_wezterm()
case Terminal.GHOSTTY:
return _setup_ghostty()
case Terminal.UNKNOWN:
return SetupResult(
success=False,
terminal=Terminal.UNKNOWN,
message="Could not detect terminal. Supported terminals:\n"
"- VSCode\n"
"- Cursor\n"
"- iTerm2\n"
"- WezTerm\n"
"- Ghostty\n\n"
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
)

View file

@ -3,17 +3,20 @@ from __future__ import annotations
import asyncio import asyncio
from enum import StrEnum, auto from enum import StrEnum, auto
import subprocess import subprocess
import time
from typing import Any, ClassVar, assert_never from typing import Any, ClassVar, assert_never
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType from textual.binding import Binding, BindingType
from textual.containers import Horizontal, VerticalScroll from textual.containers import Horizontal, VerticalScroll
from textual.events import MouseUp from textual.events import AppBlur, AppFocus, MouseUp
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Static from textual.widgets import Static
from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry from vibe.cli.commands import CommandRegistry
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
@ -28,6 +31,7 @@ from vibe.cli.textual_ui.widgets.messages import (
InterruptMessage, InterruptMessage,
UserCommandMessage, UserCommandMessage,
UserMessage, UserMessage,
WarningMessage,
) )
from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator
from vibe.cli.textual_ui.widgets.path_display import PathDisplay from vibe.cli.textual_ui.widgets.path_display import PathDisplay
@ -42,13 +46,13 @@ from vibe.cli.update_notifier import (
VersionUpdateGateway, VersionUpdateGateway,
get_update_if_available, get_update_if_available,
) )
from vibe.core import __version__ as CORE_VERSION
from vibe.core.agent import Agent from vibe.core.agent import Agent
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.config_path import HISTORY_FILE from vibe.core.modes import AgentMode, next_mode
from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role from vibe.core.types import ApprovalResponse, LLMMessage, Role
from vibe.core.utils import ( from vibe.core.utils import (
CancellationReason, CancellationReason,
get_user_cancellation_message, get_user_cancellation_message,
@ -68,7 +72,8 @@ class VibeApp(App):
CSS_PATH = "app.tcss" CSS_PATH = "app.tcss"
BINDINGS: ClassVar[list[BindingType]] = [ BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "force_quit", "Quit", show=False), Binding("ctrl+c", "clear_quit", "Quit", show=False),
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
Binding("escape", "interrupt", "Interrupt", show=False, priority=True), Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False), Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False), Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
@ -82,11 +87,10 @@ class VibeApp(App):
def __init__( def __init__(
self, self,
config: VibeConfig, config: VibeConfig,
auto_approve: bool = False, initial_mode: AgentMode = AgentMode.DEFAULT,
enable_streaming: bool = False, enable_streaming: bool = False,
initial_prompt: str | None = None, initial_prompt: str | None = None,
loaded_messages: list[LLMMessage] | None = None, loaded_messages: list[LLMMessage] | None = None,
session_info: ResumeSessionInfo | None = None,
version_update_notifier: VersionUpdateGateway | None = None, version_update_notifier: VersionUpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None, update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION, current_version: str = CORE_VERSION,
@ -94,7 +98,7 @@ class VibeApp(App):
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.config = config self.config = config
self.auto_approve = auto_approve self._current_agent_mode = initial_mode
self.enable_streaming = enable_streaming self.enable_streaming = enable_streaming
self.agent: Agent | None = None self.agent: Agent | None = None
self._agent_running = False self._agent_running = False
@ -128,12 +132,12 @@ class VibeApp(App):
self._initial_prompt = initial_prompt self._initial_prompt = initial_prompt
self._loaded_messages = loaded_messages self._loaded_messages = loaded_messages
self._session_info = session_info
self._agent_init_task: asyncio.Task | None = None self._agent_init_task: asyncio.Task | None = None
# prevent a race condition where the agent initialization # prevent a race condition where the agent initialization
# completes exactly at the moment the user interrupts # completes exactly at the moment the user interrupts
self._agent_init_interrupted = False self._agent_init_interrupted = False
self._auto_scroll = True self._auto_scroll = True
self._last_escape_time: float | None = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"): with VerticalScroll(id="chat"):
@ -142,7 +146,7 @@ class VibeApp(App):
with Horizontal(id="loading-area"): with Horizontal(id="loading-area"):
yield Static(id="loading-area-content") yield Static(id="loading-area-content")
yield ModeIndicator(auto_approve=self.auto_approve) yield ModeIndicator(mode=self._current_agent_mode)
yield Static(id="todo-area") yield Static(id="todo-area")
@ -151,7 +155,7 @@ class VibeApp(App):
history_file=self.history_file, history_file=self.history_file,
command_registry=self.commands, command_registry=self.commands,
id="input-container", id="input-container",
show_warning=self.auto_approve, safety=self._current_agent_mode.safety,
) )
with Horizontal(id="bottom-bar"): with Horizontal(id="bottom-bar"):
@ -184,8 +188,8 @@ class VibeApp(App):
await self._show_dangerous_directory_warning() await self._show_dangerous_directory_warning()
self._schedule_update_notification() self._schedule_update_notification()
if self._session_info: if self._loaded_messages:
await self._mount_and_scroll(AssistantMessage(self._session_info.message())) await self._rebuild_history_from_messages()
if self._initial_prompt: if self._initial_prompt:
self.call_after_refresh(self._process_initial_prompt) self.call_after_refresh(self._process_initial_prompt)
@ -258,6 +262,21 @@ class VibeApp(App):
if self._loading_widget and self._loading_widget.parent: if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove() await self._loading_widget.remove()
self._loading_widget = None self._loading_widget = None
self._hide_todo_area()
def _show_todo_area(self) -> None:
try:
todo_area = self.query_one("#todo-area")
todo_area.add_class("loading-active")
except Exception:
pass
def _hide_todo_area(self) -> None:
try:
todo_area = self.query_one("#todo-area")
todo_area.remove_class("loading-active")
except Exception:
pass
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None: def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
if message.key == "textual_theme": if message.key == "textual_theme":
@ -307,6 +326,7 @@ class VibeApp(App):
async def _handle_command(self, user_input: str) -> bool: async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input): if command := self.commands.find_command(user_input):
await self._mount_and_scroll(UserMessage(user_input))
handler = getattr(self, command.handler) handler = getattr(self, command.handler)
if asyncio.iscoroutinefunction(handler): if asyncio.iscoroutinefunction(handler):
await handler() await handler()
@ -419,11 +439,11 @@ class VibeApp(App):
try: try:
agent = Agent( agent = Agent(
self.config, self.config,
auto_approve=self.auto_approve, mode=self._current_agent_mode,
enable_streaming=self.enable_streaming, enable_streaming=self.enable_streaming,
) )
if not self.auto_approve: if not self._current_agent_mode.auto_approve:
agent.approval_callback = self._approval_callback agent.approval_callback = self._approval_callback
if self._loaded_messages: if self._loaded_messages:
@ -450,6 +470,58 @@ class VibeApp(App):
self._agent_initializing = False self._agent_initializing = False
self._agent_init_task = None self._agent_init_task = None
async def _rebuild_history_from_messages(self) -> None:
if not self._loaded_messages:
return
messages_area = self.query_one("#messages")
tool_call_map: dict[str, str] = {}
for msg in self._loaded_messages:
if msg.role == Role.system:
continue
match msg.role:
case Role.user:
if msg.content:
await messages_area.mount(UserMessage(msg.content))
case Role.assistant:
await self._mount_history_assistant_message(
msg, messages_area, tool_call_map
)
case Role.tool:
tool_name = msg.name or tool_call_map.get(
msg.tool_call_id or "", "tool"
)
await messages_area.mount(
ToolResultMessage(
tool_name=tool_name,
content=msg.content,
collapsed=self._tools_collapsed,
)
)
async def _mount_history_assistant_message(
self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str]
) -> None:
if msg.content:
widget = AssistantMessage(msg.content)
await messages_area.mount(widget)
await widget.write_initial_content()
await widget.stop_stream()
if not msg.tool_calls:
return
for tool_call in msg.tool_calls:
tool_name = tool_call.function.name or "unknown"
if tool_call.id:
tool_call_map[tool_call.id] = tool_name
await messages_area.mount(ToolCallMessage(tool_name=tool_name))
def _ensure_agent_init_task(self) -> asyncio.Task | None: def _ensure_agent_init_task(self) -> asyncio.Task | None:
if self.agent: if self.agent:
self._agent_init_task = None self._agent_init_task = None
@ -486,6 +558,7 @@ class VibeApp(App):
loading = LoadingWidget() loading = LoadingWidget()
self._loading_widget = loading self._loading_widget = loading
await loading_area.mount(loading) await loading_area.mount(loading)
self._show_todo_area()
try: try:
rendered_prompt = render_path_prompt( rendered_prompt = render_path_prompt(
@ -527,6 +600,7 @@ class VibeApp(App):
if self._loading_widget: if self._loading_widget:
await self._loading_widget.remove() await self._loading_widget.remove()
self._loading_widget = None self._loading_widget = None
self._hide_todo_area()
await self._finalize_current_streaming_message() await self._finalize_current_streaming_message()
async def _interrupt_agent(self) -> None: async def _interrupt_agent(self) -> None:
@ -563,6 +637,8 @@ class VibeApp(App):
self._agent_running = False self._agent_running = False
loading_area = self.query_one("#loading-area-content") loading_area = self.query_one("#loading-area-content")
await loading_area.remove_children() await loading_area.remove_children()
self._loading_widget = None
self._hide_todo_area()
await self._finalize_current_streaming_message() await self._finalize_current_streaming_message()
await self._mount_and_scroll(InterruptMessage()) await self._mount_and_scroll(InterruptMessage())
@ -603,7 +679,7 @@ class VibeApp(App):
async def _reload_config(self) -> None: async def _reload_config(self) -> None:
try: try:
new_config = VibeConfig.load() new_config = VibeConfig.load(**self._current_agent_mode.config_overrides)
if self.agent: if self.agent:
await self.agent.reload_with_initial_messages(config=new_config) await self.agent.reload_with_initial_messages(config=new_config)
@ -656,6 +732,7 @@ class VibeApp(App):
max_tokens=current_state.max_tokens, max_tokens=current_state.max_tokens,
current_tokens=self.agent.stats.context_tokens, current_tokens=self.agent.stats.context_tokens,
) )
await messages_area.mount(UserMessage("/clear"))
await self._mount_and_scroll( await self._mount_and_scroll(
UserCommandMessage("Conversation history cleared!") UserCommandMessage("Conversation history cleared!")
) )
@ -738,23 +815,68 @@ class VibeApp(App):
self.event_handler.current_compact = compact_msg self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg) await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task(
self._run_compact(compact_msg, old_tokens)
)
async def _run_compact(self, compact_msg: CompactMessage, old_tokens: int) -> None:
self._agent_running = True
try: try:
if not self.agent:
return
await self.agent.compact() await self.agent.compact()
new_tokens = self.agent.stats.context_tokens new_tokens = self.agent.stats.context_tokens
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens) compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
self.event_handler.current_compact = None
if self._context_progress: if self._context_progress:
current_state = self._context_progress.tokens current_state = self._context_progress.tokens
self._context_progress.tokens = TokenState( self._context_progress.tokens = TokenState(
max_tokens=current_state.max_tokens, current_tokens=new_tokens max_tokens=current_state.max_tokens, current_tokens=new_tokens
) )
except asyncio.CancelledError:
compact_msg.set_error("Compaction interrupted")
raise
except Exception as e: except Exception as e:
compact_msg.set_error(str(e)) compact_msg.set_error(str(e))
finally:
self._agent_running = False
self._agent_task = None
if self.event_handler:
self.event_handler.current_compact = None self.event_handler.current_compact = None
def _get_session_resume_info(self) -> str | None:
if not self.agent:
return None
if not self.agent.interaction_logger.enabled:
return None
if not self.agent.interaction_logger.session_id:
return None
return self.agent.interaction_logger.session_id[:8]
async def _exit_app(self) -> None: async def _exit_app(self) -> None:
self.exit() self.exit(result=self._get_session_resume_info())
async def _setup_terminal(self) -> None:
result = setup_terminal()
if result.success:
if result.requires_restart:
await self._mount_and_scroll(
UserCommandMessage(
f"{result.terminal.value}: Set up Shift+Enter keybind (You may need to restart your terminal.)"
)
)
else:
await self._mount_and_scroll(
WarningMessage(
f"{result.terminal.value}: Shift+Enter keybind already set up"
)
)
else:
await self._mount_and_scroll(
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
async def _switch_to_config_app(self) -> None: async def _switch_to_config_app(self) -> None:
if self._current_bottom_app == BottomApp.Config: if self._current_bottom_app == BottomApp.Config:
@ -833,7 +955,7 @@ class VibeApp(App):
history_file=self.history_file, history_file=self.history_file,
command_registry=self.commands, command_registry=self.commands,
id="input-container", id="input-container",
show_warning=self.auto_approve, safety=self._current_agent_mode.safety,
) )
await bottom_container.mount(chat_input_container) await bottom_container.mount(chat_input_container)
self._chat_input_container = chat_input_container self._chat_input_container = chat_input_container
@ -857,12 +979,15 @@ class VibeApp(App):
pass pass
def action_interrupt(self) -> None: def action_interrupt(self) -> None:
current_time = time.monotonic()
if self._current_bottom_app == BottomApp.Config: if self._current_bottom_app == BottomApp.Config:
try: try:
config_app = self.query_one(ConfigApp) config_app = self.query_one(ConfigApp)
config_app.action_close() config_app.action_close()
except Exception: except Exception:
pass pass
self._last_escape_time = None
return return
if self._current_bottom_app == BottomApp.Approval: if self._current_bottom_app == BottomApp.Approval:
@ -871,8 +996,23 @@ class VibeApp(App):
approval_app.action_reject() approval_app.action_reject()
except Exception: except Exception:
pass pass
self._last_escape_time = None
return return
if (
self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
):
try:
input_widget = self.query_one(ChatInputContainer)
if input_widget.value:
input_widget.value = ""
self._last_escape_time = None
return
except Exception:
pass
has_pending_user_message = any( has_pending_user_message = any(
msg.has_class("pending") for msg in self.query(UserMessage) msg.has_class("pending") for msg in self.query(UserMessage)
) )
@ -886,71 +1026,72 @@ class VibeApp(App):
if interrupt_needed: if interrupt_needed:
self.run_worker(self._interrupt_agent(), exclusive=False) self.run_worker(self._interrupt_agent(), exclusive=False)
self._last_escape_time = current_time
self._scroll_to_bottom() self._scroll_to_bottom()
self._focus_current_bottom_app() self._focus_current_bottom_app()
async def action_toggle_tool(self) -> None: async def action_toggle_tool(self) -> None:
if not self.event_handler:
return
self._tools_collapsed = not self._tools_collapsed self._tools_collapsed = not self._tools_collapsed
non_todo_results = [ for result in self.query(ToolResultMessage):
result if result.tool_name != "todo":
for result in self.event_handler.tool_results await result.set_collapsed(self._tools_collapsed)
if result.event.tool_name != "todo"
]
for result in non_todo_results:
result.collapsed = self._tools_collapsed
await result.render_result()
try: try:
error_messages = self.query(ErrorMessage) for error_msg in self.query(ErrorMessage):
for error_msg in error_messages:
error_msg.set_collapsed(self._tools_collapsed) error_msg.set_collapsed(self._tools_collapsed)
except Exception: except Exception:
pass pass
async def action_toggle_todo(self) -> None: async def action_toggle_todo(self) -> None:
if not self.event_handler:
return
self._todos_collapsed = not self._todos_collapsed self._todos_collapsed = not self._todos_collapsed
todo_results = [ for result in self.query(ToolResultMessage):
result if result.tool_name == "todo":
for result in self.event_handler.tool_results await result.set_collapsed(self._todos_collapsed)
if result.event.tool_name == "todo"
]
for result in todo_results:
result.collapsed = self._todos_collapsed
await result.render_result()
def action_cycle_mode(self) -> None: def action_cycle_mode(self) -> None:
if self._current_bottom_app != BottomApp.Input: if self._current_bottom_app != BottomApp.Input:
return return
self.auto_approve = not self.auto_approve new_mode = next_mode(self._current_agent_mode)
self._switch_mode(new_mode)
def _switch_mode(self, mode: AgentMode) -> None:
if mode == self._current_agent_mode:
return
self._current_agent_mode = mode
if self._mode_indicator: if self._mode_indicator:
self._mode_indicator.set_auto_approve(self.auto_approve) self._mode_indicator.set_mode(mode)
if self._chat_input_container: if self._chat_input_container:
self._chat_input_container.set_show_warning(self.auto_approve) self._chat_input_container.set_safety(mode.safety)
if self.agent: if self.agent:
self.agent.auto_approve = self.auto_approve if mode.auto_approve:
if self.auto_approve:
self.agent.approval_callback = None self.agent.approval_callback = None
else: else:
self.agent.approval_callback = self._approval_callback self.agent.approval_callback = self._approval_callback
self.run_worker(
self._do_agent_switch(mode), group="mode_switch", exclusive=True
)
self._focus_current_bottom_app() self._focus_current_bottom_app()
def action_force_quit(self) -> None: async def _do_agent_switch(self, mode: AgentMode) -> None:
if self.agent:
await self.agent.switch_mode(mode)
if self._context_progress:
current_state = self._context_progress.tokens
self._context_progress.tokens = TokenState(
max_tokens=current_state.max_tokens,
current_tokens=self.agent.stats.context_tokens,
)
def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer) input_widgets = self.query(ChatInputContainer)
if input_widgets: if input_widgets:
input_widget = input_widgets.first() input_widget = input_widgets.first()
@ -958,10 +1099,13 @@ class VibeApp(App):
input_widget.value = "" input_widget.value = ""
return return
self.action_force_quit()
def action_force_quit(self) -> None:
if self._agent_task and not self._agent_task.done(): if self._agent_task and not self._agent_task.done():
self._agent_task.cancel() self._agent_task.cancel()
self.exit() self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None: def action_scroll_chat_up(self) -> None:
try: try:
@ -984,7 +1128,7 @@ class VibeApp(App):
is_dangerous, reason = is_dangerous_directory() is_dangerous, reason = is_dangerous_directory()
if is_dangerous: if is_dangerous:
warning = ( warning = (
f" WARNING: {reason}\n\nRunning in this location is not recommended." f" WARNING: {reason}\n\nRunning in this location is not recommended."
) )
await self._mount_and_scroll(UserCommandMessage(warning)) await self._mount_and_scroll(UserCommandMessage(warning))
@ -1110,25 +1254,42 @@ class VibeApp(App):
def on_mouse_up(self, event: MouseUp) -> None: def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self) copy_selection_to_clipboard(self)
def on_app_blur(self, event: AppBlur) -> None:
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(False)
def on_app_focus(self, event: AppFocus) -> None:
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True)
def _print_session_resume_message(session_id: str | None) -> None:
if not session_id:
return
print()
print("To continue this session, run: vibe --continue")
print(f"Or: vibe --resume {session_id}")
def run_textual_ui( def run_textual_ui(
config: VibeConfig, config: VibeConfig,
auto_approve: bool = False, initial_mode: AgentMode = AgentMode.DEFAULT,
enable_streaming: bool = False, enable_streaming: bool = False,
initial_prompt: str | None = None, initial_prompt: str | None = None,
loaded_messages: list[LLMMessage] | None = None, loaded_messages: list[LLMMessage] | None = None,
session_info: ResumeSessionInfo | None = None,
) -> None: ) -> None:
"""Run the Textual UI."""
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe") update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository() update_cache_repository = FileSystemUpdateCacheRepository()
app = VibeApp( app = VibeApp(
config=config, config=config,
auto_approve=auto_approve, initial_mode=initial_mode,
enable_streaming=enable_streaming, enable_streaming=enable_streaming,
initial_prompt=initial_prompt, initial_prompt=initial_prompt,
loaded_messages=loaded_messages, loaded_messages=loaded_messages,
session_info=session_info,
version_update_notifier=update_notifier, version_update_notifier=update_notifier,
update_cache_repository=update_cache_repository, update_cache_repository=update_cache_repository,
) )
app.run() session_id = app.run()
_print_session_resume_message(session_id)

View file

@ -6,14 +6,14 @@ Screen {
height: 1fr; height: 1fr;
width: 100%; width: 100%;
background: $background; background: $background;
padding: 0 2 0 2; padding: 0;
} }
#loading-area { #loading-area {
height: auto; height: auto;
width: 100%; width: 100%;
background: $background; background: $background;
padding: 1 2 0 2; padding: 1 0 0 0;
layout: horizontal; layout: horizontal;
align: left middle; align: left middle;
} }
@ -26,14 +26,28 @@ Screen {
#todo-area { #todo-area {
height: auto; height: auto;
max-height: 8; max-height: 10;
width: 100%; width: 100%;
background: $background; background: $background;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-y: auto; overflow: hidden;
text-align: left; text-align: left;
margin-top: 0; margin-top: 0;
.tool-result-border {
display: none;
}
&.loading-active .tool-result-border {
display: block;
}
.tool-result-content {
max-height: 9;
overflow-y: auto;
scrollbar-visibility: hidden;
}
} }
#bottom-app-container { #bottom-app-container {
@ -46,7 +60,7 @@ Screen {
height: auto; height: auto;
width: 100%; width: 100%;
background: $background; background: $background;
padding: 0 2 1 2; padding: 0 0 1 0;
align: left middle; align: left middle;
layout: horizontal; layout: horizontal;
} }
@ -71,7 +85,7 @@ Screen {
width: 100%; width: 100%;
background: $background; background: $background;
padding: 0; padding: 0;
margin: 0 2 1 2; margin: 0;
} }
#completion-popup { #completion-popup {
@ -84,11 +98,23 @@ Screen {
height: auto; height: auto;
width: 100%; width: 100%;
background: $background; background: $background;
border: round $foreground-muted; border-top: solid $foreground-muted;
border-bottom: solid $foreground-muted;
padding: 0 1; padding: 0 1;
&.border-warning { &.border-warning {
border: round $warning; border-top: solid $warning;
border-bottom: solid $warning;
}
&.border-safe {
border-top: solid $success;
border-bottom: solid $success;
}
&.border-error {
border-top: solid $error;
border-bottom: solid $error;
} }
} }
@ -112,12 +138,13 @@ Screen {
color: $text; color: $text;
border: none; border: none;
padding: 0; padding: 0;
scrollbar-visibility: hidden;
} }
ToastRack { ToastRack {
align: left bottom; align: left bottom;
padding: 0 2; padding: 0;
margin: 0 2 6 2; margin: 0 0 6 0;
} }
Markdown MarkdownFence { Markdown MarkdownFence {
@ -128,8 +155,10 @@ Markdown MarkdownFence {
.user-message { .user-message {
margin-top: 1; margin-top: 1;
padding: 1 0;
width: 100%; width: 100%;
height: auto; height: auto;
background: $surface;
&:first-child { &:first-child {
margin-top: 0; margin-top: 0;
@ -194,25 +223,135 @@ Markdown MarkdownFence {
} }
} }
.assistant-message-content Markdown > *:last-child {
margin-bottom: 0;
}
.interrupt-message { .interrupt-message {
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
margin-left: 2;
height: auto; height: auto;
padding: 1 2; width: 100%;
background: $warning 10%; }
.interrupt-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.interrupt-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.interrupt-content {
width: 1fr;
height: auto;
margin: 0;
color: $text-warning; color: $text-warning;
} }
.error-message { .error-message {
margin-top: 1; margin-top: 0;
margin-bottom: 0;
height: auto; height: auto;
padding: 1 2; width: 100%;
background: $error 10%; }
.error-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.error-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.error-content {
width: 1fr;
height: auto;
margin: 0;
color: $error; color: $error;
text-style: bold; text-style: bold;
} }
.warning-message {
margin-top: 0;
margin-bottom: 0;
height: auto;
width: 100%;
}
.warning-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.warning-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.warning-content {
width: 1fr;
height: auto;
margin: 0;
color: $text-warning;
}
.user-command-message {
margin-top: 0;
margin-bottom: 0;
height: auto;
width: 100%;
}
.user-command-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.user-command-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.user-command-content {
width: 1fr;
height: auto;
margin: 0;
Markdown {
margin: 0;
padding: 0;
}
Markdown > *:first-child {
margin-top: 0;
}
Markdown > *:last-child {
margin-bottom: 0;
}
}
.bash-output-message { .bash-output-message {
margin-top: 1; margin-top: 1;
width: 100%; width: 100%;
@ -280,7 +419,7 @@ Markdown MarkdownFence {
color: $text-muted; color: $text-muted;
} }
BlinkingMessage { StatusMessage {
width: 100%; width: 100%;
height: auto; height: auto;
@ -290,10 +429,11 @@ BlinkingMessage {
} }
} }
.blink-dot { .status-indicator-icon {
width: auto; width: auto;
height: auto; height: auto;
color: $foreground; color: $foreground;
margin-right: 1;
&.success { &.success {
color: $text-success; color: $text-success;
@ -304,7 +444,7 @@ BlinkingMessage {
} }
} }
.blink-text { .status-indicator-text {
width: 1fr; width: 1fr;
height: auto; height: auto;
color: $foreground; color: $foreground;
@ -326,22 +466,40 @@ BlinkingMessage {
width: 100%; width: 100%;
height: auto; height: auto;
margin-top: 0; margin-top: 0;
margin-left: 2; margin-left: 0;
padding: 1 2; padding: 0;
background: $surface; background: transparent;
color: $foreground; color: $foreground;
&.error-text { &.error-text {
background: $error 10%;
color: $text-error; color: $text-error;
} }
&.warning-text { &.warning-text {
background: $warning 10%;
color: $text-warning; color: $text-warning;
} }
} }
.tool-result-container {
width: 100%;
height: auto;
margin-top: 0;
padding: 0;
}
.tool-result-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.tool-result-content {
width: 1fr;
height: auto;
padding: 0;
}
.tool-call-widget { .tool-call-widget {
width: 100%; width: 100%;
height: auto; height: auto;
@ -430,7 +588,6 @@ BlinkingMessage {
#todo-area .tool-result { #todo-area .tool-result {
margin-left: 0; margin-left: 0;
background: $surface;
} }
.loading-widget { .loading-widget {
@ -443,10 +600,11 @@ BlinkingMessage {
height: auto; height: auto;
} }
.loading-star { .loading-indicator {
width: auto; width: auto;
height: auto; height: auto;
color: $warning; color: $warning;
margin-right: 1;
} }
.loading-status { .loading-status {
@ -478,8 +636,8 @@ WelcomeBanner {
border-title-align: center; border-title-align: center;
text-align: center; text-align: center;
content-align: center middle; content-align: center middle;
padding: 2 4; padding: 2 0;
margin: 1 1 0 1; margin: 1 0 0 0;
color: $foreground; color: $foreground;
.muted { .muted {
@ -493,7 +651,7 @@ WelcomeBanner {
background: $background; background: $background;
border: round $foreground-muted; border: round $foreground-muted;
padding: 0 1; padding: 0 1;
margin: 0 2 1 2; margin: 0 0 1 0;
} }
#config-content { #config-content {
@ -556,7 +714,7 @@ WelcomeBanner {
background: $background; background: $background;
border: round $foreground-muted; border: round $foreground-muted;
padding: 0 1; padding: 0 1;
margin: 0 2 1 2; margin: 0 0 1 0;
} }
#approval-content { #approval-content {
@ -645,21 +803,35 @@ Horizontal {
height: auto; height: auto;
} }
ExpandingBorder {
width: auto;
height: 100%;
content-align: left bottom;
}
ModeIndicator { ModeIndicator {
width: auto; width: auto;
height: auto; height: auto;
background: transparent; background: transparent;
padding: 0; padding: 0;
margin: 0 0 0 1; margin: 0 0 0 1;
color: $warning; color: $text-muted;
align: left middle; align: left middle;
&.mode-on { &.mode-safe {
color: $success;
}
&.mode-neutral {
color: $text-muted;
}
&.mode-destructive {
color: $warning; color: $warning;
} }
&.mode-off { &.mode-yolo {
color: $text-muted; color: $error;
} }
} }

View file

@ -146,12 +146,12 @@ class EventHandler:
def stop_current_tool_call(self) -> None: def stop_current_tool_call(self) -> None:
if self.current_tool_call: if self.current_tool_call:
self.current_tool_call.stop_blinking() self.current_tool_call.stop_spinning()
self.current_tool_call = None self.current_tool_call = None
def stop_current_compact(self) -> None: def stop_current_compact(self) -> None:
if self.current_compact: if self.current_compact:
self.current_compact.stop_blinking(success=False) self.current_compact.stop_spinning(success=False)
self.current_compact = None self.current_compact = None
def get_last_tool_result(self) -> ToolResultMessage | None: def get_last_tool_result(self) -> ToolResultMessage | None:

View file

@ -110,7 +110,7 @@ class ApprovalApp(Container):
def _update_options(self) -> None: def _update_options(self) -> None:
options = [ options = [
("Yes", "yes"), ("Yes", "yes"),
(f"Yes and always allow {self.tool_name} this session", "yes"), (f"Yes and always allow {self.tool_name} for this session", "yes"),
("No and tell the agent what to do instead", "no"), ("No and tell the agent what to do instead", "no"),
] ]

View file

@ -1,67 +0,0 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
class BlinkingMessage(Static):
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
self.blink_state = False
self._blink_timer = None
self._is_blinking = True
self.success = True
self._initial_text = initial_text
self._dot_widget: Static | None = None
self._text_widget: Static | None = None
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
with Horizontal():
self._dot_widget = Static("", classes="blink-dot")
yield self._dot_widget
self._text_widget = Static("", markup=False, classes="blink-text")
yield self._text_widget
def on_mount(self) -> None:
self.update_display()
self._blink_timer = self.set_interval(0.5, self.toggle_blink)
def toggle_blink(self) -> None:
if not self._is_blinking:
return
self.blink_state = not self.blink_state
self.update_display()
def update_display(self) -> None:
if not self._dot_widget or not self._text_widget:
return
content = self.get_content()
if self._is_blinking:
dot = "" if self.blink_state else ""
self._dot_widget.update(dot)
self._dot_widget.remove_class("success")
self._dot_widget.remove_class("error")
else:
self._dot_widget.update("")
if self.success:
self._dot_widget.add_class("success")
self._dot_widget.remove_class("error")
else:
self._dot_widget.add_class("error")
self._dot_widget.remove_class("success")
self._text_widget.update(content)
def get_content(self) -> str:
return self._initial_text
def stop_blinking(self, success: bool = True) -> None:
self._is_blinking = False
self.blink_state = True
self.success = success
self.update_display()

View file

@ -11,7 +11,7 @@ from textual.widget import Widget
from textual.widgets import Static from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager from vibe.cli.history_manager import HistoryManager
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
class ChatInputBody(Widget): class ChatInputBody(Widget):
@ -44,26 +44,35 @@ class ChatInputBody(Widget):
if self.input_widget: if self.input_widget:
self.input_widget.focus() self.input_widget.focus()
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
if text.startswith("!"):
return "!", text[1:]
elif text.startswith("/"):
return "/", text[1:]
else:
return ">", text
def _update_prompt(self) -> None: def _update_prompt(self) -> None:
if not self.input_widget or not self.prompt_widget: if not self.input_widget or not self.prompt_widget:
return return
text = self.input_widget.text self.prompt_widget.update(self.input_widget.input_mode)
if text.startswith("!"):
self.prompt_widget.update("!") def on_chat_text_area_mode_changed(self, event: ChatTextArea.ModeChanged) -> None:
elif text.startswith("/"): if self.prompt_widget:
self.prompt_widget.update("/") self.prompt_widget.update(event.mode)
else:
self.prompt_widget.update(">")
def _load_history_entry(self, text: str, cursor_col: int | None = None) -> None: def _load_history_entry(self, text: str, cursor_col: int | None = None) -> None:
if not self.input_widget: if not self.input_widget:
return return
self.input_widget._navigating_history = True mode, display_text = self._parse_mode_and_text(text)
self.input_widget.load_text(text)
first_line = text.split("\n")[0] if text else "" self.input_widget._navigating_history = True
self.input_widget.set_mode(mode)
self.input_widget.load_text(display_text)
first_line = display_text.split("\n")[0]
col = cursor_col if cursor_col is not None else len(first_line) col = cursor_col if cursor_col is not None else len(first_line)
cursor_pos = (0, col) cursor_pos = (0, col)
@ -137,9 +146,6 @@ class ChatInputBody(Widget):
self.input_widget._cursor_pos_after_load = None self.input_widget._cursor_pos_after_load = None
self.input_widget._cursor_moved_since_load = False self.input_widget._cursor_moved_since_load = False
def on_text_area_changed(self, event: ChatTextArea.Changed) -> None:
self._update_prompt()
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None: def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
event.stop() event.stop()
@ -161,12 +167,16 @@ class ChatInputBody(Widget):
@property @property
def value(self) -> str: def value(self) -> str:
return self.input_widget.text if self.input_widget else "" if not self.input_widget:
return ""
return self.input_widget.get_full_text()
@value.setter @value.setter
def value(self, text: str) -> None: def value(self, text: str) -> None:
if self.input_widget: if self.input_widget:
self.input_widget.load_text(text) mode, display_text = self._parse_mode_and_text(text)
self.input_widget.set_mode(mode)
self.input_widget.load_text(display_text)
self._update_prompt() self._update_prompt()
def focus_input(self) -> None: def focus_input(self) -> None:

View file

@ -17,11 +17,17 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
from vibe.core.modes import ModeSafety
SAFETY_BORDER_CLASSES: dict[ModeSafety, str] = {
ModeSafety.SAFE: "border-safe",
ModeSafety.DESTRUCTIVE: "border-warning",
ModeSafety.YOLO: "border-error",
}
class ChatInputContainer(Vertical): class ChatInputContainer(Vertical):
ID_INPUT_BOX = "input-box" ID_INPUT_BOX = "input-box"
BORDER_WARNING_CLASS = "border-warning"
class Submitted(Message): class Submitted(Message):
def __init__(self, value: str) -> None: def __init__(self, value: str) -> None:
@ -32,13 +38,13 @@ class ChatInputContainer(Vertical):
self, self,
history_file: Path | None = None, history_file: Path | None = None,
command_registry: CommandRegistry | None = None, command_registry: CommandRegistry | None = None,
show_warning: bool = False, safety: ModeSafety = ModeSafety.NEUTRAL,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._history_file = history_file self._history_file = history_file
self._command_registry = command_registry or CommandRegistry() self._command_registry = command_registry or CommandRegistry()
self._show_warning = show_warning self._safety = safety
command_entries = [ command_entries = [
(alias, command.description) (alias, command.description)
@ -57,9 +63,8 @@ class ChatInputContainer(Vertical):
self._completion_popup = CompletionPopup() self._completion_popup = CompletionPopup()
yield self._completion_popup yield self._completion_popup
with Vertical( border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
id=self.ID_INPUT_BOX, classes="border-warning" if self._show_warning else "" with Vertical(id=self.ID_INPUT_BOX, classes=border_class):
):
self._body = ChatInputBody(history_file=self._history_file, id="input-body") self._body = ChatInputBody(history_file=self._history_file, id="input-body")
yield self._body yield self._body
@ -91,7 +96,7 @@ class ChatInputContainer(Vertical):
widget = self._body.input_widget widget = self._body.input_widget
if widget: if widget:
self._completion_manager.on_text_changed( self._completion_manager.on_text_changed(
widget.text, widget.get_cursor_offset() widget.get_full_text(), widget._get_full_cursor_offset()
) )
def focus_input(self) -> None: def focus_input(self) -> None:
@ -131,6 +136,9 @@ class ChatInputContainer(Vertical):
widget = self.input_widget widget = self.input_widget
if not widget or not self._body: if not widget or not self._body:
return return
start, end, replacement = widget.adjust_from_full_text_coords(
start, end, replacement
)
text = widget.text text = widget.text
start = max(0, min(start, len(text))) start = max(0, min(start, len(text)))
@ -147,11 +155,12 @@ class ChatInputContainer(Vertical):
event.stop() event.stop()
self.post_message(self.Submitted(event.value)) self.post_message(self.Submitted(event.value))
def set_show_warning(self, show_warning: bool) -> None: def set_safety(self, safety: ModeSafety) -> None:
self._show_warning = show_warning self._safety = safety
input_box = self.get_widget_by_id(self.ID_INPUT_BOX) input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
if show_warning: for border_class in SAFETY_BORDER_CLASSES.values():
input_box.add_class(self.BORDER_WARNING_CLASS) input_box.remove_class(border_class)
else:
input_box.remove_class(self.BORDER_WARNING_CLASS) if safety in SAFETY_BORDER_CLASSES:
input_box.add_class(SAFETY_BORDER_CLASSES[safety])

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, ClassVar from typing import Any, ClassVar, Literal
from textual import events from textual import events
from textual.binding import Binding from textual.binding import Binding
@ -12,6 +12,8 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager, MultiCompletionManager,
) )
InputMode = Literal["!", "/", ">"]
class ChatTextArea(TextArea): class ChatTextArea(TextArea):
BINDINGS: ClassVar[list[Binding]] = [ BINDINGS: ClassVar[list[Binding]] = [
@ -24,6 +26,9 @@ class ChatTextArea(TextArea):
) )
] ]
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
class Submitted(Message): class Submitted(Message):
def __init__(self, value: str) -> None: def __init__(self, value: str) -> None:
self.value = value self.value = value
@ -42,8 +47,16 @@ class ChatTextArea(TextArea):
class HistoryReset(Message): class HistoryReset(Message):
"""Message sent when history navigation should be reset.""" """Message sent when history navigation should be reset."""
class ModeChanged(Message):
"""Message sent when the input mode changes (>, !, /)."""
def __init__(self, mode: InputMode) -> None:
self.mode = mode
super().__init__()
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._input_mode: InputMode = self.DEFAULT_MODE
self._history_prefix: str | None = None self._history_prefix: str | None = None
self._last_text = "" self._last_text = ""
self._navigating_history = False self._navigating_history = False
@ -53,8 +66,16 @@ class ChatTextArea(TextArea):
self._cursor_pos_after_load: tuple[int, int] | None = None self._cursor_pos_after_load: tuple[int, int] | None = None
self._cursor_moved_since_load: bool = False self._cursor_moved_since_load: bool = False
self._completion_manager: MultiCompletionManager | None = None self._completion_manager: MultiCompletionManager | None = None
self._app_has_focus: bool = True
def on_blur(self, event: events.Blur) -> None: def on_blur(self, event: events.Blur) -> None:
if self._app_has_focus:
self.call_after_refresh(self.focus)
def set_app_focus(self, has_focus: bool) -> None:
self._app_has_focus = has_focus
self.cursor_blink = has_focus
if has_focus and not self.has_focus:
self.call_after_refresh(self.focus) self.call_after_refresh(self.focus)
def on_click(self, event: events.Click) -> None: def on_click(self, event: events.Click) -> None:
@ -76,7 +97,7 @@ class ChatTextArea(TextArea):
if self._completion_manager and not was_navigating_history: if self._completion_manager and not was_navigating_history:
self._completion_manager.on_text_changed( self._completion_manager.on_text_changed(
self.text, self.get_cursor_offset() self.get_full_text(), self._get_full_cursor_offset()
) )
def _reset_prefix(self) -> None: def _reset_prefix(self) -> None:
@ -96,7 +117,10 @@ class ChatTextArea(TextArea):
cursor_row, cursor_col = self.cursor_location cursor_row, cursor_col = self.cursor_location
lines = self.text.split("\n") lines = self.text.split("\n")
if cursor_row < len(lines): if cursor_row < len(lines):
return lines[cursor_row][:cursor_col] visible_prefix = lines[cursor_row][:cursor_col]
if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
return self._input_mode + visible_prefix
return visible_prefix
return "" return ""
def _handle_history_up(self) -> bool: def _handle_history_up(self) -> bool:
@ -139,12 +163,14 @@ class ChatTextArea(TextArea):
self.post_message(self.HistoryNext(self._history_prefix)) self.post_message(self.HistoryNext(self._history_prefix))
return True return True
async def _on_key(self, event: events.Key) -> None: async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
self._mark_cursor_moved_if_needed() self._mark_cursor_moved_if_needed()
manager = self._completion_manager manager = self._completion_manager
if manager: if manager:
match manager.on_key(event, self.text, self.get_cursor_offset()): match manager.on_key(
event, self.get_full_text(), self._get_full_cursor_offset()
):
case CompletionResult.HANDLED: case CompletionResult.HANDLED:
event.prevent_default() event.prevent_default()
event.stop() event.stop()
@ -152,7 +178,7 @@ class ChatTextArea(TextArea):
case CompletionResult.SUBMIT: case CompletionResult.SUBMIT:
event.prevent_default() event.prevent_default()
event.stop() event.stop()
value = self.text.strip() value = self.get_full_text().strip()
if value: if value:
self._reset_prefix() self._reset_prefix()
self.post_message(self.Submitted(value)) self.post_message(self.Submitted(value))
@ -161,7 +187,7 @@ class ChatTextArea(TextArea):
if event.key == "enter": if event.key == "enter":
event.prevent_default() event.prevent_default()
event.stop() event.stop()
value = self.text.strip() value = self.get_full_text().strip()
if value: if value:
self._reset_prefix() self._reset_prefix()
self.post_message(self.Submitted(value)) self.post_message(self.Submitted(value))
@ -172,6 +198,23 @@ class ChatTextArea(TextArea):
event.stop() event.stop()
return return
if (
event.character
and event.character in self.MODE_CHARACTERS
and not self.text
and self._input_mode == self.DEFAULT_MODE
):
self._set_mode(event.character)
event.prevent_default()
event.stop()
return
if event.key == "backspace" and self._should_reset_mode_on_backspace():
self._set_mode(self.DEFAULT_MODE)
event.prevent_default()
event.stop()
return
if event.key == "up" and self._handle_history_up(): if event.key == "up" and self._handle_history_up():
event.prevent_default() event.prevent_default()
event.stop() event.stop()
@ -189,7 +232,7 @@ class ChatTextArea(TextArea):
self._completion_manager = manager self._completion_manager = manager
if self._completion_manager: if self._completion_manager:
self._completion_manager.on_text_changed( self._completion_manager.on_text_changed(
self.text, self.get_cursor_offset() self.get_full_text(), self._get_full_cursor_offset()
) )
def get_cursor_offset(self) -> int: def get_cursor_offset(self) -> int:
@ -244,3 +287,59 @@ class ChatTextArea(TextArea):
def clear_text(self) -> None: def clear_text(self) -> None:
self.clear() self.clear()
self.reset_history_state() self.reset_history_state()
self._set_mode(self.DEFAULT_MODE)
def _set_mode(self, mode: InputMode) -> None:
if self._input_mode == mode:
return
self._input_mode = mode
self.post_message(self.ModeChanged(mode))
if self._completion_manager:
self._completion_manager.on_text_changed(
self.get_full_text(), self._get_full_cursor_offset()
)
def _should_reset_mode_on_backspace(self) -> bool:
return (
self._input_mode != self.DEFAULT_MODE
and not self.text
and self.get_cursor_offset() == 0
)
def get_full_text(self) -> str:
if self._input_mode != self.DEFAULT_MODE:
return self._input_mode + self.text
return self.text
def _get_full_cursor_offset(self) -> int:
return self.get_cursor_offset() + self._get_mode_prefix_length()
def _get_mode_prefix_length(self) -> int:
return {">": 0, "/": 1, "!": 1}[self._input_mode]
@property
def input_mode(self) -> InputMode:
return self._input_mode
def set_mode(self, mode: InputMode) -> None:
if self._input_mode != mode:
self._input_mode = mode
self.post_message(self.ModeChanged(mode))
def adjust_from_full_text_coords(
self, start: int, end: int, replacement: str
) -> tuple[int, int, str]:
"""Translate from full-text coordinates to widget coordinates.
The completion manager works with 'full text' that includes the mode prefix.
This adjusts coordinates and replacement text for the actual widget text.
"""
mode_len = self._get_mode_prefix_length()
adj_start = max(0, start - mode_len)
adj_end = max(adj_start, end - mode_len)
if mode_len > 0 and replacement.startswith(self._input_mode):
replacement = replacement[mode_len:]
return adj_start, adj_end, replacement

View file

@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from vibe.cli.textual_ui.widgets.blinking_message import BlinkingMessage from vibe.cli.textual_ui.widgets.status_message import StatusMessage
class CompactMessage(BlinkingMessage): class CompactMessage(StatusMessage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.add_class("compact-message") self.add_class("compact-message")
@ -12,7 +12,7 @@ class CompactMessage(BlinkingMessage):
self.error_message: str | None = None self.error_message: str | None = None
def get_content(self) -> str: def get_content(self) -> str:
if self._is_blinking: if self._is_spinning:
return "Compacting conversation history..." return "Compacting conversation history..."
if self.error_message: if self.error_message:
@ -35,8 +35,8 @@ class CompactMessage(BlinkingMessage):
) -> None: ) -> None:
self.old_tokens = old_tokens self.old_tokens = old_tokens
self.new_tokens = new_tokens self.new_tokens = new_tokens
self.stop_blinking(success=True) self.stop_spinning(success=True)
def set_error(self, error_message: str) -> None: def set_error(self, error_message: str) -> None:
self.error_message = error_message self.error_message = error_message
self.stop_blinking(success=False) self.stop_spinning(success=False)

View file

@ -9,10 +9,10 @@ from textual.app import ComposeResult
from textual.containers import Horizontal from textual.containers import Horizontal
from textual.widgets import Static from textual.widgets import Static
from vibe.cli.textual_ui.widgets.spinner import BrailleSpinner
class LoadingWidget(Static): class LoadingWidget(Static):
BRAILLE_SPINNER = ("", "", "", "", "", "", "", "", "", "")
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500") TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
EASTER_EGGS: ClassVar[list[str]] = [ EASTER_EGGS: ClassVar[list[str]] = [
@ -51,8 +51,9 @@ class LoadingWidget(Static):
def __init__(self, status: str | None = None) -> None: def __init__(self, status: str | None = None) -> None:
super().__init__(classes="loading-widget") super().__init__(classes="loading-widget")
self.status = status or self._get_default_status() self.status = status or self._get_default_status()
self.gradient_offset = 0 self.current_color_index = 0
self.spinner_pos = 0 self.transition_progress = 0
self._spinner = BrailleSpinner()
self.char_widgets: list[Static] = [] self.char_widgets: list[Static] = []
self.spinner_widget: Static | None = None self.spinner_widget: Static | None = None
self.ellipsis_widget: Static | None = None self.ellipsis_widget: Static | None = None
@ -84,13 +85,12 @@ class LoadingWidget(Static):
def set_status(self, status: str) -> None: def set_status(self, status: str) -> None:
self.status = self._apply_easter_egg(status) self.status = self._apply_easter_egg(status)
self.gradient_offset = 0
self._rebuild_chars() self._rebuild_chars()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(classes="loading-container"): with Horizontal(classes="loading-container"):
self.spinner_widget = Static( self.spinner_widget = Static(
self.BRAILLE_SPINNER[0] + " ", classes="loading-star" self._spinner.current_frame(), classes="loading-indicator"
) )
yield self.spinner_widget yield self.spinner_widget
@ -127,30 +127,40 @@ class LoadingWidget(Static):
self.update_animation() self.update_animation()
self.set_interval(0.1, self.update_animation) self.set_interval(0.1, self.update_animation)
def _get_gradient_color(self, position: int) -> str: def _get_color_for_position(self, position: int) -> str:
color_index = (position - self.gradient_offset) % len(self.TARGET_COLORS) current_color = self.TARGET_COLORS[self.current_color_index]
return self.TARGET_COLORS[color_index] next_color = self.TARGET_COLORS[
(self.current_color_index + 1) % len(self.TARGET_COLORS)
]
if position < self.transition_progress:
return next_color
return current_color
def update_animation(self) -> None: def update_animation(self) -> None:
total_elements = 1 + len(self.char_widgets) + 2
if self.spinner_widget: if self.spinner_widget:
spinner_char = self.BRAILLE_SPINNER[self.spinner_pos] spinner_char = self._spinner.next_frame()
color_0 = self._get_gradient_color(0) color = self._get_color_for_position(0)
color_1 = self._get_gradient_color(1) self.spinner_widget.update(f"[{color}]{spinner_char}[/]")
self.spinner_widget.update(f"[{color_0}]{spinner_char}[/][{color_1}] [/]")
self.spinner_pos = (self.spinner_pos + 1) % len(self.BRAILLE_SPINNER)
for i, widget in enumerate(self.char_widgets): for i, widget in enumerate(self.char_widgets):
position = 2 + i position = 1 + i
color = self._get_gradient_color(position) color = self._get_color_for_position(position)
widget.update(f"[{color}]{self.status[i]}[/]") widget.update(f"[{color}]{self.status[i]}[/]")
if self.ellipsis_widget: if self.ellipsis_widget:
ellipsis_start = 2 + len(self.status) ellipsis_start = 1 + len(self.status)
color_ellipsis = self._get_gradient_color(ellipsis_start) color_ellipsis = self._get_color_for_position(ellipsis_start)
color_space = self._get_gradient_color(ellipsis_start + 1) color_space = self._get_color_for_position(ellipsis_start + 1)
self.ellipsis_widget.update(f"[{color_ellipsis}]…[/][{color_space}] [/]") self.ellipsis_widget.update(f"[{color_ellipsis}]…[/][{color_space}] [/]")
self.gradient_offset = (self.gradient_offset + 1) % len(self.TARGET_COLORS) self.transition_progress += 1
if self.transition_progress > total_elements:
self.current_color_index = (self.current_color_index + 1) % len(
self.TARGET_COLORS
)
self.transition_progress = 0
if self.hint_widget and self.start_time is not None: if self.hint_widget and self.start_time is not None:
elapsed = int(time() - self.start_time) elapsed = int(time() - self.start_time)

View file

@ -1,11 +1,35 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.widgets import Markdown, Static from textual.widgets import Markdown, Static
from textual.widgets._markdown import MarkdownStream from textual.widgets._markdown import MarkdownStream
class NonSelectableStatic(Static):
@property
def text_selection(self) -> None:
return None
@text_selection.setter
def text_selection(self, value: Any) -> None:
pass
def get_selection(self, selection: Any) -> None:
return None
class ExpandingBorder(NonSelectableStatic):
def render(self) -> str:
height = self.size.height
return "\n".join([""] * (height - 1) + [""])
def on_resize(self) -> None:
self.refresh()
class UserMessage(Static): class UserMessage(Static):
def __init__(self, content: str, pending: bool = False) -> None: def __init__(self, content: str, pending: bool = False) -> None:
super().__init__() super().__init__()
@ -15,7 +39,7 @@ class UserMessage(Static):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(classes="user-message-container"): with Horizontal(classes="user-message-container"):
yield Static("> ", classes="user-message-prompt") yield NonSelectableStatic("> ", classes="user-message-prompt")
yield Static(self._content, markup=False, classes="user-message-content") yield Static(self._content, markup=False, classes="user-message-content")
if self._pending: if self._pending:
self.add_class("pending") self.add_class("pending")
@ -43,7 +67,7 @@ class AssistantMessage(Static):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(classes="assistant-message-container"): with Horizontal(classes="assistant-message-container"):
yield Static("", classes="assistant-message-dot") yield NonSelectableStatic("", classes="assistant-message-dot")
with Vertical(classes="assistant-message-content"): with Vertical(classes="assistant-message-content"):
markdown = Markdown("") markdown = Markdown("")
self._markdown = markdown self._markdown = markdown
@ -87,13 +111,24 @@ class UserCommandMessage(Static):
self._content = content self._content = content
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(classes="user-command-container"):
yield ExpandingBorder(classes="user-command-border")
with Vertical(classes="user-command-content"):
yield Markdown(self._content) yield Markdown(self._content)
class InterruptMessage(Static): class InterruptMessage(Static):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__()
"Interrupted · What should Vibe do instead?", classes="interrupt-message" self.add_class("interrupt-message")
def compose(self) -> ComposeResult:
with Horizontal(classes="interrupt-container"):
yield ExpandingBorder(classes="interrupt-border")
yield Static(
"Interrupted · What should Vibe do instead?",
markup=False,
classes="interrupt-content",
) )
@ -125,24 +160,41 @@ class BashOutputMessage(Static):
class ErrorMessage(Static): class ErrorMessage(Static):
def __init__(self, error: str, collapsed: bool = True) -> None: def __init__(self, error: str, collapsed: bool = True) -> None:
super().__init__(classes="error-message") super().__init__()
self.add_class("error-message")
self._error = error self._error = error
self.collapsed = collapsed self.collapsed = collapsed
self._content_widget: Static | None = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(classes="error-container"):
yield ExpandingBorder(classes="error-border")
self._content_widget = Static(
self._get_text(), markup=False, classes="error-content"
)
yield self._content_widget
def _get_text(self) -> str:
if self.collapsed: if self.collapsed:
yield Static("Error. (ctrl+o to expand)", markup=False) return "Error. (ctrl+o to expand)"
else: return f"Error: {self._error}"
yield Static(f"Error: {self._error}", markup=False)
def set_collapsed(self, collapsed: bool) -> None: def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed == collapsed: if self.collapsed == collapsed:
return return
self.collapsed = collapsed self.collapsed = collapsed
self.remove_children() if self._content_widget:
self._content_widget.update(self._get_text())
if self.collapsed:
self.mount(Static("Error. (ctrl+o to expand)", markup=False)) class WarningMessage(Static):
else: def __init__(self, message: str) -> None:
self.mount(Static(f"Error: {self._error}", markup=False)) super().__init__()
self.add_class("warning-message")
self._message = message
def compose(self) -> ComposeResult:
with Horizontal(classes="warning-container"):
yield ExpandingBorder(classes="warning-border")
yield Static(self._message, markup=False, classes="warning-content")

View file

@ -2,24 +2,46 @@ from __future__ import annotations
from textual.widgets import Static from textual.widgets import Static
from vibe.core.modes import AgentMode, ModeSafety
MODE_ICONS: dict[AgentMode, str] = {
AgentMode.DEFAULT: "",
AgentMode.PLAN: "⏸︎",
AgentMode.ACCEPT_EDITS: "⏵⏵",
AgentMode.AUTO_APPROVE: "⏵⏵⏵",
}
SAFETY_CLASSES: dict[ModeSafety, str] = {
ModeSafety.SAFE: "mode-safe",
ModeSafety.NEUTRAL: "mode-neutral",
ModeSafety.DESTRUCTIVE: "mode-destructive",
ModeSafety.YOLO: "mode-yolo",
}
class ModeIndicator(Static): class ModeIndicator(Static):
def __init__(self, auto_approve: bool = False) -> None: """Displays the current agent mode with safety-colored indicator."""
def __init__(self, mode: AgentMode = AgentMode.DEFAULT) -> None:
super().__init__() super().__init__()
self.can_focus = False self.can_focus = False
self._auto_approve = auto_approve self._mode = mode
self._update_display() self._update_display()
def _update_display(self) -> None: def _update_display(self) -> None:
if self._auto_approve: icon = MODE_ICONS.get(self._mode, "??")
self.update("⏵⏵ auto-approve on (shift+tab to toggle)") name = self._mode.display_name.lower()
self.add_class("mode-on") self.update(f"{icon} {name} mode (shift+tab to cycle)")
self.remove_class("mode-off")
else:
self.update("⏵ auto-approve off (shift+tab to toggle)")
self.add_class("mode-off")
self.remove_class("mode-on")
def set_auto_approve(self, enabled: bool) -> None: for safety_class in SAFETY_CLASSES.values():
self._auto_approve = enabled self.remove_class(safety_class)
self.add_class(SAFETY_CLASSES[self._mode.safety])
@property
def mode(self) -> AgentMode:
return self._mode
def set_mode(self, mode: AgentMode) -> None:
self._mode = mode
self._update_display() self._update_display()

View file

@ -0,0 +1,87 @@
from __future__ import annotations
from abc import ABC
from enum import Enum
from typing import ClassVar
class Spinner(ABC):
FRAMES: ClassVar[tuple[str, ...]]
def __init__(self) -> None:
self._position = 0
def next_frame(self) -> str:
frame = self.FRAMES[self._position]
self._position = (self._position + 1) % len(self.FRAMES)
return frame
def current_frame(self) -> str:
return self.FRAMES[self._position]
def reset(self) -> None:
self._position = 0
class BrailleSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = (
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
)
class LineSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("|", "/", "-", "\\")
class CircleSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("", "", "", "")
class BowtieSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = (
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
)
class DotWaveSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("", "", "", "", "", "", "", "")
class SpinnerType(Enum):
BRAILLE = "braille"
LINE = "line"
CIRCLE = "circle"
BOWTIE = "bowtie"
DOT_WAVE = "dot_wave"
_SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
SpinnerType.BRAILLE: BrailleSpinner,
SpinnerType.LINE: LineSpinner,
SpinnerType.CIRCLE: CircleSpinner,
SpinnerType.BOWTIE: BowtieSpinner,
SpinnerType.DOT_WAVE: DotWaveSpinner,
}
def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
return spinner_class()

View file

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Any, ClassVar
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
from vibe.cli.textual_ui.widgets.spinner import Spinner, SpinnerType, create_spinner
class StatusMessage(Static):
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
self._spinner: Spinner = create_spinner(self.SPINNER_TYPE)
self._spinner_timer = None
self._is_spinning = True
self.success = True
self._initial_text = initial_text
self._indicator_widget: Static | None = None
self._text_widget: Static | None = None
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
with Horizontal():
self._indicator_widget = NonSelectableStatic(
self._spinner.current_frame(),
markup=False,
classes="status-indicator-icon",
)
yield self._indicator_widget
self._text_widget = Static(
"", markup=False, classes="status-indicator-text"
)
yield self._text_widget
def on_mount(self) -> None:
self.update_display()
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
def _update_spinner(self) -> None:
if not self._is_spinning:
return
self.update_display()
def update_display(self) -> None:
if not self._indicator_widget or not self._text_widget:
return
content = self.get_content()
if self._is_spinning:
self._indicator_widget.update(self._spinner.next_frame())
self._indicator_widget.remove_class("success")
self._indicator_widget.remove_class("error")
elif self.success:
self._indicator_widget.update("")
self._indicator_widget.add_class("success")
self._indicator_widget.remove_class("error")
else:
self._indicator_widget.update("")
self._indicator_widget.add_class("error")
self._indicator_widget.remove_class("success")
self._text_widget.update(content)
def get_content(self) -> str:
return self._initial_text
def stop_spinning(self, success: bool = True) -> None:
self._is_spinning = False
self.success = success
self.update_display()

View file

@ -4,6 +4,8 @@ from textual.app import ComposeResult
from textual.containers import Vertical from textual.containers import Vertical
from textual.widgets import Markdown, Static from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
class ToolApprovalWidget(Vertical): class ToolApprovalWidget(Vertical):
def __init__(self, data: dict) -> None: def __init__(self, data: dict) -> None:
@ -27,17 +29,23 @@ class ToolApprovalWidget(Vertical):
class ToolResultWidget(Static): class ToolResultWidget(Static):
SHORTCUT = DEFAULT_TOOL_SHORTCUT
def __init__(self, data: dict, collapsed: bool = True) -> None: def __init__(self, data: dict, collapsed: bool = True) -> None:
super().__init__() super().__init__()
self.data = data self.data = data
self.collapsed = collapsed self.collapsed = collapsed
self.add_class("tool-result-widget") self.add_class("tool-result-widget")
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self.SHORTCUT} to {action})"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
message = self.data.get("message", "") message = self.data.get("message", "")
if self.collapsed: if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False) yield Static(f"{message} {self._hint()}", markup=False)
else: else:
yield Static(message, markup=False) yield Static(message, markup=False)
@ -66,7 +74,7 @@ class BashResultWidget(ToolResultWidget):
message = self.data.get("message", "") message = self.data.get("message", "")
if self.collapsed: if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False) yield Static(f"{message} {self._hint()}", markup=False)
else: else:
yield Static(message, markup=False) yield Static(message, markup=False)
@ -96,7 +104,7 @@ class WriteFileResultWidget(ToolResultWidget):
message = self.data.get("message", "") message = self.data.get("message", "")
if self.collapsed: if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False) yield Static(f"{message} {self._hint()}", markup=False)
else: else:
yield Static(message, markup=False) yield Static(message, markup=False)
@ -158,7 +166,7 @@ class SearchReplaceResultWidget(ToolResultWidget):
message = self.data.get("message", "") message = self.data.get("message", "")
if self.collapsed: if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False) yield Static(f"{message} {self._hint()}", markup=False)
else: else:
yield Static(message, markup=False) yield Static(message, markup=False)
@ -185,13 +193,15 @@ class TodoApprovalWidget(ToolApprovalWidget):
class TodoResultWidget(ToolResultWidget): class TodoResultWidget(ToolResultWidget):
SHORTCUT = TOOL_SHORTCUTS["todo"]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
message = self.data.get("message", "") message = self.data.get("message", "")
if self.collapsed: if self.collapsed:
yield Static(message, markup=False) yield Static(f"{message} {self._hint()}", markup=False)
else: else:
yield Static(message, markup=False) yield Static(f"{message} {self._hint()}", markup=False)
yield Static("") yield Static("")
by_status = self.data.get("todos_by_status", {}) by_status = self.data.get("todos_by_status", {})
@ -228,7 +238,7 @@ class ReadFileResultWidget(ToolResultWidget):
message = self.data.get("message", "") message = self.data.get("message", "")
if self.collapsed: if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False) yield Static(f"{message} {self._hint()}", markup=False)
else: else:
yield Static(message, markup=False) yield Static(message, markup=False)
@ -277,7 +287,7 @@ class GrepResultWidget(ToolResultWidget):
message = self.data.get("message", "") message = self.data.get("message", "")
if self.collapsed: if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False) yield Static(f"{message} {self._hint()}", markup=False)
else: else:
yield Static(message, markup=False) yield Static(message, markup=False)

View file

@ -1,86 +1,163 @@
from __future__ import annotations from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static from textual.widgets import Static
from vibe.cli.textual_ui.renderers import get_renderer from vibe.cli.textual_ui.renderers import get_renderer
from vibe.cli.textual_ui.widgets.blinking_message import BlinkingMessage from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.ui import ToolUIDataAdapter from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
class ToolCallMessage(BlinkingMessage): class ToolCallMessage(StatusMessage):
def __init__(self, event: ToolCallEvent) -> None: def __init__(
self.event = event self, event: ToolCallEvent | None = None, *, tool_name: str | None = None
) -> None:
if event is None and tool_name is None:
raise ValueError("Either event or tool_name must be provided")
self._event = event
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._is_history = event is None
super().__init__() super().__init__()
self.add_class("tool-call") self.add_class("tool-call")
if self._is_history:
self._is_spinning = False
def get_content(self) -> str: def get_content(self) -> str:
if not self.event.tool_class: if self._event and self._event.tool_class:
return f"{self.event.tool_name}" adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_call_display(self._event)
adapter = ToolUIDataAdapter(self.event.tool_class) return display.summary
display = adapter.get_call_display(self.event) return self._tool_name
return f"{display.summary}"
class ToolResultMessage(Static): class ToolResultMessage(Static):
def __init__( def __init__(
self, self,
event: ToolResultEvent, event: ToolResultEvent | None = None,
call_widget: ToolCallMessage | None = None, call_widget: ToolCallMessage | None = None,
collapsed: bool = True, collapsed: bool = True,
*,
tool_name: str | None = None,
content: str | None = None,
) -> None: ) -> None:
self.event = event if event is None and tool_name is None:
self.call_widget = call_widget raise ValueError("Either event or tool_name must be provided")
self._event = event
self._call_widget = call_widget
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._content = content
self.collapsed = collapsed self.collapsed = collapsed
self._content_container: Vertical | None = None
super().__init__() super().__init__()
self.add_class("tool-result") self.add_class("tool-result")
@property
def tool_name(self) -> str:
return self._tool_name
def _shortcut(self) -> str:
return TOOL_SHORTCUTS.get(self._tool_name, DEFAULT_TOOL_SHORTCUT)
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self._shortcut()} to {action})"
def compose(self) -> ComposeResult:
with Horizontal(classes="tool-result-container"):
yield ExpandingBorder(classes="tool-result-border")
self._content_container = Vertical(classes="tool-result-content")
yield self._content_container
async def on_mount(self) -> None: async def on_mount(self) -> None:
if self.call_widget: if self._call_widget:
success = not self.event.error and not self.event.skipped success = self._event is None or (
self.call_widget.stop_blinking(success=success) not self._event.error and not self._event.skipped
await self.render_result() )
self._call_widget.stop_spinning(success=success)
await self._render_result()
async def render_result(self) -> None: async def _render_result(self) -> None:
await self.remove_children() if self._content_container is None:
if self.event.error:
self.add_class("error-text")
if self.collapsed:
self.update("Error. (ctrl+o to expand)")
else:
await self.mount(Static(f"Error: {self.event.error}", markup=False))
return return
if self.event.skipped: await self._content_container.remove_children()
self.add_class("warning-text")
reason = self.event.skip_reason or "User skipped" if self._event is None:
await self._render_simple()
return
if self._event.error:
self.add_class("error-text")
if self.collapsed: if self.collapsed:
self.update("Skipped. (ctrl+o to expand)") await self._content_container.mount(
Static(f"Error. {self._hint()}", markup=False)
)
else: else:
await self.mount(Static(f"Skipped: {reason}", markup=False)) await self._content_container.mount(
Static(f"Error: {self._event.error}", markup=False)
)
return
if self._event.skipped:
self.add_class("warning-text")
reason = self._event.skip_reason or "User skipped"
if self.collapsed:
await self._content_container.mount(
Static(f"Skipped. {self._hint()}", markup=False)
)
else:
await self._content_container.mount(
Static(f"Skipped: {reason}", markup=False)
)
return return
self.remove_class("error-text") self.remove_class("error-text")
self.remove_class("warning-text") self.remove_class("warning-text")
adapter = ToolUIDataAdapter(self.event.tool_class) if self._event.tool_class is None:
display = adapter.get_result_display(self.event) await self._render_simple()
return
renderer = get_renderer(self.event.tool_name) adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_result_display(self._event)
renderer = get_renderer(self._event.tool_name)
widget_class, data = renderer.get_result_widget(display, self.collapsed) widget_class, data = renderer.get_result_widget(display, self.collapsed)
await self._content_container.mount(
widget_class(data, collapsed=self.collapsed)
)
result_widget = widget_class(data, collapsed=self.collapsed) async def _render_simple(self) -> None:
await self.mount(result_widget) if self._content_container is None:
return
if self.collapsed:
await self._content_container.mount(
Static(f"{self._tool_name} completed {self._hint()}", markup=False)
)
return
if self._content:
await self._content_container.mount(Static(self._content, markup=False))
else:
await self._content_container.mount(
Static(f"{self._tool_name} completed.", markup=False)
)
async def set_collapsed(self, collapsed: bool) -> None: async def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed != collapsed: if self.collapsed == collapsed:
return
self.collapsed = collapsed self.collapsed = collapsed
await self.render_result() await self._render_result()
async def toggle_collapsed(self) -> None: async def toggle_collapsed(self) -> None:
self.collapsed = not self.collapsed self.collapsed = not self.collapsed
await self.render_result() await self._render_result()

View file

@ -0,0 +1,4 @@
from __future__ import annotations
TOOL_SHORTCUTS = {"todo": "ctrl+t"}
DEFAULT_TOOL_SHORTCUT = "ctrl+o"

View file

@ -9,7 +9,7 @@ from rich.text import Text
from textual.color import Color from textual.color import Color
from textual.widgets import Static from textual.widgets import Static
from vibe.core import __version__ from vibe import __version__
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
@ -104,8 +104,7 @@ class WelcomeBanner(Static):
self._static_line5_suffix = ( self._static_line5_suffix = (
f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]" f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]"
) )
block = (self.SPACE * 4) + self.LOGO_TEXT_GAP self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"
self._static_line7 = f"{block}[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information[/]"
@property @property
def skeleton_color(self) -> str: def skeleton_color(self) -> str:

View file

@ -8,7 +8,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache, UpdateCache,
UpdateCacheRepository, UpdateCacheRepository,
) )
from vibe.core.config_path import VIBE_HOME from vibe.core.paths.global_paths import VIBE_HOME
class FileSystemUpdateCacheRepository(UpdateCacheRepository): class FileSystemUpdateCacheRepository(UpdateCacheRepository):

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
__all__ = ["__version__", "run_programmatic"] __all__ = ["run_programmatic"]
__version__ = "1.1.3"
from vibe.core.programmatic import run_programmatic from vibe.core.programmatic import run_programmatic

View file

@ -22,10 +22,12 @@ from vibe.core.middleware import (
MiddlewareAction, MiddlewareAction,
MiddlewarePipeline, MiddlewarePipeline,
MiddlewareResult, MiddlewareResult,
PlanModeMiddleware,
PriceLimitMiddleware, PriceLimitMiddleware,
ResetReason, ResetReason,
TurnLimitMiddleware, TurnLimitMiddleware,
) )
from vibe.core.modes import AgentMode
from vibe.core.prompts import UtilityPrompt from vibe.core.prompts import UtilityPrompt
from vibe.core.system_prompt import get_universal_system_prompt from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.base import ( from vibe.core.tools.base import (
@ -88,14 +90,18 @@ class Agent:
def __init__( def __init__(
self, self,
config: VibeConfig, config: VibeConfig,
auto_approve: bool = False, mode: AgentMode = AgentMode.DEFAULT,
message_observer: Callable[[LLMMessage], None] | None = None, message_observer: Callable[[LLMMessage], None] | None = None,
max_turns: int | None = None, max_turns: int | None = None,
max_price: float | None = None, max_price: float | None = None,
backend: BackendLike | None = None, backend: BackendLike | None = None,
enable_streaming: bool = False, enable_streaming: bool = False,
) -> None: ) -> None:
"""Initialize the agent with configuration and mode."""
self.config = config self.config = config
self._mode = mode
self._max_turns = max_turns
self._max_price = max_price
self.tool_manager = ToolManager(config) self.tool_manager = ToolManager(config)
self.format_handler = APIToolFormatHandler() self.format_handler = APIToolFormatHandler()
@ -107,10 +113,9 @@ class Agent:
self._last_observed_message_index: int = 0 self._last_observed_message_index: int = 0
self.middleware_pipeline = MiddlewarePipeline() self.middleware_pipeline = MiddlewarePipeline()
self.enable_streaming = enable_streaming self.enable_streaming = enable_streaming
self._setup_middleware(max_turns, max_price) self._setup_middleware()
system_prompt = get_universal_system_prompt(self.tool_manager, config) system_prompt = get_universal_system_prompt(self.tool_manager, config)
self.messages = [LLMMessage(role=Role.system, content=system_prompt)] self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
if self.message_observer: if self.message_observer:
@ -125,7 +130,6 @@ class Agent:
except ValueError: except ValueError:
pass pass
self.auto_approve = auto_approve
self.approval_callback: ApprovalCallback | None = None self.approval_callback: ApprovalCallback | None = None
self.session_id = str(uuid4()) self.session_id = str(uuid4())
@ -133,12 +137,20 @@ class Agent:
self.interaction_logger = InteractionLogger( self.interaction_logger = InteractionLogger(
config.session_logging, config.session_logging,
self.session_id, self.session_id,
auto_approve, self.auto_approve,
config.effective_workdir, config.effective_workdir,
) )
self._last_chunk: LLMChunk | None = None self._last_chunk: LLMChunk | None = None
@property
def mode(self) -> AgentMode:
return self._mode
@property
def auto_approve(self) -> bool:
return self._mode.auto_approve
def _select_backend(self) -> BackendLike: def _select_backend(self) -> BackendLike:
active_model = self.config.get_active_model() active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model) provider = self.config.get_provider_for_model(active_model)
@ -164,14 +176,15 @@ class Agent:
async for event in self._conversation_loop(msg): async for event in self._conversation_loop(msg):
yield event yield event
def _setup_middleware(self, max_turns: int | None, max_price: float | None) -> None: def _setup_middleware(self) -> None:
"""Configure middleware pipeline for this conversation."""
self.middleware_pipeline.clear() self.middleware_pipeline.clear()
if max_turns is not None: if self._max_turns is not None:
self.middleware_pipeline.add(TurnLimitMiddleware(max_turns)) self.middleware_pipeline.add(TurnLimitMiddleware(self._max_turns))
if max_price is not None: if self._max_price is not None:
self.middleware_pipeline.add(PriceLimitMiddleware(max_price)) self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
if self.config.auto_compact_threshold > 0: if self.config.auto_compact_threshold > 0:
self.middleware_pipeline.add( self.middleware_pipeline.add(
@ -182,6 +195,8 @@ class Agent:
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold) ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
) )
self.middleware_pipeline.add(PlanModeMiddleware(lambda: self._mode))
async def _handle_middleware_result( async def _handle_middleware_result(
self, result: MiddlewareResult self, result: MiddlewareResult
) -> AsyncGenerator[BaseEvent]: ) -> AsyncGenerator[BaseEvent]:
@ -191,9 +206,6 @@ class Agent:
content=f"<{VIBE_STOP_EVENT_TAG}>{result.reason}</{VIBE_STOP_EVENT_TAG}>", content=f"<{VIBE_STOP_EVENT_TAG}>{result.reason}</{VIBE_STOP_EVENT_TAG}>",
stopped_by_middleware=True, stopped_by_middleware=True,
) )
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
)
case MiddlewareAction.INJECT_MESSAGE: case MiddlewareAction.INJECT_MESSAGE:
if result.message and len(self.messages) > 0: if result.message and len(self.messages) > 0:
@ -241,12 +253,10 @@ class Agent:
result = await self.middleware_pipeline.run_before_turn( result = await self.middleware_pipeline.run_before_turn(
self._get_context() self._get_context()
) )
async for event in self._handle_middleware_result(result): async for event in self._handle_middleware_result(result):
yield event yield event
if result.action == MiddlewareAction.STOP: if result.action == MiddlewareAction.STOP:
self._flush_new_messages()
return return
self.stats.steps += 1 self.stats.steps += 1
@ -264,40 +274,25 @@ class Agent:
) )
self._flush_new_messages() self._flush_new_messages()
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
)
if user_cancelled: if user_cancelled:
self._flush_new_messages()
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
)
return return
after_result = await self.middleware_pipeline.run_after_turn( after_result = await self.middleware_pipeline.run_after_turn(
self._get_context() self._get_context()
) )
async for event in self._handle_middleware_result(after_result): async for event in self._handle_middleware_result(after_result):
yield event yield event
if after_result.action == MiddlewareAction.STOP: if after_result.action == MiddlewareAction.STOP:
self._flush_new_messages()
return return
finally:
self._flush_new_messages() self._flush_new_messages()
await self.interaction_logger.save_interaction( await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager self.messages, self.stats, self.config, self.tool_manager
) )
except Exception:
self._flush_new_messages()
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
)
raise
async def _perform_llm_turn( async def _perform_llm_turn(
self, self,
) -> AsyncGenerator[AssistantEvent | ToolCallEvent | ToolResultEvent]: ) -> AsyncGenerator[AssistantEvent | ToolCallEvent | ToolResultEvent]:
@ -410,7 +405,7 @@ class Agent:
return AssistantEvent(content=assistant_msg.content or "") return AssistantEvent(content=assistant_msg.content or "")
async def _handle_tool_calls( # noqa: PLR0915 async def _handle_tool_calls(
self, resolved: ResolvedMessage self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent]: ) -> AsyncGenerator[ToolCallEvent | ToolResultEvent]:
for failed in resolved.failed_calls: for failed in resolved.failed_calls:
@ -534,9 +529,6 @@ class Agent:
) )
) )
) )
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
)
raise raise
except KeyboardInterrupt: except KeyboardInterrupt:
@ -556,9 +548,6 @@ class Agent:
) )
) )
) )
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
)
raise raise
except (ToolError, ToolPermissionError) as exc: except (ToolError, ToolPermissionError) as exc:
@ -844,6 +833,7 @@ class Agent:
self._reset_session() self._reset_session()
async def compact(self) -> str: async def compact(self) -> str:
"""Compact the conversation history."""
try: try:
self._clean_message_history() self._clean_message_history()
await self.interaction_logger.save_interaction( await self.interaction_logger.save_interaction(
@ -906,6 +896,16 @@ class Agent:
) )
raise raise
async def switch_mode(self, new_mode: AgentMode) -> None:
if new_mode == self._mode:
return
new_config = VibeConfig.load(
workdir=self.config.workdir, **new_mode.config_overrides
)
await self.reload_with_initial_messages(config=new_config)
self._mode = new_mode
async def reload_with_initial_messages( async def reload_with_initial_messages(
self, self,
config: VibeConfig | None = None, config: VibeConfig | None = None,
@ -917,22 +917,25 @@ class Agent:
) )
preserved_messages = self.messages[1:] if len(self.messages) > 1 else [] preserved_messages = self.messages[1:] if len(self.messages) > 1 else []
old_system_prompt = self.messages[0].content if len(self.messages) > 0 else ""
if config is not None: if config is not None:
self.config = config self.config = config
self.backend = self.backend_factory() self.backend = self.backend_factory()
if max_turns is not None:
self._max_turns = max_turns
if max_price is not None:
self._max_price = max_price
self.tool_manager = ToolManager(self.config) self.tool_manager = ToolManager(self.config)
new_system_prompt = get_universal_system_prompt(self.tool_manager, self.config) new_system_prompt = get_universal_system_prompt(self.tool_manager, self.config)
self.messages = [LLMMessage(role=Role.system, content=new_system_prompt)] self.messages = [LLMMessage(role=Role.system, content=new_system_prompt)]
did_system_prompt_change = old_system_prompt != new_system_prompt
if preserved_messages: if preserved_messages:
self.messages.extend(preserved_messages) self.messages.extend(preserved_messages)
if len(self.messages) == 1 or did_system_prompt_change: if len(self.messages) == 1:
self.stats.reset_context_state() self.stats.reset_context_state()
try: try:
@ -945,7 +948,7 @@ class Agent:
self._last_observed_message_index = 0 self._last_observed_message_index = 0
self._setup_middleware(max_turns, max_price) self._setup_middleware()
if self.message_observer: if self.message_observer:
for msg in self.messages: for msg in self.messages:

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import atexit
from collections.abc import Iterable from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
@ -40,6 +41,8 @@ class FileIndexer:
self._target_root: Path | None = None self._target_root: Path | None = None
self._shutdown = False self._shutdown = False
atexit.register(self.shutdown)
@property @property
def stats(self) -> FileIndexStats: def stats(self) -> FileIndexStats:
return self._stats return self._stats

View file

@ -19,14 +19,8 @@ from pydantic_settings import (
) )
import tomli_w import tomli_w
from vibe.core.config_path import ( from vibe.core.paths.config_paths import AGENT_DIR, CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
AGENT_DIR, from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
CONFIG_DIR,
CONFIG_FILE,
GLOBAL_ENV_FILE,
PROMPT_DIR,
SESSION_LOG_DIR,
)
from vibe.core.prompts import SystemPrompt from vibe.core.prompts import SystemPrompt
from vibe.core.tools.base import BaseToolConfig from vibe.core.tools.base import BaseToolConfig

View file

@ -1,51 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
import os
from pathlib import Path
class ConfigPath:
def __init__(self, path_resolver: Callable[[], Path]) -> None:
self._path_resolver = path_resolver
@property
def path(self) -> Path:
return self._path_resolver()
_DEFAULT_VIBE_HOME = Path.home() / ".vibe"
def _get_vibe_home() -> Path:
if vibe_home := os.getenv("VIBE_HOME"):
return Path(vibe_home).expanduser().resolve()
return _DEFAULT_VIBE_HOME
def _resolve_config_file() -> Path:
if (candidate := Path.cwd() / ".vibe" / "config.toml").is_file():
return candidate
return _get_vibe_home() / "config.toml"
def resolve_local_tools_dir(dir: Path) -> Path | None:
if (candidate := dir / ".vibe" / "tools").is_dir():
return candidate
return None
VIBE_HOME = ConfigPath(_get_vibe_home)
GLOBAL_CONFIG_FILE = ConfigPath(lambda: VIBE_HOME.path / "config.toml")
GLOBAL_ENV_FILE = ConfigPath(lambda: VIBE_HOME.path / ".env")
GLOBAL_TOOLS_DIR = ConfigPath(lambda: VIBE_HOME.path / "tools")
SESSION_LOG_DIR = ConfigPath(lambda: VIBE_HOME.path / "logs" / "session")
CONFIG_FILE = ConfigPath(_resolve_config_file)
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
LOG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "logs")
AGENT_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "agents")
PROMPT_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "prompts")
INSTRUCTIONS_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "instructions.md")
HISTORY_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "vibehistory")
LOG_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "vibe.log")

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum, auto from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
from vibe.core.modes import AgentMode
from vibe.core.utils import VIBE_WARNING_TAG from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING: if TYPE_CHECKING:
@ -141,6 +143,37 @@ class ContextWarningMiddleware:
self.has_warned = False self.has_warned = False
PLAN_MODE_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits). Instead, you should:
1. Answer the user's query comprehensively
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.</{VIBE_WARNING_TAG}>"""
class PlanModeMiddleware:
"""Injects plan mode reminder after each assistant turn when plan mode is active."""
def __init__(
self, mode_getter: Callable[[], AgentMode], reminder: str = PLAN_MODE_REMINDER
) -> None:
self._mode_getter = mode_getter
self.reminder = reminder
def _is_plan_mode(self) -> bool:
return self._mode_getter() == AgentMode.PLAN
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if not self._is_plan_mode():
return MiddlewareResult()
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
class MiddlewarePipeline: class MiddlewarePipeline:
def __init__(self) -> None: def __init__(self) -> None:
self.middlewares: list[ConversationMiddleware] = [] self.middlewares: list[ConversationMiddleware] = []
@ -174,18 +207,13 @@ class MiddlewarePipeline:
return MiddlewareResult() return MiddlewareResult()
async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult: async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult:
messages_to_inject = []
for mw in self.middlewares: for mw in self.middlewares:
result = await mw.after_turn(context) result = await mw.after_turn(context)
if result.action == MiddlewareAction.INJECT_MESSAGE and result.message: if result.action == MiddlewareAction.INJECT_MESSAGE:
messages_to_inject.append(result.message) raise ValueError(
elif result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}: f"INJECT_MESSAGE not allowed in after_turn (from {type(mw).__name__})"
return result
if messages_to_inject:
combined_message = "\n\n".join(messages_to_inject)
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=combined_message
) )
if result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
return result
return MiddlewareResult() return MiddlewareResult()

108
vibe/core/modes.py Normal file
View file

@ -0,0 +1,108 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum, auto
from typing import Any
class ModeSafety(StrEnum):
SAFE = auto()
NEUTRAL = auto()
DESTRUCTIVE = auto()
YOLO = auto()
class AgentMode(StrEnum):
DEFAULT = auto()
AUTO_APPROVE = auto()
PLAN = auto()
ACCEPT_EDITS = auto()
@property
def display_name(self) -> str:
return MODE_CONFIGS[self].display_name
@property
def description(self) -> str:
return MODE_CONFIGS[self].description
@property
def config_overrides(self) -> dict[str, Any]:
return MODE_CONFIGS[self].config_overrides
@property
def auto_approve(self) -> bool:
return MODE_CONFIGS[self].auto_approve
@property
def safety(self) -> ModeSafety:
return MODE_CONFIGS[self].safety
@classmethod
def from_string(cls, value: str) -> AgentMode | None:
try:
return cls(value.lower())
except ValueError:
return None
@dataclass(frozen=True)
class ModeConfig:
display_name: str
description: str
safety: ModeSafety = ModeSafety.NEUTRAL
auto_approve: bool = False
config_overrides: dict[str, Any] = field(default_factory=dict)
PLAN_MODE_TOOLS = ["grep", "read_file", "todo"]
ACCEPT_EDITS_TOOLS = ["write_file", "search_replace"]
MODE_CONFIGS: dict[AgentMode, ModeConfig] = {
AgentMode.DEFAULT: ModeConfig(
display_name="Default",
description="Requires approval for tool executions",
safety=ModeSafety.NEUTRAL,
auto_approve=False,
),
AgentMode.PLAN: ModeConfig(
display_name="Plan",
description="Read-only mode for exploration and planning",
safety=ModeSafety.SAFE,
auto_approve=True,
config_overrides={"enabled_tools": PLAN_MODE_TOOLS},
),
AgentMode.ACCEPT_EDITS: ModeConfig(
display_name="Accept Edits",
description="Auto-approves file edits only",
safety=ModeSafety.DESTRUCTIVE,
auto_approve=False,
config_overrides={
"tools": {
"write_file": {"permission": "always"},
"search_replace": {"permission": "always"},
}
},
),
AgentMode.AUTO_APPROVE: ModeConfig(
display_name="Auto Approve",
description="Auto-approves all tool executions",
safety=ModeSafety.YOLO,
auto_approve=True,
),
}
def get_mode_order() -> list[AgentMode]:
return [
AgentMode.DEFAULT,
AgentMode.PLAN,
AgentMode.ACCEPT_EDITS,
AgentMode.AUTO_APPROVE,
]
def next_mode(current: AgentMode) -> AgentMode:
order = get_mode_order()
idx = order.index(current)
return order[(idx + 1) % len(order)]

View file

View file

@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
from typing import Literal
from vibe.core.paths.global_paths import VIBE_HOME, GlobalPath
from vibe.core.trusted_folders import trusted_folders_manager
_config_paths_locked: bool = True
class ConfigPath(GlobalPath):
@property
def path(self) -> Path:
if _config_paths_locked:
raise RuntimeError("Config path is locked")
return super().path
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
if type == "file":
if (candidate := cwd / ".vibe" / basename).is_file():
return candidate
elif type == "dir":
if (candidate := cwd / ".vibe" / basename).is_dir():
return candidate
return VIBE_HOME.path / basename
def resolve_local_tools_dir(dir: Path) -> Path | None:
if (candidate := dir / ".vibe" / "tools").is_dir():
return candidate
return None
def unlock_config_paths() -> None:
global _config_paths_locked
_config_paths_locked = False
CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file"))
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
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"))

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from collections.abc import Callable
import os
from pathlib import Path
from vibe import VIBE_ROOT
class GlobalPath:
def __init__(self, resolver: Callable[[], Path]) -> None:
self._resolver = resolver
@property
def path(self) -> Path:
return self._resolver()
_DEFAULT_VIBE_HOME = Path.home() / ".vibe"
def _get_vibe_home() -> Path:
if vibe_home := os.getenv("VIBE_HOME"):
return Path(vibe_home).expanduser().resolve()
return _DEFAULT_VIBE_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")
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")
DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins")

View file

@ -4,6 +4,7 @@ import asyncio
from vibe.core.agent import Agent from vibe.core.agent import Agent
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.output_formatters import create_formatter from vibe.core.output_formatters import create_formatter
from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger from vibe.core.utils import ConversationLimitException, logger
@ -16,7 +17,7 @@ def run_programmatic(
max_price: float | None = None, max_price: float | None = None,
output_format: OutputFormat = OutputFormat.TEXT, output_format: OutputFormat = OutputFormat.TEXT,
previous_messages: list[LLMMessage] | None = None, previous_messages: list[LLMMessage] | None = None,
auto_approve: bool = True, mode: AgentMode = AgentMode.AUTO_APPROVE,
) -> str | None: ) -> str | None:
"""Run in programmatic mode: execute prompt and return the assistant response. """Run in programmatic mode: execute prompt and return the assistant response.
@ -27,7 +28,7 @@ def run_programmatic(
max_price: Maximum cost in dollars before stopping max_price: Maximum cost in dollars before stopping
output_format: Format for the output output_format: Format for the output
previous_messages: Optional messages from a previous session to continue previous_messages: Optional messages from a previous session to continue
auto_approve: Whether to automatically approve tool execution mode: Operational mode (defaults to AUTO_APPROVE for programmatic use)
Returns: Returns:
The final assistant response text, or None if no response The final assistant response text, or None if no response
@ -36,7 +37,7 @@ def run_programmatic(
agent = Agent( agent = Agent(
config, config,
auto_approve=auto_approve, mode=mode,
message_observer=formatter.on_message_added, message_observer=formatter.on_message_added,
max_turns=max_turns, max_turns=max_turns,
max_price=max_price, max_price=max_price,

View file

@ -10,8 +10,8 @@ import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from vibe.core.config import PROJECT_DOC_FILENAMES from vibe.core.config import PROJECT_DOC_FILENAMES
from vibe.core.config_path import INSTRUCTIONS_FILE
from vibe.core.llm.format import get_active_tool_classes from vibe.core.llm.format import get_active_tool_classes
from vibe.core.paths.config_paths import INSTRUCTIONS_FILE
from vibe.core.prompts import UtilityPrompt from vibe.core.prompts import UtilityPrompt
from vibe.core.utils import is_dangerous_directory, is_windows from vibe.core.utils import is_dangerous_directory, is_windows

View file

@ -100,7 +100,7 @@ class SearchReplace(
if isinstance(event.result, SearchReplaceResult): if isinstance(event.result, SearchReplaceResult):
return ToolResultDisplay( return ToolResultDisplay(
success=True, success=True,
message=f"Applied {event.result.blocks_applied} blocks", message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'}",
warnings=event.result.warnings, warnings=event.result.warnings,
details={ details={
"lines_changed": event.result.lines_changed, "lines_changed": event.result.lines_changed,

View file

@ -9,8 +9,8 @@ import re
import sys import sys
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from vibe import VIBE_ROOT from vibe.core.paths.config_paths import resolve_local_tools_dir
from vibe.core.config_path import GLOBAL_TOOLS_DIR, resolve_local_tools_dir from vibe.core.paths.global_paths import DEFAULT_TOOL_DIR, GLOBAL_TOOLS_DIR
from vibe.core.tools.base import BaseTool, BaseToolConfig from vibe.core.tools.base import BaseTool, BaseToolConfig
from vibe.core.tools.mcp import ( from vibe.core.tools.mcp import (
RemoteTool, RemoteTool,
@ -19,6 +19,7 @@ from vibe.core.tools.mcp import (
list_tools_http, list_tools_http,
list_tools_stdio, list_tools_stdio,
) )
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.utils import run_sync from vibe.core.utils import run_sync
logger = getLogger("vibe") logger = getLogger("vibe")
@ -31,9 +32,6 @@ class NoSuchToolError(Exception):
"""Exception raised when a tool is not found.""" """Exception raised when a tool is not found."""
DEFAULT_TOOL_DIR = VIBE_ROOT / "core" / "tools" / "builtins"
class ToolManager: class ToolManager:
"""Manages tool discovery and instantiation for an Agent. """Manages tool discovery and instantiation for an Agent.
@ -53,14 +51,19 @@ class ToolManager:
@staticmethod @staticmethod
def _compute_search_paths(config: VibeConfig) -> list[Path]: def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = [DEFAULT_TOOL_DIR] paths: list[Path] = [DEFAULT_TOOL_DIR.path]
for p in config.tool_paths: for p in config.tool_paths:
path = Path(p).expanduser().resolve() path = Path(p).expanduser().resolve()
if path.is_dir(): if path.is_dir():
paths.append(path) paths.append(path)
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None: is_folder_trusted = trusted_folders_manager.is_trusted(config.effective_workdir)
if (
is_folder_trusted is True
and (tools_dir := resolve_local_tools_dir(config.effective_workdir))
is not None
):
paths.append(tools_dir) paths.append(tools_dir)
if GLOBAL_TOOLS_DIR.path.is_dir(): if GLOBAL_TOOLS_DIR.path.is_dir():
@ -115,7 +118,7 @@ class ToolManager:
search_paths: list[Path] | None = None, search_paths: list[Path] | None = None,
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
if search_paths is None: if search_paths is None:
search_paths = [DEFAULT_TOOL_DIR] search_paths = [DEFAULT_TOOL_DIR.path]
defaults: dict[str, dict[str, Any]] = {} defaults: dict[str, dict[str, Any]] = {}
for cls in ToolManager._iter_tool_classes(search_paths): for cls in ToolManager._iter_tool_classes(search_paths):

View file

@ -0,0 +1,72 @@
from __future__ import annotations
from pathlib import Path
import tomllib
import tomli_w
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
class TrustedFoldersManager:
def __init__(self) -> None:
self._file_path = TRUSTED_FOLDERS_FILE.path
self._trusted: list[str] = []
self._untrusted: list[str] = []
self._load()
def _normalize_path(self, path: Path) -> str:
return str(path.expanduser().resolve())
def _load(self) -> None:
if not self._file_path.is_file():
self._trusted = []
self._untrusted = []
self._save()
return
try:
with self._file_path.open("rb") as f:
data = tomllib.load(f)
self._trusted = list(data.get("trusted", []))
self._untrusted = list(data.get("untrusted", []))
except (OSError, tomllib.TOMLDecodeError):
self._trusted = []
self._untrusted = []
self._save()
def _save(self) -> None:
self._file_path.parent.mkdir(parents=True, exist_ok=True)
data = {"trusted": self._trusted, "untrusted": self._untrusted}
try:
with self._file_path.open("wb") as f:
tomli_w.dump(data, f)
except OSError:
pass
def is_trusted(self, path: Path) -> bool | None:
normalized = self._normalize_path(path)
if normalized in self._trusted:
return True
if normalized in self._untrusted:
return False
return None
def add_trusted(self, path: Path) -> None:
normalized = self._normalize_path(path)
if normalized not in self._trusted:
self._trusted.append(normalized)
if normalized in self._untrusted:
self._untrusted.remove(normalized)
self._save()
def add_untrusted(self, path: Path) -> None:
normalized = self._normalize_path(path)
if normalized not in self._untrusted:
self._untrusted.append(normalized)
if normalized in self._trusted:
self._trusted.remove(normalized)
self._save()
trusted_folders_manager = TrustedFoldersManager()

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from abc import ABC from abc import ABC
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from enum import StrEnum, auto from enum import StrEnum, auto
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
@ -18,22 +17,6 @@ from pydantic import (
from vibe.core.tools.base import BaseTool from vibe.core.tools.base import BaseTool
@dataclass
class ResumeSessionInfo:
type: Literal["continue", "resume"]
session_id: str
session_time: str
def message(self) -> str:
action = None
match self.type:
case "continue":
action = "Continuing"
case "resume":
action = "Resuming"
return f"{action} session `{self.session_id}` from {self.session_time}"
class AgentStats(BaseModel): class AgentStats(BaseModel):
steps: int = 0 steps: int = 0
session_prompt_tokens: int = 0 session_prompt_tokens: int = 0

View file

@ -13,9 +13,9 @@ from typing import Any
import httpx import httpx
from vibe.core import __version__ from vibe import __version__
from vibe.core.config import Backend from vibe.core.config import Backend
from vibe.core.config_path import CONFIG_FILE, GLOBAL_CONFIG_FILE, LOG_DIR, LOG_FILE from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
from vibe.core.types import BaseEvent, ToolResultEvent from vibe.core.types import BaseEvent, ToolResultEvent
CANCELLATION_TAG = "user_cancellation" CANCELLATION_TAG = "user_cancellation"
@ -144,13 +144,6 @@ logging.basicConfig(
) )
logger = logging.getLogger("vibe") logger = logging.getLogger("vibe")
logger.info("Using config: %s", CONFIG_FILE.path)
if CONFIG_FILE.path != GLOBAL_CONFIG_FILE.path and GLOBAL_CONFIG_FILE.path.is_file():
logger.warning(
"Project config active (%s); ignoring global config (%s)",
CONFIG_FILE.path,
GLOBAL_CONFIG_FILE.path,
)
def get_user_agent(backend: Backend) -> str: def get_user_agent(backend: Backend) -> str:

View file

@ -5,7 +5,7 @@ import sys
from rich import print as rprint from rich import print as rprint
from textual.app import App from textual.app import App
from vibe.core.config_path import GLOBAL_ENV_FILE from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding.screens import ( from vibe.setup.onboarding.screens import (
ApiKeyScreen, ApiKeyScreen,
ThemeSelectionScreen, ThemeSelectionScreen,

View file

@ -13,7 +13,7 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.config_path import GLOBAL_ENV_FILE from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding.base import OnboardingScreen from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = { PROVIDER_HELP = {

View file

@ -0,0 +1,187 @@
from __future__ import annotations
from pathlib import Path
import tomllib
from typing import Any, ClassVar
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import CenterMiddle, Horizontal
from textual.message import Message
from textual.widgets import Static
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE
class TrustDialogQuitException(Exception):
pass
class TrustFolderDialog(CenterMiddle):
can_focus = True
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("left", "move_left", "Left", show=False),
Binding("right", "move_right", "Right", show=False),
Binding("enter", "select", "Select", show=False),
Binding("1", "select_1", "Yes", show=False),
Binding("y", "select_1", "Yes", show=False),
Binding("2", "select_2", "No", show=False),
Binding("n", "select_2", "No", show=False),
]
class Trusted(Message):
pass
class Untrusted(Message):
pass
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self.selected_option = 0
self.option_widgets: list[Static] = []
def compose(self) -> ComposeResult:
with CenterMiddle(id="trust-dialog"):
yield Static("⚠ Trust this folder?", id="trust-dialog-title")
yield Static(
str(self.folder_path),
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",
)
async def on_mount(self) -> None:
self.selected_option = 1 # Default to "No"
self._update_options()
self.focus()
def _update_options(self) -> None:
options = ["Yes", "No"]
if len(self.option_widgets) != len(options):
return
for idx, (text, widget) in enumerate(
zip(options, self.option_widgets, strict=True)
):
is_selected = idx == self.selected_option
cursor = " " if is_selected else " "
option_text = f"{cursor}{text}"
widget.update(option_text)
widget.remove_class("trust-cursor-selected")
widget.remove_class("trust-option-selected")
if is_selected:
widget.add_class("trust-cursor-selected")
else:
widget.add_class("trust-option-selected")
def action_move_left(self) -> None:
self.selected_option = (self.selected_option - 1) % 2
self._update_options()
def action_move_right(self) -> None:
self.selected_option = (self.selected_option + 1) % 2
self._update_options()
def action_select(self) -> None:
self._handle_selection(self.selected_option)
def action_select_1(self) -> None:
self.selected_option = 0
self._handle_selection(0)
def action_select_2(self) -> None:
self.selected_option = 1
self._handle_selection(1)
def _handle_selection(self, option: int) -> None:
match option:
case 0:
self.post_message(self.Trusted())
case 1:
self.post_message(self.Untrusted())
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)
class TrustFolderApp(App):
CSS_PATH = "trust_folder_dialog.tcss"
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+q", "quit_without_saving", "Quit", show=False, priority=True),
Binding("ctrl+c", "quit_without_saving", "Quit", show=False, priority=True),
]
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self._result: bool | None = None
self._quit_without_saving = False
self._load_theme()
def _load_theme(self) -> None:
config_file = GLOBAL_CONFIG_FILE.path
if not config_file.is_file():
return
try:
with config_file.open("rb") as f:
config_data = tomllib.load(f)
if textual_theme := config_data.get("textual_theme"):
self.theme = textual_theme
except (OSError, tomllib.TOMLDecodeError, KeyError):
pass
def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path)
def action_quit_without_saving(self) -> None:
self._quit_without_saving = True
self.exit()
def on_trust_folder_dialog_trusted(self, _: TrustFolderDialog.Trusted) -> None:
self._result = True
self.exit()
def on_trust_folder_dialog_untrusted(self, _: TrustFolderDialog.Untrusted) -> None:
self._result = False
self.exit()
def run_trust_dialog(self) -> bool | None:
self.run()
if self._quit_without_saving:
raise TrustDialogQuitException()
return self._result
def ask_trust_folder(folder_path: Path) -> bool | None:
app = TrustFolderApp(folder_path)
return app.run_trust_dialog()

View file

@ -0,0 +1,83 @@
Screen {
align: center middle;
background: $background 80%;
}
#trust-dialog {
width: 70;
max-width: 90%;
min-width: 50;
height: auto;
border: round $foreground-muted;
background: $background;
padding: 1;
}
#trust-dialog-title {
width: 100%;
height: auto;
text-style: bold;
color: $warning;
text-align: center;
margin-bottom: 1;
}
#trust-dialog-path {
width: 100%;
height: auto;
color: $primary;
text-align: center;
text-wrap: wrap;
margin-bottom: 1;
}
#trust-dialog-message {
width: 100%;
height: auto;
color: $foreground;
text-align: center;
text-wrap: wrap;
margin-bottom: 1;
}
#trust-options-container {
width: 100%;
height: auto;
align: center middle;
margin-bottom: 1;
}
.trust-option {
height: auto;
width: auto;
color: $foreground;
margin: 0 3;
content-align: center middle;
}
.trust-cursor-selected {
color: $foreground;
text-style: bold;
}
.trust-option-selected {
color: $foreground;
}
.trust-dialog-help {
width: 100%;
height: auto;
color: $text-muted;
text-align: center;
margin-bottom: 1;
}
.trust-dialog-save-info {
width: 100%;
height: auto;
color: $text-muted;
text-align: center;
text-style: italic;
text-wrap: wrap;
margin-bottom: 1;
}