v1.2.0
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>
33
.github/workflows/build-and-upload.yml
vendored
@@ -1,11 +1,11 @@
|
||||
name: Build and upload
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
configure:
|
||||
@@ -95,3 +95,30 @@ jobs:
|
||||
with:
|
||||
name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_windows.outputs.version }}
|
||||
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 }}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
[default]
|
||||
extend-ignore-re = ["(?m)^.*(#|//)\\s*typos:disable-line$", "datas"]
|
||||
|
||||
[default.extend-words]
|
||||
iterm = "iterm"
|
||||
ITERM = "ITERM"
|
||||
|
||||
20
.vscode/launch.json
vendored
@@ -1,15 +1,12 @@
|
||||
{
|
||||
"version": "1.1.3",
|
||||
"version": "1.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "vibe/acp/entrypoint.py",
|
||||
"args": [
|
||||
"--workdir",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"args": [],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false
|
||||
},
|
||||
@@ -18,10 +15,7 @@
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"args": [
|
||||
"-v",
|
||||
"-s"
|
||||
],
|
||||
"args": ["-v", "-s"],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}",
|
||||
@@ -34,13 +28,7 @@
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"args": [
|
||||
"-k",
|
||||
"${input:test_identifier}",
|
||||
"-vvv",
|
||||
"-s",
|
||||
"--no-header"
|
||||
],
|
||||
"args": ["-k", "${input:test_identifier}", "-vvv", "-s", "--no-header"],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}",
|
||||
|
||||
22
CHANGELOG.md
@@ -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/),
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -304,6 +304,10 @@ This affects where Vibe looks for:
|
||||
- `tools/` - Custom tools
|
||||
- `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
|
||||
|
||||
- [CHANGELOG](CHANGELOG.md) - See what's new in each version
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "1.1.3"
|
||||
version = "1.2.0"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
@@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
||||
icon = "./icons/mistral_vibe.svg"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/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"
|
||||
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
||||
7
docs/README.md
Normal 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
@@ -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
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763283776,
|
||||
"narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=",
|
||||
"lastModified": 1765472234,
|
||||
"narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a",
|
||||
"rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -47,11 +47,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1761781027,
|
||||
"narHash": "sha256-YDvxPAm2WnxrznRqWwHLjryBGG5Ey1ATEJXrON+TWt8=",
|
||||
"lastModified": 1763662255,
|
||||
"narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "build-system-pkgs",
|
||||
"rev": "795a980d25301e5133eca37adae37283ec3c8e66",
|
||||
"rev": "042904167604c681a090c07eb6967b4dd4dae88c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -67,11 +67,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763017646,
|
||||
"narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=",
|
||||
"lastModified": 1764134915,
|
||||
"narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"rev": "47bd6f296502842643078d66128f7b5e5370790c",
|
||||
"rev": "2c8df1383b32e5443c921f61224b198a2282a657",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -114,11 +114,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763349549,
|
||||
"narHash": "sha256-GQKYN9j8HOh09RW2I739tyu87ygcsAmpJJ32FspWVJ8=",
|
||||
"lastModified": 1765631794,
|
||||
"narHash": "sha256-90d//IZ4GXipNsngO4sb2SAPbIC/a2P+IAdAWOwpcOM=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"rev": "071b718279182c5585f74939c2902c202f93f588",
|
||||
"rev": "4cca323a547a1aaa9b94929c4901bed5343eafe8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "1.1.3"
|
||||
version = "1.2.0"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -131,7 +131,7 @@ Examples:
|
||||
)
|
||||
# Update vibe/core/__init__.py
|
||||
update_hard_values_files(
|
||||
"vibe/core/__init__.py",
|
||||
"vibe/__init__.py",
|
||||
[(f'__version__ = "{current_version}"', f'__version__ = "{new_version}"')],
|
||||
)
|
||||
# Update tests/acp/test_initialize.py
|
||||
|
||||
@@ -83,6 +83,9 @@ def _create_vibe_home_dir(tmp_path: Path, *sections: dict[str, Any]) -> Path:
|
||||
with config_file.open("wb") as 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
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestACPInitialize:
|
||||
),
|
||||
)
|
||||
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 == []
|
||||
@@ -63,7 +63,7 @@ class TestACPInitialize:
|
||||
),
|
||||
)
|
||||
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
|
||||
|
||||
@@ -9,9 +9,9 @@ import pytest
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.acp.utils import VibeSessionMode
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.modes import AgentMode
|
||||
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.currentModeId 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 (
|
||||
session_response.modes.availableModes[0].id
|
||||
== VibeSessionMode.APPROVAL_REQUIRED
|
||||
)
|
||||
assert session_response.modes.availableModes[0].name == "Approval Required"
|
||||
assert (
|
||||
session_response.modes.availableModes[1].id == VibeSessionMode.AUTO_APPROVE
|
||||
session_response.modes.availableModes[1].id == AgentMode.AUTO_APPROVE.value
|
||||
)
|
||||
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.asyncio
|
||||
|
||||
@@ -9,8 +9,8 @@ import pytest
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_connection import FakeAgentSideConnection
|
||||
from vibe.acp.acp_agent import VibeAcpAgent
|
||||
from vibe.acp.utils import VibeSessionMode
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||
|
||||
class TestACPSetMode:
|
||||
@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(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
@@ -59,21 +59,18 @@ class TestACPSetMode:
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
acp_session.agent.auto_approve = True
|
||||
acp_session.mode_id = VibeSessionMode.AUTO_APPROVE
|
||||
await acp_session.agent.switch_mode(AgentMode.AUTO_APPROVE)
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=VibeSessionMode.APPROVAL_REQUIRED
|
||||
)
|
||||
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.DEFAULT.value)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@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(
|
||||
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
|
||||
)
|
||||
@@ -83,19 +80,67 @@ class TestACPSetMode:
|
||||
)
|
||||
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
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
SetSessionModeRequest(
|
||||
sessionId=session_id, modeId=VibeSessionMode.AUTO_APPROVE
|
||||
sessionId=session_id, modeId=AgentMode.AUTO_APPROVE.value
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@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
|
||||
async def test_set_mode_invalid_mode_returns_none(
|
||||
self, acp_agent: VibeAcpAgent
|
||||
@@ -109,7 +154,7 @@ class TestACPSetMode:
|
||||
)
|
||||
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
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
@@ -117,7 +162,7 @@ class TestACPSetMode:
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -131,15 +176,15 @@ class TestACPSetMode:
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
initial_mode_id = VibeSessionMode.APPROVAL_REQUIRED
|
||||
assert acp_session.mode_id == initial_mode_id
|
||||
initial_mode = AgentMode.DEFAULT
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
|
||||
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 acp_session.mode_id == initial_mode_id
|
||||
assert acp_session.agent.mode == initial_mode
|
||||
assert acp_session.agent.auto_approve is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -153,7 +198,7 @@ class TestACPSetMode:
|
||||
)
|
||||
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
|
||||
|
||||
response = await acp_agent.setSessionMode(
|
||||
@@ -161,5 +206,5 @@ class TestACPSetMode:
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -105,7 +105,7 @@ def test_on_text_change_clears_suggestions_when_no_matches() -> None:
|
||||
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
|
||||
):
|
||||
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)
|
||||
|
||||
suggestions, selected_index = view.suggestion_events[-1]
|
||||
assert len(suggestions) == 5
|
||||
assert len(suggestions) == 7
|
||||
assert [suggestion.alias for suggestion in suggestions] == [
|
||||
"/config",
|
||||
"/compact",
|
||||
"/help",
|
||||
"/summarize",
|
||||
"/logpath",
|
||||
"/exit",
|
||||
"/vim",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -29,13 +29,13 @@ async def test_popup_appears_with_matching_suggestions(vibe_app: VibeApp) -> Non
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
popup = vibe_app.query_one(CompletionPopup)
|
||||
|
||||
await pilot.press(*"/sum")
|
||||
await pilot.press(*"/com")
|
||||
|
||||
popup_content = str(popup.render())
|
||||
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 chat_input.value == "/sum"
|
||||
assert chat_input.value == "/com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -88,11 +88,11 @@ async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) -
|
||||
|
||||
await pilot.press(*"/c")
|
||||
|
||||
ensure_selected_command(popup, "/cfg")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/config")
|
||||
await pilot.press("down")
|
||||
ensure_selected_command(popup, "/clear")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/cfg")
|
||||
ensure_selected_command(popup, "/config")
|
||||
|
||||
|
||||
@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:
|
||||
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")
|
||||
ensure_selected_command(popup, "/status")
|
||||
ensure_selected_command(popup, "/compact")
|
||||
await pilot.press("up")
|
||||
ensure_selected_command(popup, "/stats")
|
||||
ensure_selected_command(popup, "/config")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Any
|
||||
import pytest
|
||||
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]:
|
||||
@@ -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)
|
||||
def config_dir(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
|
||||
@@ -41,10 +51,15 @@ def config_dir(
|
||||
config_file = config_dir / "config.toml"
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _unlock_config_paths():
|
||||
unlock_config_paths()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "mock")
|
||||
|
||||
@@ -4,28 +4,41 @@ from pathlib import Path
|
||||
|
||||
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:
|
||||
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
|
||||
) -> None:
|
||||
"""Test that local .vibe/config.toml is found when it exists."""
|
||||
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")
|
||||
|
||||
monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True)
|
||||
|
||||
assert CONFIG_FILE.path == local_config
|
||||
assert CONFIG_FILE.path.is_file()
|
||||
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(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that global config is returned when local config doesn't exist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# Ensure no local config exists
|
||||
assert not (tmp_path / ".vibe" / "config.toml").exists()
|
||||
@@ -35,7 +48,6 @@ class TestResolveConfigFile:
|
||||
def test_respects_vibe_home_env_var(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that VIBE_HOME environment variable affects VIBE_HOME.path."""
|
||||
assert VIBE_HOME.path != tmp_path
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
assert VIBE_HOME.path == tmp_path
|
||||
|
||||
205
tests/core/test_trusted_folders.py
Normal 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
|
||||
@@ -14,12 +14,15 @@ from unittest.mock import patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from tests import TESTS_ROOT
|
||||
from tests.mock.utils import MOCK_DATA_ENV_VAR
|
||||
from vibe.core.types import LLMChunk
|
||||
from vibe.core.paths.config_paths import unlock_config_paths
|
||||
|
||||
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))
|
||||
|
||||
# Apply mocking before importing any vibe modules
|
||||
@@ -57,10 +60,6 @@ def mock_llm_output() -> None:
|
||||
side_effect=mock_complete_streaming,
|
||||
).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mock_llm_output()
|
||||
|
||||
from vibe.acp.entrypoint import main
|
||||
|
||||
main()
|
||||
|
||||
@@ -10,7 +10,7 @@ from textual.geometry import Size
|
||||
from textual.pilot import Pilot
|
||||
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.screens.api_key import ApiKeyScreen
|
||||
from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
@@ -37,7 +37,7 @@ class BaseSnapshotTestApp(VibeApp):
|
||||
|
||||
self.agent = Agent(
|
||||
config,
|
||||
auto_approve=self.auto_approve,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
backend=FakeBackend(),
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
|
||||
super().__init__(config=config)
|
||||
self.agent = Agent(
|
||||
config,
|
||||
auto_approve=self.auto_approve,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
backend=fake_backend,
|
||||
)
|
||||
|
||||
84
tests/snapshots/test_ui_snapshot_modes.py
Normal 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,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ from vibe.core.middleware import (
|
||||
MiddlewareResult,
|
||||
ResetReason,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.tools.builtins.todo import TodoArgs
|
||||
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)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
@@ -229,7 +230,7 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
@@ -274,7 +275,7 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
enable_streaming=True,
|
||||
)
|
||||
|
||||
@@ -366,7 +367,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
|
||||
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
|
||||
),
|
||||
backend=backend,
|
||||
auto_approve=False,
|
||||
mode=AgentMode.DEFAULT,
|
||||
enable_streaming=True,
|
||||
)
|
||||
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].skip_reason is not None
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ from vibe.core.config import (
|
||||
SessionLoggingConfig,
|
||||
VibeConfig,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
@@ -193,7 +194,7 @@ class TestReloadPreservesStats:
|
||||
mock_llm_chunk(content="Done", finish_reason="stop"),
|
||||
])
|
||||
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"):
|
||||
pass
|
||||
@@ -257,9 +258,7 @@ class TestReloadPreservesStats:
|
||||
assert agent.stats.context_tokens == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_resets_context_tokens_when_system_prompt_changes(
|
||||
self,
|
||||
) -> None:
|
||||
async def test_reload_preserves_context_tokens_when_messages_exist(self) -> None:
|
||||
backend = FakeBackend([
|
||||
mock_llm_chunk(content="Response", finish_reason="stop")
|
||||
])
|
||||
@@ -267,13 +266,14 @@ class TestReloadPreservesStats:
|
||||
config2 = make_config(system_prompt_id="cli")
|
||||
agent = Agent(config1, backend=backend)
|
||||
[_ 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
|
||||
|
||||
await agent.reload_with_initial_messages(config=config2)
|
||||
|
||||
assert len(agent.messages) > 1
|
||||
assert agent.stats.context_tokens == 0
|
||||
assert agent.stats.context_tokens == original_context_tokens
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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"),
|
||||
])
|
||||
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"):
|
||||
pass
|
||||
|
||||
@@ -11,6 +11,7 @@ from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_tool import FakeTool
|
||||
from vibe.core.agent import Agent
|
||||
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.builtins.todo import TodoItem
|
||||
from vibe.core.types import (
|
||||
@@ -57,10 +58,9 @@ def make_agent(
|
||||
backend: FakeBackend,
|
||||
approval_callback: SyncApprovalCallback | None = None,
|
||||
) -> Agent:
|
||||
mode = AgentMode.AUTO_APPROVE if auto_approve else AgentMode.DEFAULT
|
||||
agent = Agent(
|
||||
make_config(todo_permission=todo_permission),
|
||||
auto_approve=auto_approve,
|
||||
backend=backend,
|
||||
make_config(todo_permission=todo_permission), mode=mode, backend=backend
|
||||
)
|
||||
if approval_callback:
|
||||
agent.set_approval_callback(approval_callback)
|
||||
@@ -403,7 +403,7 @@ async def test_tool_call_can_be_interrupted(
|
||||
)
|
||||
agent = Agent(
|
||||
config,
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
backend=FakeBackend([
|
||||
mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call]),
|
||||
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:
|
||||
agent = Agent(
|
||||
make_config(),
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]),
|
||||
)
|
||||
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:
|
||||
agent = Agent(
|
||||
make_config(),
|
||||
auto_approve=True,
|
||||
mode=AgentMode.AUTO_APPROVE,
|
||||
backend=FakeBackend([mock_llm_chunk(content="ok", finish_reason="stop")]),
|
||||
)
|
||||
tool_msg = LLMMessage(
|
||||
|
||||
@@ -126,7 +126,6 @@ def test_run_programmatic_ignores_system_messages_in_previous(
|
||||
content="Second system message that should be ignored.",
|
||||
),
|
||||
],
|
||||
auto_approve=True,
|
||||
)
|
||||
|
||||
roles = [r for r, _ in spy.emitted]
|
||||
|
||||
126
tests/test_middleware.py
Normal 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
@@ -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()
|
||||
)
|
||||
@@ -21,6 +21,7 @@ from vibe.cli.update_notifier import (
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.modes import AgentMode
|
||||
|
||||
|
||||
async def _wait_for_notification(
|
||||
@@ -66,7 +67,7 @@ class VibeAppFactory(Protocol):
|
||||
notifier: FakeVersionUpdateGateway,
|
||||
update_cache_repository: FakeUpdateCacheRepository | None = None,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
current_version: str = "0.1.0",
|
||||
) -> VibeApp: ...
|
||||
|
||||
@@ -81,12 +82,12 @@ def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeApp
|
||||
update_cache_repository: FakeUpdateCacheRepository
|
||||
| None = update_cache_repository,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
current_version: str = "0.1.0",
|
||||
) -> VibeApp:
|
||||
return VibeApp(
|
||||
config=config or vibe_config_with_update_checks_enabled,
|
||||
auto_approve=auto_approve,
|
||||
initial_mode=initial_mode,
|
||||
version_update_notifier=notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
current_version=current_version,
|
||||
|
||||
8
uv.lock
generated
@@ -661,7 +661,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mistral-vibe"
|
||||
version = "1.1.3"
|
||||
version = "1.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
@@ -1512,7 +1512,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "6.7.1"
|
||||
version = "6.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||
@@ -1522,9 +1522,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
||||
@@ -3,3 +3,4 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "1.2.0"
|
||||
|
||||
@@ -46,17 +46,23 @@ from acp.schema import (
|
||||
)
|
||||
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.session_update import (
|
||||
tool_call_session_update,
|
||||
tool_result_session_update,
|
||||
)
|
||||
from vibe.acp.utils import TOOL_OPTIONS, ToolOption, VibeSessionMode
|
||||
from vibe.core import __version__
|
||||
from vibe.acp.utils import (
|
||||
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.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
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.types import (
|
||||
ApprovalResponse,
|
||||
@@ -72,7 +78,6 @@ class AcpSession(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
id: str
|
||||
agent: VibeAgent
|
||||
mode_id: VibeSessionMode = VibeSessionMode.APPROVAL_REQUIRED
|
||||
task: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
@@ -154,9 +159,11 @@ class VibeAcpAgent(AcpAgent):
|
||||
async def newSession(self, params: NewSessionRequest) -> NewSessionResponse:
|
||||
capability_disabled_tools = self._get_disabled_tools_from_capabilities()
|
||||
load_api_keys_from_env()
|
||||
|
||||
cwd = Path(params.cwd)
|
||||
try:
|
||||
config = VibeConfig.load(
|
||||
workdir=Path(params.cwd),
|
||||
workdir=cwd,
|
||||
tool_paths=[str(VIBE_ROOT / "acp" / "tools" / "builtins")],
|
||||
disabled_tools=capability_disabled_tools,
|
||||
)
|
||||
@@ -165,7 +172,7 @@ class VibeAcpAgent(AcpAgent):
|
||||
"message": "You must be authenticated before creating a new session"
|
||||
}) 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.
|
||||
# We should just use agent.session_id everywhere, but it can still change during
|
||||
# session lifetime (e.g. agent.compact is called).
|
||||
@@ -188,8 +195,8 @@ class VibeAcpAgent(AcpAgent):
|
||||
],
|
||||
),
|
||||
modes=SessionModeState(
|
||||
currentModeId=session.mode_id,
|
||||
availableModes=VibeSessionMode.get_all_acp_session_modes(),
|
||||
currentModeId=session.agent.mode.value,
|
||||
availableModes=get_all_acp_session_modes(),
|
||||
),
|
||||
)
|
||||
return response
|
||||
@@ -280,11 +287,21 @@ class VibeAcpAgent(AcpAgent):
|
||||
) -> SetSessionModeResponse | None:
|
||||
session = self._get_session(params.sessionId)
|
||||
|
||||
if not VibeSessionMode.is_valid(params.modeId):
|
||||
if not is_valid_acp_mode(params.modeId):
|
||||
return None
|
||||
|
||||
session.mode_id = VibeSessionMode(params.modeId)
|
||||
session.agent.auto_approve = params.modeId == VibeSessionMode.AUTO_APPROVE
|
||||
new_mode = acp_to_agent_mode(params.modeId)
|
||||
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()
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import argparse
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
|
||||
from vibe.acp.acp_agent import run_acp_server
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
from vibe import __version__
|
||||
from vibe.core.paths.config_paths import unlock_config_paths
|
||||
|
||||
# Configure line buffering for subprocess communication
|
||||
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -20,12 +20,20 @@ class Arguments:
|
||||
|
||||
def parse_arguments() -> Arguments:
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
return Arguments(setup=args.setup)
|
||||
|
||||
|
||||
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()
|
||||
if args.setup:
|
||||
run_onboarding()
|
||||
|
||||
@@ -1,47 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from enum import StrEnum
|
||||
from typing import Literal, cast
|
||||
|
||||
from acp.schema import PermissionOption, SessionMode
|
||||
|
||||
|
||||
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]
|
||||
from vibe.core.modes import MODE_CONFIGS, AgentMode
|
||||
|
||||
|
||||
class ToolOption(StrEnum):
|
||||
@@ -68,3 +32,22 @@ TOOL_OPTIONS = [
|
||||
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]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from threading import Lock
|
||||
|
||||
@@ -171,3 +172,6 @@ class PathCompletionController:
|
||||
self._view.replace_completion_range(start, end, completion)
|
||||
self.reset()
|
||||
return True
|
||||
|
||||
|
||||
atexit.register(PathCompletionController._executor.shutdown)
|
||||
|
||||
@@ -5,7 +5,7 @@ from textual import events
|
||||
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
|
||||
from vibe.core.autocompletion.completers import CommandCompleter
|
||||
|
||||
MAX_SUGGESTIONS_COUNT = 5
|
||||
MAX_SUGGESTIONS_COUNT = 10
|
||||
|
||||
|
||||
class SlashCommandController:
|
||||
|
||||
186
vibe/cli/cli.py
Normal 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)
|
||||
@@ -17,46 +17,51 @@ class CommandRegistry:
|
||||
excluded_commands = []
|
||||
self.commands = {
|
||||
"help": Command(
|
||||
aliases=frozenset(["/help", "/h"]),
|
||||
aliases=frozenset(["/help"]),
|
||||
description="Show help message",
|
||||
handler="_show_help",
|
||||
),
|
||||
"status": Command(
|
||||
aliases=frozenset(["/status", "/stats"]),
|
||||
description="Display agent statistics",
|
||||
handler="_show_status",
|
||||
),
|
||||
"config": Command(
|
||||
aliases=frozenset(["/config", "/cfg", "/theme", "/model"]),
|
||||
aliases=frozenset(["/config", "/theme", "/model"]),
|
||||
description="Edit config settings",
|
||||
handler="_show_config",
|
||||
),
|
||||
"reload": Command(
|
||||
aliases=frozenset(["/reload", "/r"]),
|
||||
aliases=frozenset(["/reload"]),
|
||||
description="Reload configuration from disk",
|
||||
handler="_reload_config",
|
||||
),
|
||||
"clear": Command(
|
||||
aliases=frozenset(["/clear", "/reset"]),
|
||||
aliases=frozenset(["/clear"]),
|
||||
description="Clear conversation history",
|
||||
handler="_clear_history",
|
||||
),
|
||||
"log": Command(
|
||||
aliases=frozenset(["/log", "/logpath"]),
|
||||
aliases=frozenset(["/log"]),
|
||||
description="Show path to current interaction log file",
|
||||
handler="_show_log_path",
|
||||
),
|
||||
"compact": Command(
|
||||
aliases=frozenset(["/compact", "/summarize"]),
|
||||
aliases=frozenset(["/compact"]),
|
||||
description="Compact conversation history by summarizing",
|
||||
handler="_compact_history",
|
||||
),
|
||||
"exit": Command(
|
||||
aliases=frozenset(["/exit", "/quit", "/q"]),
|
||||
aliases=frozenset(["/exit"]),
|
||||
description="Exit the application",
|
||||
handler="_exit_app",
|
||||
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:
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
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 import __version__
|
||||
from vibe.core.paths.config_paths import unlock_config_paths
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||
TrustDialogQuitException,
|
||||
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:
|
||||
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(
|
||||
"initial_prompt",
|
||||
nargs="?",
|
||||
@@ -41,7 +39,13 @@ def parse_arguments() -> argparse.Namespace:
|
||||
"--auto-approve",
|
||||
action="store_true",
|
||||
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(
|
||||
"--max-turns",
|
||||
@@ -99,160 +103,41 @@ def parse_arguments() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def get_prompt_from_stdin() -> str | None:
|
||||
if sys.stdin.isatty():
|
||||
return None
|
||||
def check_and_resolve_trusted_folder() -> None:
|
||||
cwd = Path.cwd()
|
||||
if not (cwd / ".vibe").exists():
|
||||
return
|
||||
|
||||
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
|
||||
|
||||
if is_folder_trusted is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
if content := sys.stdin.read().strip():
|
||||
sys.stdin = sys.__stdin__ = open("/dev/tty")
|
||||
return content
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except OSError:
|
||||
return None
|
||||
is_folder_trusted = ask_trust_folder(cwd)
|
||||
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
|
||||
return
|
||||
|
||||
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:
|
||||
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()
|
||||
def main() -> None:
|
||||
args = parse_arguments()
|
||||
|
||||
if args.setup:
|
||||
run_onboarding()
|
||||
sys.exit(0)
|
||||
try:
|
||||
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}[/]")
|
||||
is_interactive = args.prompt is None
|
||||
if is_interactive:
|
||||
check_and_resolve_trusted_folder()
|
||||
unlock_config_paths()
|
||||
|
||||
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}[/]")
|
||||
from vibe.cli.cli import run_cli
|
||||
|
||||
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}[/]")
|
||||
|
||||
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)
|
||||
run_cli(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
400
vibe/cli/terminal_setup.py
Normal 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",
|
||||
)
|
||||
@@ -3,17 +3,20 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, ClassVar, assert_never
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
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.widgets import Static
|
||||
|
||||
from vibe import __version__ as CORE_VERSION
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
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.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
@@ -28,6 +31,7 @@ from vibe.cli.textual_ui.widgets.messages import (
|
||||
InterruptMessage,
|
||||
UserCommandMessage,
|
||||
UserMessage,
|
||||
WarningMessage,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator
|
||||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
@@ -42,13 +46,13 @@ from vibe.cli.update_notifier import (
|
||||
VersionUpdateGateway,
|
||||
get_update_if_available,
|
||||
)
|
||||
from vibe.core import __version__ as CORE_VERSION
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
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.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role
|
||||
from vibe.core.types import ApprovalResponse, LLMMessage, Role
|
||||
from vibe.core.utils import (
|
||||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
@@ -68,7 +72,8 @@ class VibeApp(App):
|
||||
CSS_PATH = "app.tcss"
|
||||
|
||||
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("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
||||
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
|
||||
@@ -82,11 +87,10 @@ class VibeApp(App):
|
||||
def __init__(
|
||||
self,
|
||||
config: VibeConfig,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
enable_streaming: bool = False,
|
||||
initial_prompt: str | None = None,
|
||||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
version_update_notifier: VersionUpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
@@ -94,7 +98,7 @@ class VibeApp(App):
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.config = config
|
||||
self.auto_approve = auto_approve
|
||||
self._current_agent_mode = initial_mode
|
||||
self.enable_streaming = enable_streaming
|
||||
self.agent: Agent | None = None
|
||||
self._agent_running = False
|
||||
@@ -128,12 +132,12 @@ class VibeApp(App):
|
||||
|
||||
self._initial_prompt = initial_prompt
|
||||
self._loaded_messages = loaded_messages
|
||||
self._session_info = session_info
|
||||
self._agent_init_task: asyncio.Task | None = None
|
||||
# prevent a race condition where the agent initialization
|
||||
# completes exactly at the moment the user interrupts
|
||||
self._agent_init_interrupted = False
|
||||
self._auto_scroll = True
|
||||
self._last_escape_time: float | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="chat"):
|
||||
@@ -142,7 +146,7 @@ class VibeApp(App):
|
||||
|
||||
with Horizontal(id="loading-area"):
|
||||
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")
|
||||
|
||||
@@ -151,7 +155,7 @@ class VibeApp(App):
|
||||
history_file=self.history_file,
|
||||
command_registry=self.commands,
|
||||
id="input-container",
|
||||
show_warning=self.auto_approve,
|
||||
safety=self._current_agent_mode.safety,
|
||||
)
|
||||
|
||||
with Horizontal(id="bottom-bar"):
|
||||
@@ -184,8 +188,8 @@ class VibeApp(App):
|
||||
await self._show_dangerous_directory_warning()
|
||||
self._schedule_update_notification()
|
||||
|
||||
if self._session_info:
|
||||
await self._mount_and_scroll(AssistantMessage(self._session_info.message()))
|
||||
if self._loaded_messages:
|
||||
await self._rebuild_history_from_messages()
|
||||
|
||||
if self._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:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
|
||||
def _show_todo_area(self) -> None:
|
||||
try:
|
||||
todo_area = self.query_one("#todo-area")
|
||||
todo_area.add_class("loading-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _hide_todo_area(self) -> None:
|
||||
try:
|
||||
todo_area = self.query_one("#todo-area")
|
||||
todo_area.remove_class("loading-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
|
||||
if message.key == "textual_theme":
|
||||
@@ -307,6 +326,7 @@ class VibeApp(App):
|
||||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
await self._mount_and_scroll(UserMessage(user_input))
|
||||
handler = getattr(self, command.handler)
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
@@ -419,11 +439,11 @@ class VibeApp(App):
|
||||
try:
|
||||
agent = Agent(
|
||||
self.config,
|
||||
auto_approve=self.auto_approve,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
)
|
||||
|
||||
if not self.auto_approve:
|
||||
if not self._current_agent_mode.auto_approve:
|
||||
agent.approval_callback = self._approval_callback
|
||||
|
||||
if self._loaded_messages:
|
||||
@@ -450,6 +470,58 @@ class VibeApp(App):
|
||||
self._agent_initializing = False
|
||||
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:
|
||||
if self.agent:
|
||||
self._agent_init_task = None
|
||||
@@ -486,6 +558,7 @@ class VibeApp(App):
|
||||
loading = LoadingWidget()
|
||||
self._loading_widget = loading
|
||||
await loading_area.mount(loading)
|
||||
self._show_todo_area()
|
||||
|
||||
try:
|
||||
rendered_prompt = render_path_prompt(
|
||||
@@ -527,6 +600,7 @@ class VibeApp(App):
|
||||
if self._loading_widget:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
await self._finalize_current_streaming_message()
|
||||
|
||||
async def _interrupt_agent(self) -> None:
|
||||
@@ -563,6 +637,8 @@ class VibeApp(App):
|
||||
self._agent_running = False
|
||||
loading_area = self.query_one("#loading-area-content")
|
||||
await loading_area.remove_children()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
|
||||
await self._finalize_current_streaming_message()
|
||||
await self._mount_and_scroll(InterruptMessage())
|
||||
@@ -603,7 +679,7 @@ class VibeApp(App):
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
new_config = VibeConfig.load()
|
||||
new_config = VibeConfig.load(**self._current_agent_mode.config_overrides)
|
||||
|
||||
if self.agent:
|
||||
await self.agent.reload_with_initial_messages(config=new_config)
|
||||
@@ -656,6 +732,7 @@ class VibeApp(App):
|
||||
max_tokens=current_state.max_tokens,
|
||||
current_tokens=self.agent.stats.context_tokens,
|
||||
)
|
||||
await messages_area.mount(UserMessage("/clear"))
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Conversation history cleared!")
|
||||
)
|
||||
@@ -738,23 +815,68 @@ class VibeApp(App):
|
||||
self.event_handler.current_compact = 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:
|
||||
if not self.agent:
|
||||
return
|
||||
|
||||
await self.agent.compact()
|
||||
new_tokens = self.agent.stats.context_tokens
|
||||
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
|
||||
self.event_handler.current_compact = None
|
||||
|
||||
if self._context_progress:
|
||||
current_state = self._context_progress.tokens
|
||||
self._context_progress.tokens = TokenState(
|
||||
max_tokens=current_state.max_tokens, current_tokens=new_tokens
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
compact_msg.set_error("Compaction interrupted")
|
||||
raise
|
||||
except Exception as e:
|
||||
compact_msg.set_error(str(e))
|
||||
self.event_handler.current_compact = None
|
||||
finally:
|
||||
self._agent_running = False
|
||||
self._agent_task = None
|
||||
if self.event_handler:
|
||||
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:
|
||||
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:
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
@@ -833,7 +955,7 @@ class VibeApp(App):
|
||||
history_file=self.history_file,
|
||||
command_registry=self.commands,
|
||||
id="input-container",
|
||||
show_warning=self.auto_approve,
|
||||
safety=self._current_agent_mode.safety,
|
||||
)
|
||||
await bottom_container.mount(chat_input_container)
|
||||
self._chat_input_container = chat_input_container
|
||||
@@ -857,12 +979,15 @@ class VibeApp(App):
|
||||
pass
|
||||
|
||||
def action_interrupt(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
try:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
config_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Approval:
|
||||
@@ -871,8 +996,23 @@ class VibeApp(App):
|
||||
approval_app.action_reject()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
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(
|
||||
msg.has_class("pending") for msg in self.query(UserMessage)
|
||||
)
|
||||
@@ -886,71 +1026,72 @@ class VibeApp(App):
|
||||
if interrupt_needed:
|
||||
self.run_worker(self._interrupt_agent(), exclusive=False)
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self._scroll_to_bottom()
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
async def action_toggle_tool(self) -> None:
|
||||
if not self.event_handler:
|
||||
return
|
||||
|
||||
self._tools_collapsed = not self._tools_collapsed
|
||||
|
||||
non_todo_results = [
|
||||
result
|
||||
for result in self.event_handler.tool_results
|
||||
if result.event.tool_name != "todo"
|
||||
]
|
||||
|
||||
for result in non_todo_results:
|
||||
result.collapsed = self._tools_collapsed
|
||||
await result.render_result()
|
||||
for result in self.query(ToolResultMessage):
|
||||
if result.tool_name != "todo":
|
||||
await result.set_collapsed(self._tools_collapsed)
|
||||
|
||||
try:
|
||||
error_messages = self.query(ErrorMessage)
|
||||
for error_msg in error_messages:
|
||||
for error_msg in self.query(ErrorMessage):
|
||||
error_msg.set_collapsed(self._tools_collapsed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def action_toggle_todo(self) -> None:
|
||||
if not self.event_handler:
|
||||
return
|
||||
|
||||
self._todos_collapsed = not self._todos_collapsed
|
||||
|
||||
todo_results = [
|
||||
result
|
||||
for result in self.event_handler.tool_results
|
||||
if result.event.tool_name == "todo"
|
||||
]
|
||||
|
||||
for result in todo_results:
|
||||
result.collapsed = self._todos_collapsed
|
||||
await result.render_result()
|
||||
for result in self.query(ToolResultMessage):
|
||||
if result.tool_name == "todo":
|
||||
await result.set_collapsed(self._todos_collapsed)
|
||||
|
||||
def action_cycle_mode(self) -> None:
|
||||
if self._current_bottom_app != BottomApp.Input:
|
||||
return
|
||||
|
||||
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:
|
||||
self._mode_indicator.set_auto_approve(self.auto_approve)
|
||||
|
||||
self._mode_indicator.set_mode(mode)
|
||||
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:
|
||||
self.agent.auto_approve = self.auto_approve
|
||||
|
||||
if self.auto_approve:
|
||||
if mode.auto_approve:
|
||||
self.agent.approval_callback = None
|
||||
else:
|
||||
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()
|
||||
|
||||
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)
|
||||
if input_widgets:
|
||||
input_widget = input_widgets.first()
|
||||
@@ -958,10 +1099,13 @@ class VibeApp(App):
|
||||
input_widget.value = ""
|
||||
return
|
||||
|
||||
self.action_force_quit()
|
||||
|
||||
def action_force_quit(self) -> None:
|
||||
if self._agent_task and not self._agent_task.done():
|
||||
self._agent_task.cancel()
|
||||
|
||||
self.exit()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
def action_scroll_chat_up(self) -> None:
|
||||
try:
|
||||
@@ -984,7 +1128,7 @@ class VibeApp(App):
|
||||
is_dangerous, reason = is_dangerous_directory()
|
||||
if is_dangerous:
|
||||
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))
|
||||
|
||||
@@ -1110,25 +1254,42 @@ class VibeApp(App):
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
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(
|
||||
config: VibeConfig,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
enable_streaming: bool = False,
|
||||
initial_prompt: str | None = None,
|
||||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
) -> None:
|
||||
"""Run the Textual UI."""
|
||||
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
app = VibeApp(
|
||||
config=config,
|
||||
auto_approve=auto_approve,
|
||||
initial_mode=initial_mode,
|
||||
enable_streaming=enable_streaming,
|
||||
initial_prompt=initial_prompt,
|
||||
loaded_messages=loaded_messages,
|
||||
session_info=session_info,
|
||||
version_update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
)
|
||||
app.run()
|
||||
session_id = app.run()
|
||||
_print_session_resume_message(session_id)
|
||||
|
||||
@@ -6,14 +6,14 @@ Screen {
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
padding: 0 2 0 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#loading-area {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
padding: 1 2 0 2;
|
||||
padding: 1 0 0 0;
|
||||
layout: horizontal;
|
||||
align: left middle;
|
||||
}
|
||||
@@ -26,14 +26,28 @@ Screen {
|
||||
|
||||
#todo-area {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
max-height: 10;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
margin-top: 0;
|
||||
|
||||
.tool-result-border {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.loading-active .tool-result-border {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tool-result-content {
|
||||
max-height: 9;
|
||||
overflow-y: auto;
|
||||
scrollbar-visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
#bottom-app-container {
|
||||
@@ -46,7 +60,7 @@ Screen {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
padding: 0 2 1 2;
|
||||
padding: 0 0 1 0;
|
||||
align: left middle;
|
||||
layout: horizontal;
|
||||
}
|
||||
@@ -71,7 +85,7 @@ Screen {
|
||||
width: 100%;
|
||||
background: $background;
|
||||
padding: 0;
|
||||
margin: 0 2 1 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#completion-popup {
|
||||
@@ -84,11 +98,23 @@ Screen {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
border: round $foreground-muted;
|
||||
border-top: solid $foreground-muted;
|
||||
border-bottom: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
|
||||
&.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;
|
||||
border: none;
|
||||
padding: 0;
|
||||
scrollbar-visibility: hidden;
|
||||
}
|
||||
|
||||
ToastRack {
|
||||
align: left bottom;
|
||||
padding: 0 2;
|
||||
margin: 0 2 6 2;
|
||||
padding: 0;
|
||||
margin: 0 0 6 0;
|
||||
}
|
||||
|
||||
Markdown MarkdownFence {
|
||||
@@ -128,8 +155,10 @@ Markdown MarkdownFence {
|
||||
|
||||
.user-message {
|
||||
margin-top: 1;
|
||||
padding: 1 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: $surface;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
@@ -194,25 +223,135 @@ Markdown MarkdownFence {
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-message-content Markdown > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.interrupt-message {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
margin-left: 2;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
background: $warning 10%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.interrupt-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.interrupt-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.interrupt-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: $text-warning;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
background: $error 10%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.error-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: $error;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.warning-message {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.warning-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.warning-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: $text-warning;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 1;
|
||||
width: 100%;
|
||||
@@ -280,7 +419,7 @@ Markdown MarkdownFence {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
BlinkingMessage {
|
||||
StatusMessage {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
@@ -290,10 +429,11 @@ BlinkingMessage {
|
||||
}
|
||||
}
|
||||
|
||||
.blink-dot {
|
||||
.status-indicator-icon {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
margin-right: 1;
|
||||
|
||||
&.success {
|
||||
color: $text-success;
|
||||
@@ -304,7 +444,7 @@ BlinkingMessage {
|
||||
}
|
||||
}
|
||||
|
||||
.blink-text {
|
||||
.status-indicator-text {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
@@ -326,22 +466,40 @@ BlinkingMessage {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 0;
|
||||
margin-left: 2;
|
||||
padding: 1 2;
|
||||
background: $surface;
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: $foreground;
|
||||
|
||||
&.error-text {
|
||||
background: $error 10%;
|
||||
color: $text-error;
|
||||
}
|
||||
|
||||
&.warning-text {
|
||||
background: $warning 10%;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -430,7 +588,6 @@ BlinkingMessage {
|
||||
|
||||
#todo-area .tool-result {
|
||||
margin-left: 0;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
.loading-widget {
|
||||
@@ -443,10 +600,11 @@ BlinkingMessage {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.loading-star {
|
||||
.loading-indicator {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: $warning;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
.loading-status {
|
||||
@@ -478,8 +636,8 @@ WelcomeBanner {
|
||||
border-title-align: center;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
padding: 2 4;
|
||||
margin: 1 1 0 1;
|
||||
padding: 2 0;
|
||||
margin: 1 0 0 0;
|
||||
color: $foreground;
|
||||
|
||||
.muted {
|
||||
@@ -493,7 +651,7 @@ WelcomeBanner {
|
||||
background: $background;
|
||||
border: round $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0 2 1 2;
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
|
||||
#config-content {
|
||||
@@ -556,7 +714,7 @@ WelcomeBanner {
|
||||
background: $background;
|
||||
border: round $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0 2 1 2;
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
|
||||
#approval-content {
|
||||
@@ -645,21 +803,35 @@ Horizontal {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
ExpandingBorder {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
content-align: left bottom;
|
||||
}
|
||||
|
||||
ModeIndicator {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0 0 0 1;
|
||||
color: $warning;
|
||||
color: $text-muted;
|
||||
align: left middle;
|
||||
|
||||
&.mode-on {
|
||||
&.mode-safe {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.mode-neutral {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
&.mode-destructive {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.mode-off {
|
||||
color: $text-muted;
|
||||
&.mode-yolo {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,12 +146,12 @@ class EventHandler:
|
||||
|
||||
def stop_current_tool_call(self) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.stop_blinking()
|
||||
self.current_tool_call.stop_spinning()
|
||||
self.current_tool_call = None
|
||||
|
||||
def stop_current_compact(self) -> None:
|
||||
if self.current_compact:
|
||||
self.current_compact.stop_blinking(success=False)
|
||||
self.current_compact.stop_spinning(success=False)
|
||||
self.current_compact = None
|
||||
|
||||
def get_last_tool_result(self) -> ToolResultMessage | None:
|
||||
|
||||
@@ -110,7 +110,7 @@ class ApprovalApp(Container):
|
||||
def _update_options(self) -> None:
|
||||
options = [
|
||||
("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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -11,7 +11,7 @@ from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
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):
|
||||
@@ -44,26 +44,35 @@ class ChatInputBody(Widget):
|
||||
if self.input_widget:
|
||||
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:
|
||||
if not self.input_widget or not self.prompt_widget:
|
||||
return
|
||||
|
||||
text = self.input_widget.text
|
||||
if text.startswith("!"):
|
||||
self.prompt_widget.update("!")
|
||||
elif text.startswith("/"):
|
||||
self.prompt_widget.update("/")
|
||||
else:
|
||||
self.prompt_widget.update(">")
|
||||
self.prompt_widget.update(self.input_widget.input_mode)
|
||||
|
||||
def on_chat_text_area_mode_changed(self, event: ChatTextArea.ModeChanged) -> None:
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.update(event.mode)
|
||||
|
||||
def _load_history_entry(self, text: str, cursor_col: int | None = None) -> None:
|
||||
if not self.input_widget:
|
||||
return
|
||||
|
||||
self.input_widget._navigating_history = True
|
||||
self.input_widget.load_text(text)
|
||||
mode, display_text = self._parse_mode_and_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)
|
||||
cursor_pos = (0, col)
|
||||
|
||||
@@ -137,9 +146,6 @@ class ChatInputBody(Widget):
|
||||
self.input_widget._cursor_pos_after_load = None
|
||||
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:
|
||||
event.stop()
|
||||
|
||||
@@ -161,12 +167,16 @@ class ChatInputBody(Widget):
|
||||
|
||||
@property
|
||||
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
|
||||
def value(self, text: str) -> None:
|
||||
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()
|
||||
|
||||
def focus_input(self) -> None:
|
||||
|
||||
@@ -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.text_area import ChatTextArea
|
||||
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):
|
||||
ID_INPUT_BOX = "input-box"
|
||||
BORDER_WARNING_CLASS = "border-warning"
|
||||
|
||||
class Submitted(Message):
|
||||
def __init__(self, value: str) -> None:
|
||||
@@ -32,13 +38,13 @@ class ChatInputContainer(Vertical):
|
||||
self,
|
||||
history_file: Path | None = None,
|
||||
command_registry: CommandRegistry | None = None,
|
||||
show_warning: bool = False,
|
||||
safety: ModeSafety = ModeSafety.NEUTRAL,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._history_file = history_file
|
||||
self._command_registry = command_registry or CommandRegistry()
|
||||
self._show_warning = show_warning
|
||||
self._safety = safety
|
||||
|
||||
command_entries = [
|
||||
(alias, command.description)
|
||||
@@ -57,9 +63,8 @@ class ChatInputContainer(Vertical):
|
||||
self._completion_popup = CompletionPopup()
|
||||
yield self._completion_popup
|
||||
|
||||
with Vertical(
|
||||
id=self.ID_INPUT_BOX, classes="border-warning" if self._show_warning else ""
|
||||
):
|
||||
border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
|
||||
with Vertical(id=self.ID_INPUT_BOX, classes=border_class):
|
||||
self._body = ChatInputBody(history_file=self._history_file, id="input-body")
|
||||
|
||||
yield self._body
|
||||
@@ -91,7 +96,7 @@ class ChatInputContainer(Vertical):
|
||||
widget = self._body.input_widget
|
||||
if widget:
|
||||
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:
|
||||
@@ -131,6 +136,9 @@ class ChatInputContainer(Vertical):
|
||||
widget = self.input_widget
|
||||
if not widget or not self._body:
|
||||
return
|
||||
start, end, replacement = widget.adjust_from_full_text_coords(
|
||||
start, end, replacement
|
||||
)
|
||||
|
||||
text = widget.text
|
||||
start = max(0, min(start, len(text)))
|
||||
@@ -147,11 +155,12 @@ class ChatInputContainer(Vertical):
|
||||
event.stop()
|
||||
self.post_message(self.Submitted(event.value))
|
||||
|
||||
def set_show_warning(self, show_warning: bool) -> None:
|
||||
self._show_warning = show_warning
|
||||
def set_safety(self, safety: ModeSafety) -> None:
|
||||
self._safety = safety
|
||||
|
||||
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
|
||||
if show_warning:
|
||||
input_box.add_class(self.BORDER_WARNING_CLASS)
|
||||
else:
|
||||
input_box.remove_class(self.BORDER_WARNING_CLASS)
|
||||
for border_class in SAFETY_BORDER_CLASSES.values():
|
||||
input_box.remove_class(border_class)
|
||||
|
||||
if safety in SAFETY_BORDER_CLASSES:
|
||||
input_box.add_class(SAFETY_BORDER_CLASSES[safety])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from textual import events
|
||||
from textual.binding import Binding
|
||||
@@ -12,6 +12,8 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
||||
MultiCompletionManager,
|
||||
)
|
||||
|
||||
InputMode = Literal["!", "/", ">"]
|
||||
|
||||
|
||||
class ChatTextArea(TextArea):
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
@@ -24,6 +26,9 @@ class ChatTextArea(TextArea):
|
||||
)
|
||||
]
|
||||
|
||||
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
|
||||
DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
|
||||
|
||||
class Submitted(Message):
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
@@ -42,8 +47,16 @@ class ChatTextArea(TextArea):
|
||||
class HistoryReset(Message):
|
||||
"""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:
|
||||
super().__init__(**kwargs)
|
||||
self._input_mode: InputMode = self.DEFAULT_MODE
|
||||
self._history_prefix: str | None = None
|
||||
self._last_text = ""
|
||||
self._navigating_history = False
|
||||
@@ -53,9 +66,17 @@ class ChatTextArea(TextArea):
|
||||
self._cursor_pos_after_load: tuple[int, int] | None = None
|
||||
self._cursor_moved_since_load: bool = False
|
||||
self._completion_manager: MultiCompletionManager | None = None
|
||||
self._app_has_focus: bool = True
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
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)
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
self._mark_cursor_moved_if_needed()
|
||||
@@ -76,7 +97,7 @@ class ChatTextArea(TextArea):
|
||||
|
||||
if self._completion_manager and not was_navigating_history:
|
||||
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:
|
||||
@@ -96,7 +117,10 @@ class ChatTextArea(TextArea):
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
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 ""
|
||||
|
||||
def _handle_history_up(self) -> bool:
|
||||
@@ -139,12 +163,14 @@ class ChatTextArea(TextArea):
|
||||
self.post_message(self.HistoryNext(self._history_prefix))
|
||||
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()
|
||||
|
||||
manager = self._completion_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:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
@@ -152,7 +178,7 @@ class ChatTextArea(TextArea):
|
||||
case CompletionResult.SUBMIT:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
value = self.text.strip()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
@@ -161,7 +187,7 @@ class ChatTextArea(TextArea):
|
||||
if event.key == "enter":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
value = self.text.strip()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
@@ -172,6 +198,23 @@ class ChatTextArea(TextArea):
|
||||
event.stop()
|
||||
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():
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
@@ -189,7 +232,7 @@ class ChatTextArea(TextArea):
|
||||
self._completion_manager = manager
|
||||
if self._completion_manager:
|
||||
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:
|
||||
@@ -244,3 +287,59 @@ class ChatTextArea(TextArea):
|
||||
def clear_text(self) -> None:
|
||||
self.clear()
|
||||
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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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:
|
||||
super().__init__()
|
||||
self.add_class("compact-message")
|
||||
@@ -12,7 +12,7 @@ class CompactMessage(BlinkingMessage):
|
||||
self.error_message: str | None = None
|
||||
|
||||
def get_content(self) -> str:
|
||||
if self._is_blinking:
|
||||
if self._is_spinning:
|
||||
return "Compacting conversation history..."
|
||||
|
||||
if self.error_message:
|
||||
@@ -35,8 +35,8 @@ class CompactMessage(BlinkingMessage):
|
||||
) -> None:
|
||||
self.old_tokens = old_tokens
|
||||
self.new_tokens = new_tokens
|
||||
self.stop_blinking(success=True)
|
||||
self.stop_spinning(success=True)
|
||||
|
||||
def set_error(self, error_message: str) -> None:
|
||||
self.error_message = error_message
|
||||
self.stop_blinking(success=False)
|
||||
self.stop_spinning(success=False)
|
||||
|
||||
@@ -9,10 +9,10 @@ from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.spinner import BrailleSpinner
|
||||
|
||||
|
||||
class LoadingWidget(Static):
|
||||
BRAILLE_SPINNER = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||||
|
||||
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
|
||||
|
||||
EASTER_EGGS: ClassVar[list[str]] = [
|
||||
@@ -51,8 +51,9 @@ class LoadingWidget(Static):
|
||||
def __init__(self, status: str | None = None) -> None:
|
||||
super().__init__(classes="loading-widget")
|
||||
self.status = status or self._get_default_status()
|
||||
self.gradient_offset = 0
|
||||
self.spinner_pos = 0
|
||||
self.current_color_index = 0
|
||||
self.transition_progress = 0
|
||||
self._spinner = BrailleSpinner()
|
||||
self.char_widgets: list[Static] = []
|
||||
self.spinner_widget: Static | None = None
|
||||
self.ellipsis_widget: Static | None = None
|
||||
@@ -84,13 +85,12 @@ class LoadingWidget(Static):
|
||||
|
||||
def set_status(self, status: str) -> None:
|
||||
self.status = self._apply_easter_egg(status)
|
||||
self.gradient_offset = 0
|
||||
self._rebuild_chars()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="loading-container"):
|
||||
self.spinner_widget = Static(
|
||||
self.BRAILLE_SPINNER[0] + " ", classes="loading-star"
|
||||
self._spinner.current_frame(), classes="loading-indicator"
|
||||
)
|
||||
yield self.spinner_widget
|
||||
|
||||
@@ -127,30 +127,40 @@ class LoadingWidget(Static):
|
||||
self.update_animation()
|
||||
self.set_interval(0.1, self.update_animation)
|
||||
|
||||
def _get_gradient_color(self, position: int) -> str:
|
||||
color_index = (position - self.gradient_offset) % len(self.TARGET_COLORS)
|
||||
return self.TARGET_COLORS[color_index]
|
||||
def _get_color_for_position(self, position: int) -> str:
|
||||
current_color = self.TARGET_COLORS[self.current_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:
|
||||
total_elements = 1 + len(self.char_widgets) + 2
|
||||
|
||||
if self.spinner_widget:
|
||||
spinner_char = self.BRAILLE_SPINNER[self.spinner_pos]
|
||||
color_0 = self._get_gradient_color(0)
|
||||
color_1 = self._get_gradient_color(1)
|
||||
self.spinner_widget.update(f"[{color_0}]{spinner_char}[/][{color_1}] [/]")
|
||||
self.spinner_pos = (self.spinner_pos + 1) % len(self.BRAILLE_SPINNER)
|
||||
spinner_char = self._spinner.next_frame()
|
||||
color = self._get_color_for_position(0)
|
||||
self.spinner_widget.update(f"[{color}]{spinner_char}[/]")
|
||||
|
||||
for i, widget in enumerate(self.char_widgets):
|
||||
position = 2 + i
|
||||
color = self._get_gradient_color(position)
|
||||
position = 1 + i
|
||||
color = self._get_color_for_position(position)
|
||||
widget.update(f"[{color}]{self.status[i]}[/]")
|
||||
|
||||
if self.ellipsis_widget:
|
||||
ellipsis_start = 2 + len(self.status)
|
||||
color_ellipsis = self._get_gradient_color(ellipsis_start)
|
||||
color_space = self._get_gradient_color(ellipsis_start + 1)
|
||||
ellipsis_start = 1 + len(self.status)
|
||||
color_ellipsis = self._get_color_for_position(ellipsis_start)
|
||||
color_space = self._get_color_for_position(ellipsis_start + 1)
|
||||
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:
|
||||
elapsed = int(time() - self.start_time)
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Markdown, Static
|
||||
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):
|
||||
def __init__(self, content: str, pending: bool = False) -> None:
|
||||
super().__init__()
|
||||
@@ -15,7 +39,7 @@ class UserMessage(Static):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
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")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
@@ -43,7 +67,7 @@ class AssistantMessage(Static):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="assistant-message-container"):
|
||||
yield Static("● ", classes="assistant-message-dot")
|
||||
yield NonSelectableStatic("● ", classes="assistant-message-dot")
|
||||
with Vertical(classes="assistant-message-content"):
|
||||
markdown = Markdown("")
|
||||
self._markdown = markdown
|
||||
@@ -87,14 +111,25 @@ class UserCommandMessage(Static):
|
||||
self._content = content
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Markdown(self._content)
|
||||
with Horizontal(classes="user-command-container"):
|
||||
yield ExpandingBorder(classes="user-command-border")
|
||||
with Vertical(classes="user-command-content"):
|
||||
yield Markdown(self._content)
|
||||
|
||||
|
||||
class InterruptMessage(Static):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"Interrupted · What should Vibe do instead?", classes="interrupt-message"
|
||||
)
|
||||
super().__init__()
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
class BashOutputMessage(Static):
|
||||
@@ -125,24 +160,41 @@ class BashOutputMessage(Static):
|
||||
|
||||
class ErrorMessage(Static):
|
||||
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.collapsed = collapsed
|
||||
self._content_widget: Static | None = None
|
||||
|
||||
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:
|
||||
yield Static("Error. (ctrl+o to expand)", markup=False)
|
||||
else:
|
||||
yield Static(f"Error: {self._error}", markup=False)
|
||||
return "Error. (ctrl+o to expand)"
|
||||
return f"Error: {self._error}"
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
if self.collapsed == collapsed:
|
||||
return
|
||||
|
||||
self.collapsed = collapsed
|
||||
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))
|
||||
else:
|
||||
self.mount(Static(f"Error: {self._error}", markup=False))
|
||||
|
||||
class WarningMessage(Static):
|
||||
def __init__(self, message: str) -> None:
|
||||
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")
|
||||
|
||||
@@ -2,24 +2,46 @@ from __future__ import annotations
|
||||
|
||||
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):
|
||||
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__()
|
||||
self.can_focus = False
|
||||
self._auto_approve = auto_approve
|
||||
self._mode = mode
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
if self._auto_approve:
|
||||
self.update("⏵⏵ auto-approve on (shift+tab to toggle)")
|
||||
self.add_class("mode-on")
|
||||
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")
|
||||
icon = MODE_ICONS.get(self._mode, "??")
|
||||
name = self._mode.display_name.lower()
|
||||
self.update(f"{icon} {name} mode (shift+tab to cycle)")
|
||||
|
||||
def set_auto_approve(self, enabled: bool) -> None:
|
||||
self._auto_approve = enabled
|
||||
for safety_class in SAFETY_CLASSES.values():
|
||||
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()
|
||||
|
||||
87
vibe/cli/textual_ui/widgets/spinner.py
Normal 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()
|
||||
75
vibe/cli/textual_ui/widgets/status_message.py
Normal 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()
|
||||
@@ -4,6 +4,8 @@ from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
||||
|
||||
|
||||
class ToolApprovalWidget(Vertical):
|
||||
def __init__(self, data: dict) -> None:
|
||||
@@ -27,17 +29,23 @@ class ToolApprovalWidget(Vertical):
|
||||
|
||||
|
||||
class ToolResultWidget(Static):
|
||||
SHORTCUT = DEFAULT_TOOL_SHORTCUT
|
||||
|
||||
def __init__(self, data: dict, collapsed: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.data = data
|
||||
self.collapsed = collapsed
|
||||
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:
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
@@ -66,7 +74,7 @@ class BashResultWidget(ToolResultWidget):
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
@@ -96,7 +104,7 @@ class WriteFileResultWidget(ToolResultWidget):
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
@@ -158,7 +166,7 @@ class SearchReplaceResultWidget(ToolResultWidget):
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
@@ -185,13 +193,15 @@ class TodoApprovalWidget(ToolApprovalWidget):
|
||||
|
||||
|
||||
class TodoResultWidget(ToolResultWidget):
|
||||
SHORTCUT = TOOL_SHORTCUTS["todo"]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(message, markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
yield Static("")
|
||||
|
||||
by_status = self.data.get("todos_by_status", {})
|
||||
@@ -228,7 +238,7 @@ class ReadFileResultWidget(ToolResultWidget):
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
@@ -277,7 +287,7 @@ class GrepResultWidget(ToolResultWidget):
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
|
||||
@@ -1,86 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Static
|
||||
|
||||
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.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class ToolCallMessage(BlinkingMessage):
|
||||
def __init__(self, event: ToolCallEvent) -> None:
|
||||
self.event = event
|
||||
class ToolCallMessage(StatusMessage):
|
||||
def __init__(
|
||||
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__()
|
||||
self.add_class("tool-call")
|
||||
|
||||
if self._is_history:
|
||||
self._is_spinning = False
|
||||
|
||||
def get_content(self) -> str:
|
||||
if not self.event.tool_class:
|
||||
return f"{self.event.tool_name}"
|
||||
|
||||
adapter = ToolUIDataAdapter(self.event.tool_class)
|
||||
display = adapter.get_call_display(self.event)
|
||||
|
||||
return f"{display.summary}"
|
||||
if self._event and self._event.tool_class:
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
display = adapter.get_call_display(self._event)
|
||||
return display.summary
|
||||
return self._tool_name
|
||||
|
||||
|
||||
class ToolResultMessage(Static):
|
||||
def __init__(
|
||||
self,
|
||||
event: ToolResultEvent,
|
||||
event: ToolResultEvent | None = None,
|
||||
call_widget: ToolCallMessage | None = None,
|
||||
collapsed: bool = True,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
content: str | None = None,
|
||||
) -> None:
|
||||
self.event = event
|
||||
self.call_widget = call_widget
|
||||
if event is None and tool_name is None:
|
||||
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._content_container: Vertical | None = None
|
||||
|
||||
super().__init__()
|
||||
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:
|
||||
if self.call_widget:
|
||||
success = not self.event.error and not self.event.skipped
|
||||
self.call_widget.stop_blinking(success=success)
|
||||
await self.render_result()
|
||||
if self._call_widget:
|
||||
success = self._event is None or (
|
||||
not self._event.error and not self._event.skipped
|
||||
)
|
||||
self._call_widget.stop_spinning(success=success)
|
||||
await self._render_result()
|
||||
|
||||
async def render_result(self) -> None:
|
||||
await self.remove_children()
|
||||
|
||||
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))
|
||||
async def _render_result(self) -> None:
|
||||
if self._content_container is None:
|
||||
return
|
||||
|
||||
if self.event.skipped:
|
||||
self.add_class("warning-text")
|
||||
reason = self.event.skip_reason or "User skipped"
|
||||
await self._content_container.remove_children()
|
||||
|
||||
if self._event is None:
|
||||
await self._render_simple()
|
||||
return
|
||||
|
||||
if self._event.error:
|
||||
self.add_class("error-text")
|
||||
if self.collapsed:
|
||||
self.update("Skipped. (ctrl+o to expand)")
|
||||
await self._content_container.mount(
|
||||
Static(f"Error. {self._hint()}", markup=False)
|
||||
)
|
||||
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
|
||||
|
||||
self.remove_class("error-text")
|
||||
self.remove_class("warning-text")
|
||||
|
||||
adapter = ToolUIDataAdapter(self.event.tool_class)
|
||||
display = adapter.get_result_display(self.event)
|
||||
if self._event.tool_class is None:
|
||||
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)
|
||||
await self._content_container.mount(
|
||||
widget_class(data, collapsed=self.collapsed)
|
||||
)
|
||||
|
||||
result_widget = widget_class(data, collapsed=self.collapsed)
|
||||
await self.mount(result_widget)
|
||||
async def _render_simple(self) -> None:
|
||||
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:
|
||||
if self.collapsed != collapsed:
|
||||
self.collapsed = collapsed
|
||||
await self.render_result()
|
||||
if self.collapsed == collapsed:
|
||||
return
|
||||
self.collapsed = collapsed
|
||||
await self._render_result()
|
||||
|
||||
async def toggle_collapsed(self) -> None:
|
||||
self.collapsed = not self.collapsed
|
||||
await self.render_result()
|
||||
await self._render_result()
|
||||
|
||||
4
vibe/cli/textual_ui/widgets/utils.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
TOOL_SHORTCUTS = {"todo": "ctrl+t"}
|
||||
DEFAULT_TOOL_SHORTCUT = "ctrl+o"
|
||||
@@ -9,7 +9,7 @@ from rich.text import Text
|
||||
from textual.color import Color
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.core import __version__
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
@@ -104,8 +104,7 @@ class WelcomeBanner(Static):
|
||||
self._static_line5_suffix = (
|
||||
f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]"
|
||||
)
|
||||
block = (self.SPACE * 4) + self.LOGO_TEXT_GAP
|
||||
self._static_line7 = f"{block}[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information[/]"
|
||||
self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"
|
||||
|
||||
@property
|
||||
def skeleton_color(self) -> str:
|
||||
|
||||
@@ -8,7 +8,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
|
||||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
)
|
||||
from vibe.core.config_path import VIBE_HOME
|
||||
from vibe.core.paths.global_paths import VIBE_HOME
|
||||
|
||||
|
||||
class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["__version__", "run_programmatic"]
|
||||
__version__ = "1.1.3"
|
||||
__all__ = ["run_programmatic"]
|
||||
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
|
||||
@@ -22,10 +22,12 @@ from vibe.core.middleware import (
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
MiddlewareResult,
|
||||
PlanModeMiddleware,
|
||||
PriceLimitMiddleware,
|
||||
ResetReason,
|
||||
TurnLimitMiddleware,
|
||||
)
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.system_prompt import get_universal_system_prompt
|
||||
from vibe.core.tools.base import (
|
||||
@@ -88,14 +90,18 @@ class Agent:
|
||||
def __init__(
|
||||
self,
|
||||
config: VibeConfig,
|
||||
auto_approve: bool = False,
|
||||
mode: AgentMode = AgentMode.DEFAULT,
|
||||
message_observer: Callable[[LLMMessage], None] | None = None,
|
||||
max_turns: int | None = None,
|
||||
max_price: float | None = None,
|
||||
backend: BackendLike | None = None,
|
||||
enable_streaming: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the agent with configuration and mode."""
|
||||
self.config = config
|
||||
self._mode = mode
|
||||
self._max_turns = max_turns
|
||||
self._max_price = max_price
|
||||
|
||||
self.tool_manager = ToolManager(config)
|
||||
self.format_handler = APIToolFormatHandler()
|
||||
@@ -107,10 +113,9 @@ class Agent:
|
||||
self._last_observed_message_index: int = 0
|
||||
self.middleware_pipeline = MiddlewarePipeline()
|
||||
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)
|
||||
|
||||
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
|
||||
|
||||
if self.message_observer:
|
||||
@@ -125,7 +130,6 @@ class Agent:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self.auto_approve = auto_approve
|
||||
self.approval_callback: ApprovalCallback | None = None
|
||||
|
||||
self.session_id = str(uuid4())
|
||||
@@ -133,12 +137,20 @@ class Agent:
|
||||
self.interaction_logger = InteractionLogger(
|
||||
config.session_logging,
|
||||
self.session_id,
|
||||
auto_approve,
|
||||
self.auto_approve,
|
||||
config.effective_workdir,
|
||||
)
|
||||
|
||||
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:
|
||||
active_model = self.config.get_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):
|
||||
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()
|
||||
|
||||
if max_turns is not None:
|
||||
self.middleware_pipeline.add(TurnLimitMiddleware(max_turns))
|
||||
if self._max_turns is not None:
|
||||
self.middleware_pipeline.add(TurnLimitMiddleware(self._max_turns))
|
||||
|
||||
if max_price is not None:
|
||||
self.middleware_pipeline.add(PriceLimitMiddleware(max_price))
|
||||
if self._max_price is not None:
|
||||
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
|
||||
|
||||
if self.config.auto_compact_threshold > 0:
|
||||
self.middleware_pipeline.add(
|
||||
@@ -182,6 +195,8 @@ class Agent:
|
||||
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
|
||||
)
|
||||
|
||||
self.middleware_pipeline.add(PlanModeMiddleware(lambda: self._mode))
|
||||
|
||||
async def _handle_middleware_result(
|
||||
self, result: MiddlewareResult
|
||||
) -> AsyncGenerator[BaseEvent]:
|
||||
@@ -191,9 +206,6 @@ class Agent:
|
||||
content=f"<{VIBE_STOP_EVENT_TAG}>{result.reason}</{VIBE_STOP_EVENT_TAG}>",
|
||||
stopped_by_middleware=True,
|
||||
)
|
||||
await self.interaction_logger.save_interaction(
|
||||
self.messages, self.stats, self.config, self.tool_manager
|
||||
)
|
||||
|
||||
case MiddlewareAction.INJECT_MESSAGE:
|
||||
if result.message and len(self.messages) > 0:
|
||||
@@ -241,12 +253,10 @@ class Agent:
|
||||
result = await self.middleware_pipeline.run_before_turn(
|
||||
self._get_context()
|
||||
)
|
||||
|
||||
async for event in self._handle_middleware_result(result):
|
||||
yield event
|
||||
|
||||
if result.action == MiddlewareAction.STOP:
|
||||
self._flush_new_messages()
|
||||
return
|
||||
|
||||
self.stats.steps += 1
|
||||
@@ -264,39 +274,24 @@ class Agent:
|
||||
)
|
||||
|
||||
self._flush_new_messages()
|
||||
await self.interaction_logger.save_interaction(
|
||||
self.messages, self.stats, self.config, self.tool_manager
|
||||
)
|
||||
|
||||
if user_cancelled:
|
||||
self._flush_new_messages()
|
||||
await self.interaction_logger.save_interaction(
|
||||
self.messages, self.stats, self.config, self.tool_manager
|
||||
)
|
||||
return
|
||||
|
||||
after_result = await self.middleware_pipeline.run_after_turn(
|
||||
self._get_context()
|
||||
)
|
||||
|
||||
async for event in self._handle_middleware_result(after_result):
|
||||
yield event
|
||||
|
||||
if after_result.action == MiddlewareAction.STOP:
|
||||
self._flush_new_messages()
|
||||
return
|
||||
|
||||
self._flush_new_messages()
|
||||
await self.interaction_logger.save_interaction(
|
||||
self.messages, self.stats, self.config, self.tool_manager
|
||||
)
|
||||
|
||||
except Exception:
|
||||
finally:
|
||||
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(
|
||||
self,
|
||||
@@ -410,7 +405,7 @@ class Agent:
|
||||
|
||||
return AssistantEvent(content=assistant_msg.content or "")
|
||||
|
||||
async def _handle_tool_calls( # noqa: PLR0915
|
||||
async def _handle_tool_calls(
|
||||
self, resolved: ResolvedMessage
|
||||
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent]:
|
||||
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
|
||||
|
||||
except KeyboardInterrupt:
|
||||
@@ -556,9 +548,6 @@ class Agent:
|
||||
)
|
||||
)
|
||||
)
|
||||
await self.interaction_logger.save_interaction(
|
||||
self.messages, self.stats, self.config, self.tool_manager
|
||||
)
|
||||
raise
|
||||
|
||||
except (ToolError, ToolPermissionError) as exc:
|
||||
@@ -844,6 +833,7 @@ class Agent:
|
||||
self._reset_session()
|
||||
|
||||
async def compact(self) -> str:
|
||||
"""Compact the conversation history."""
|
||||
try:
|
||||
self._clean_message_history()
|
||||
await self.interaction_logger.save_interaction(
|
||||
@@ -906,6 +896,16 @@ class Agent:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
config: VibeConfig | None = None,
|
||||
@@ -917,22 +917,25 @@ class Agent:
|
||||
)
|
||||
|
||||
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:
|
||||
self.config = config
|
||||
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)
|
||||
|
||||
new_system_prompt = get_universal_system_prompt(self.tool_manager, self.config)
|
||||
self.messages = [LLMMessage(role=Role.system, content=new_system_prompt)]
|
||||
did_system_prompt_change = old_system_prompt != new_system_prompt
|
||||
|
||||
if 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()
|
||||
|
||||
try:
|
||||
@@ -945,7 +948,7 @@ class Agent:
|
||||
|
||||
self._last_observed_message_index = 0
|
||||
|
||||
self._setup_middleware(max_turns, max_price)
|
||||
self._setup_middleware()
|
||||
|
||||
if self.message_observer:
|
||||
for msg in self.messages:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
from collections.abc import Iterable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
@@ -40,6 +41,8 @@ class FileIndexer:
|
||||
self._target_root: Path | None = None
|
||||
self._shutdown = False
|
||||
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
@property
|
||||
def stats(self) -> FileIndexStats:
|
||||
return self._stats
|
||||
|
||||
@@ -19,14 +19,8 @@ from pydantic_settings import (
|
||||
)
|
||||
import tomli_w
|
||||
|
||||
from vibe.core.config_path import (
|
||||
AGENT_DIR,
|
||||
CONFIG_DIR,
|
||||
CONFIG_FILE,
|
||||
GLOBAL_ENV_FILE,
|
||||
PROMPT_DIR,
|
||||
SESSION_LOG_DIR,
|
||||
)
|
||||
from vibe.core.paths.config_paths import AGENT_DIR, CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
||||
from vibe.core.prompts import SystemPrompt
|
||||
from vibe.core.tools.base import BaseToolConfig
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum, auto
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.utils import VIBE_WARNING_TAG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -141,6 +143,37 @@ class ContextWarningMiddleware:
|
||||
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:
|
||||
def __init__(self) -> None:
|
||||
self.middlewares: list[ConversationMiddleware] = []
|
||||
@@ -174,18 +207,13 @@ class MiddlewarePipeline:
|
||||
return MiddlewareResult()
|
||||
|
||||
async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
messages_to_inject = []
|
||||
|
||||
for mw in self.middlewares:
|
||||
result = await mw.after_turn(context)
|
||||
if result.action == MiddlewareAction.INJECT_MESSAGE and result.message:
|
||||
messages_to_inject.append(result.message)
|
||||
elif result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
|
||||
if result.action == MiddlewareAction.INJECT_MESSAGE:
|
||||
raise ValueError(
|
||||
f"INJECT_MESSAGE not allowed in after_turn (from {type(mw).__name__})"
|
||||
)
|
||||
if result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
|
||||
return result
|
||||
if messages_to_inject:
|
||||
combined_message = "\n\n".join(messages_to_inject)
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=combined_message
|
||||
)
|
||||
|
||||
return MiddlewareResult()
|
||||
|
||||
108
vibe/core/modes.py
Normal 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)]
|
||||
0
vibe/core/paths/__init__.py
Normal file
50
vibe/core/paths/config_paths.py
Normal 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"))
|
||||
37
vibe/core/paths/global_paths.py
Normal 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")
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.output_formatters import create_formatter
|
||||
from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException, logger
|
||||
@@ -16,7 +17,7 @@ def run_programmatic(
|
||||
max_price: float | None = None,
|
||||
output_format: OutputFormat = OutputFormat.TEXT,
|
||||
previous_messages: list[LLMMessage] | None = None,
|
||||
auto_approve: bool = True,
|
||||
mode: AgentMode = AgentMode.AUTO_APPROVE,
|
||||
) -> str | None:
|
||||
"""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
|
||||
output_format: Format for the output
|
||||
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:
|
||||
The final assistant response text, or None if no response
|
||||
@@ -36,7 +37,7 @@ def run_programmatic(
|
||||
|
||||
agent = Agent(
|
||||
config,
|
||||
auto_approve=auto_approve,
|
||||
mode=mode,
|
||||
message_observer=formatter.on_message_added,
|
||||
max_turns=max_turns,
|
||||
max_price=max_price,
|
||||
|
||||
@@ -10,8 +10,8 @@ import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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.paths.config_paths import INSTRUCTIONS_FILE
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.utils import is_dangerous_directory, is_windows
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ class SearchReplace(
|
||||
if isinstance(event.result, SearchReplaceResult):
|
||||
return ToolResultDisplay(
|
||||
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,
|
||||
details={
|
||||
"lines_changed": event.result.lines_changed,
|
||||
|
||||
@@ -9,8 +9,8 @@ import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.core.config_path import GLOBAL_TOOLS_DIR, resolve_local_tools_dir
|
||||
from vibe.core.paths.config_paths import 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.mcp import (
|
||||
RemoteTool,
|
||||
@@ -19,6 +19,7 @@ from vibe.core.tools.mcp import (
|
||||
list_tools_http,
|
||||
list_tools_stdio,
|
||||
)
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.core.utils import run_sync
|
||||
|
||||
logger = getLogger("vibe")
|
||||
@@ -31,9 +32,6 @@ class NoSuchToolError(Exception):
|
||||
"""Exception raised when a tool is not found."""
|
||||
|
||||
|
||||
DEFAULT_TOOL_DIR = VIBE_ROOT / "core" / "tools" / "builtins"
|
||||
|
||||
|
||||
class ToolManager:
|
||||
"""Manages tool discovery and instantiation for an Agent.
|
||||
|
||||
@@ -53,14 +51,19 @@ class ToolManager:
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
path = Path(p).expanduser().resolve()
|
||||
if path.is_dir():
|
||||
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)
|
||||
|
||||
if GLOBAL_TOOLS_DIR.path.is_dir():
|
||||
@@ -115,7 +118,7 @@ class ToolManager:
|
||||
search_paths: list[Path] | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
if search_paths is None:
|
||||
search_paths = [DEFAULT_TOOL_DIR]
|
||||
search_paths = [DEFAULT_TOOL_DIR.path]
|
||||
|
||||
defaults: dict[str, dict[str, Any]] = {}
|
||||
for cls in ToolManager._iter_tool_classes(search_paths):
|
||||
|
||||
72
vibe/core/trusted_folders.py
Normal 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()
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
@@ -18,22 +17,6 @@ from pydantic import (
|
||||
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):
|
||||
steps: int = 0
|
||||
session_prompt_tokens: int = 0
|
||||
|
||||
@@ -13,9 +13,9 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe.core import __version__
|
||||
from vibe import __version__
|
||||
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
|
||||
|
||||
CANCELLATION_TAG = "user_cancellation"
|
||||
@@ -144,13 +144,6 @@ logging.basicConfig(
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@@ -5,7 +5,7 @@ import sys
|
||||
from rich import print as rprint
|
||||
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 (
|
||||
ApiKeyScreen,
|
||||
ThemeSelectionScreen,
|
||||
|
||||
@@ -13,7 +13,7 @@ from textual.widgets import Input, Link, Static
|
||||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
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
|
||||
|
||||
PROVIDER_HELP = {
|
||||
|
||||
187
vibe/setup/trusted_folders/trust_folder_dialog.py
Normal 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()
|
||||
83
vibe/setup/trusted_folders/trust_folder_dialog.tcss
Normal 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;
|
||||
}
|
||||