v1.3.0
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai> Co-Authored-by: Thiago Padilha <thiago@coplane.com>
This commit is contained in:
parent
2e1e15120d
commit
078693fc64
67 changed files with 3959 additions and 819 deletions
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
name: Bug Report
|
||||||
|
description: Tell us about a defect in Mistral Vibe
|
||||||
|
title: "bug: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: >
|
||||||
|
Thanks for filing a detailed bug. Fill out every section so we can
|
||||||
|
reproduce the issue quickly
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which part of Mistral Vibe is affected?
|
||||||
|
options:
|
||||||
|
- CLI
|
||||||
|
- ACP
|
||||||
|
- Both
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: What went wrong? Keep it short but specific.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Reproduction steps
|
||||||
|
description: >
|
||||||
|
Commands, code, or payloads that trigger the bug. Include any relevant
|
||||||
|
input files or snippets (redact secrets).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: versions
|
||||||
|
attributes:
|
||||||
|
label: Versions / environment
|
||||||
|
description: >
|
||||||
|
Include Mistral Vibe version, uv or pip version and OS.
|
||||||
|
placeholder: "mistral-vibe 1.2.0, uv 0.9.0 on macOS 15.7"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs & screenshots
|
||||||
|
description: Paste relevant stack traces, JSON snippets, or console output.
|
||||||
|
render: shell
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Ask a question
|
||||||
|
url: https://discord.com/channels/1144547040454508606/1447989080720670915
|
||||||
|
about: Join Mistral AI Discord server for support and discussions
|
||||||
|
- name: Read the docs
|
||||||
|
url: https://docs.mistral.ai/mistral-vibe/introduction
|
||||||
|
about: Read the Mistral Vibe documentation for more information
|
||||||
47
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
name: Feature Request
|
||||||
|
description: Pitch an improvement for Mistral Vibe
|
||||||
|
title: "feat: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: >
|
||||||
|
Feature requests work best when they focus on the problem first. Tell us
|
||||||
|
what you're trying to achieve and why existing APIs aren't enough.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which part of Mistral Vibe would this feature apply to?
|
||||||
|
options:
|
||||||
|
- CLI
|
||||||
|
- ACP
|
||||||
|
- Both
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem statement
|
||||||
|
description: >
|
||||||
|
What use case is blocked? Provide a clear and concise description of the problem.
|
||||||
|
placeholder: "I want to be able to use the agent to write a blog post about the benefits of using Mistral Vibe"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: >
|
||||||
|
Sketch the feature you’d like to see. Code snippets welcome.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Links, screenshots, related issues, etc.
|
||||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Release to Pipy
|
name: Release to Pipy and Zed's store
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
|
|
@ -6,7 +6,8 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release-pypy:
|
||||||
|
name: Release to Pypi
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
environment:
|
||||||
name: pypi
|
name: pypi
|
||||||
|
|
@ -42,3 +43,17 @@ jobs:
|
||||||
- name: Publish distribution to PyPI
|
- name: Publish distribution to PyPI
|
||||||
if: github.repository == 'mistralai/mistral-vibe'
|
if: github.repository == 'mistralai/mistral-vibe'
|
||||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||||
|
|
||||||
|
release-zed-extension:
|
||||||
|
name: Release Zed Extension
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'mistralai/mistral-vibe'
|
||||||
|
environment:
|
||||||
|
name: zed
|
||||||
|
steps:
|
||||||
|
- uses: huacnlee/zed-extension-action@8cd592a0d24e1e41157740f1a529aeabddc88a1b # v2
|
||||||
|
with:
|
||||||
|
extension-name: mistral-vibe
|
||||||
|
push-to: mistralai/zed-extensions
|
||||||
|
env:
|
||||||
|
COMMITTER_TOKEN: ${{ secrets.ZED_EXTENSION_GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
9
.vscode/launch.json
vendored
9
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "1.2.2",
|
"version": "1.3.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "ACP Server",
|
"name": "ACP Server",
|
||||||
|
|
@ -46,6 +46,13 @@
|
||||||
"args": [],
|
"args": [],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
"justMyCode": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attach using PID",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "attach",
|
||||||
|
"processId": "${command:pickProcess}",
|
||||||
|
"justMyCode": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"inputs": [
|
"inputs": [
|
||||||
|
|
|
||||||
22
CHANGELOG.md
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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.3.0] - 2025-12-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- agentskills.io support
|
||||||
|
- Reasoning support
|
||||||
|
- Native terminal theme support
|
||||||
|
- Issue templates for bug reports and feature requests
|
||||||
|
- Auto update zed extension on release creation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improve ToolUI system with better rendering and organization
|
||||||
|
- Use pinned actions in CI workflows
|
||||||
|
- Remove 100k -> 200k tokens config migration
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix `-p` mode to auto-approve tool calls
|
||||||
|
- Fix crash when switching mode
|
||||||
|
- Fix some cases where clipboard copy didn't work
|
||||||
|
|
||||||
## [1.2.2] - 2025-12-22
|
## [1.2.2] - 2025-12-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "1.2.2"
|
version = "1.3.0"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.2/vibe-acp-darwin-aarch64-1.2.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.0/vibe-acp-darwin-aarch64-1.3.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.2/vibe-acp-darwin-x86_64-1.2.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.0/vibe-acp-darwin-x86_64-1.3.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.2/vibe-acp-linux-aarch64-1.2.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.0/vibe-acp-linux-aarch64-1.3.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.2/vibe-acp-linux-x86_64-1.2.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.0/vibe-acp-linux-x86_64-1.3.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.2/vibe-acp-windows-aarch64-1.2.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.0/vibe-acp-windows-aarch64-1.3.0.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.2.2/vibe-acp-windows-x86_64-1.2.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.0/vibe-acp-windows-x86_64-1.3.0.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "1.2.2"
|
version = "1.3.0"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
@ -36,6 +36,7 @@ dependencies = [
|
||||||
"packaging>=24.1",
|
"packaging>=24.1",
|
||||||
"pydantic>=2.12.4",
|
"pydantic>=2.12.4",
|
||||||
"pydantic-settings>=2.12.0",
|
"pydantic-settings>=2.12.0",
|
||||||
|
"pyyaml>=6.0.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"rich>=14.0.0",
|
"rich>=14.0.0",
|
||||||
"textual>=1.0.0",
|
"textual>=1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agentInfo == Implementation(
|
assert response.agentInfo == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.2"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.authMethods == []
|
assert response.authMethods == []
|
||||||
|
|
@ -63,7 +63,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agentInfo == Implementation(
|
assert response.agentInfo == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.2.2"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.authMethods is not None
|
assert response.authMethods is not None
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,17 @@ from types import SimpleNamespace
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from unittest.mock import MagicMock, mock_open, patch
|
from unittest.mock import MagicMock, mock_open, patch
|
||||||
|
|
||||||
|
import pyperclip
|
||||||
import pytest
|
import pytest
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
|
||||||
from vibe.cli.clipboard import _copy_osc52, copy_selection_to_clipboard
|
from vibe.cli.clipboard import (
|
||||||
|
_copy_osc52,
|
||||||
|
_copy_wayland_clipboard,
|
||||||
|
_copy_x11_clipboard,
|
||||||
|
_get_copy_fns,
|
||||||
|
copy_selection_to_clipboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MockWidget:
|
class MockWidget:
|
||||||
|
|
@ -79,88 +86,69 @@ def test_copy_selection_to_clipboard_no_notification(
|
||||||
mock_app.notify.assert_not_called()
|
mock_app.notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("vibe.cli.clipboard._copy_osc52")
|
@patch("vibe.cli.clipboard._get_copy_fns")
|
||||||
@patch("vibe.cli.clipboard.pyperclip.copy")
|
def test_copy_selection_to_clipboard_success(
|
||||||
def test_copy_selection_to_clipboard_success_with_osc52(
|
mock_get_copy_fns: MagicMock, mock_app: MagicMock
|
||||||
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
widget = MockWidget(
|
widget = MockWidget(
|
||||||
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||||
)
|
)
|
||||||
mock_app.query.return_value = [widget]
|
mock_app.query.return_value = [widget]
|
||||||
|
|
||||||
|
mock_copy_fn = MagicMock()
|
||||||
|
mock_get_copy_fns.return_value = [mock_copy_fn]
|
||||||
|
|
||||||
copy_selection_to_clipboard(mock_app)
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
mock_osc52_copy.assert_called_once_with("selected text")
|
mock_copy_fn.assert_called_once_with("selected text")
|
||||||
mock_pyperclip_copy.assert_not_called()
|
|
||||||
mock_app.copy_to_clipboard.assert_not_called()
|
|
||||||
mock_app.notify.assert_called_once_with(
|
mock_app.notify.assert_called_once_with(
|
||||||
'"selected text" copied to clipboard', severity="information", timeout=2
|
'"selected text" copied to clipboard', severity="information", timeout=2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@patch("vibe.cli.clipboard._copy_osc52")
|
@patch("vibe.cli.clipboard._get_copy_fns")
|
||||||
@patch("vibe.cli.clipboard.pyperclip.copy")
|
def test_copy_selection_to_clipboard_tries_all(
|
||||||
def test_copy_selection_to_clipboard_osc52_fails_success_with_pyperclip(
|
mock_get_copy_fns: MagicMock, mock_app: MagicMock
|
||||||
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
|
||||||
) -> None:
|
|
||||||
widget = MockWidget(
|
|
||||||
text_selection=SimpleNamespace(),
|
|
||||||
get_selection_result=(" selected text ", None),
|
|
||||||
)
|
|
||||||
mock_app.query.return_value = [widget]
|
|
||||||
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
|
||||||
|
|
||||||
copy_selection_to_clipboard(mock_app)
|
|
||||||
|
|
||||||
mock_osc52_copy.assert_called_once_with(" selected text ")
|
|
||||||
mock_pyperclip_copy.assert_called_once_with(" selected text ")
|
|
||||||
mock_app.notify.assert_called_once_with(
|
|
||||||
'" selected text " copied to clipboard', severity="information", timeout=2
|
|
||||||
)
|
|
||||||
mock_app.copy_to_clipboard.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@patch("vibe.cli.clipboard._copy_osc52")
|
|
||||||
@patch("vibe.cli.clipboard.pyperclip.copy")
|
|
||||||
def test_copy_selection_to_clipboard_osc52_and_pyperclip_fail_success_with_app_copy(
|
|
||||||
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
widget = MockWidget(
|
widget = MockWidget(
|
||||||
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||||
)
|
)
|
||||||
mock_app.query.return_value = [widget]
|
mock_app.query.return_value = [widget]
|
||||||
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
|
||||||
mock_pyperclip_copy.side_effect = Exception("pyperclip failed")
|
fn_1 = MagicMock(side_effect=Exception("failed"))
|
||||||
|
fn_2 = MagicMock()
|
||||||
|
fn_3 = MagicMock()
|
||||||
|
mock_get_copy_fns.return_value = [fn_1, fn_2, fn_3]
|
||||||
|
|
||||||
copy_selection_to_clipboard(mock_app)
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
mock_osc52_copy.assert_called_once_with("selected text")
|
fn_1.assert_called_once_with("selected text")
|
||||||
mock_pyperclip_copy.assert_called_once_with("selected text")
|
fn_2.assert_called_once_with("selected text")
|
||||||
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
|
fn_3.assert_called_once_with("selected text")
|
||||||
mock_app.notify.assert_called_once_with(
|
mock_app.notify.assert_called_once_with(
|
||||||
'"selected text" copied to clipboard', severity="information", timeout=2
|
'"selected text" copied to clipboard', severity="information", timeout=2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@patch("vibe.cli.clipboard._copy_osc52")
|
@patch("vibe.cli.clipboard._get_copy_fns")
|
||||||
@patch("vibe.cli.clipboard.pyperclip.copy")
|
|
||||||
def test_copy_selection_to_clipboard_all_methods_fail(
|
def test_copy_selection_to_clipboard_all_methods_fail(
|
||||||
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
mock_get_copy_fns: MagicMock, mock_app: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
widget = MockWidget(
|
widget = MockWidget(
|
||||||
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||||
)
|
)
|
||||||
mock_app.query.return_value = [widget]
|
mock_app.query.return_value = [widget]
|
||||||
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
|
||||||
mock_pyperclip_copy.side_effect = Exception("pyperclip failed")
|
failing_fn1 = MagicMock(side_effect=Exception("failed 1"))
|
||||||
mock_app.copy_to_clipboard.side_effect = Exception("app copy failed")
|
failing_fn2 = MagicMock(side_effect=Exception("failed 2"))
|
||||||
|
failing_fn3 = MagicMock(side_effect=Exception("failed 3"))
|
||||||
|
mock_get_copy_fns.return_value = [failing_fn1, failing_fn2, failing_fn3]
|
||||||
|
|
||||||
copy_selection_to_clipboard(mock_app)
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
mock_osc52_copy.assert_called_once_with("selected text")
|
failing_fn1.assert_called_once_with("selected text")
|
||||||
mock_pyperclip_copy.assert_called_once_with("selected text")
|
failing_fn2.assert_called_once_with("selected text")
|
||||||
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
|
failing_fn3.assert_called_once_with("selected text")
|
||||||
mock_app.notify.assert_called_once_with(
|
mock_app.notify.assert_called_once_with(
|
||||||
"Failed to copy - no clipboard method available", severity="warning", timeout=3
|
"Failed to copy - no clipboard method available", severity="warning", timeout=3
|
||||||
)
|
)
|
||||||
|
|
@ -177,10 +165,12 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
|
||||||
widget3 = MockWidget(text_selection=None)
|
widget3 = MockWidget(text_selection=None)
|
||||||
mock_app.query.return_value = [widget1, widget2, widget3]
|
mock_app.query.return_value = [widget1, widget2, widget3]
|
||||||
|
|
||||||
with patch("vibe.cli.clipboard._copy_osc52") as mock_osc52_copy:
|
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
|
||||||
|
mock_copy_fn = MagicMock()
|
||||||
|
mock_get_copy_fns.return_value = [mock_copy_fn]
|
||||||
copy_selection_to_clipboard(mock_app)
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
mock_osc52_copy.assert_called_once_with("first selection\nsecond selection")
|
mock_copy_fn.assert_called_once_with("first selection\nsecond selection")
|
||||||
mock_app.notify.assert_called_once_with(
|
mock_app.notify.assert_called_once_with(
|
||||||
'"first selection⏎second selection" copied to clipboard',
|
'"first selection⏎second selection" copied to clipboard',
|
||||||
severity="information",
|
severity="information",
|
||||||
|
|
@ -195,15 +185,12 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) ->
|
||||||
)
|
)
|
||||||
mock_app.query.return_value = [widget]
|
mock_app.query.return_value = [widget]
|
||||||
|
|
||||||
with (
|
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
|
||||||
patch("vibe.cli.clipboard._copy_osc52") as mock_osc52_copy,
|
mock_copy_fn = MagicMock()
|
||||||
patch("vibe.cli.clipboard.pyperclip.copy") as mock_pyperclip_copy,
|
mock_get_copy_fns.return_value = [mock_copy_fn]
|
||||||
):
|
|
||||||
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
|
||||||
copy_selection_to_clipboard(mock_app)
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
mock_osc52_copy.assert_called_once_with(long_text)
|
mock_copy_fn.assert_called_once_with(long_text)
|
||||||
mock_pyperclip_copy.assert_called_once_with(long_text)
|
|
||||||
notification_call = mock_app.notify.call_args
|
notification_call = mock_app.notify.call_args
|
||||||
assert notification_call is not None
|
assert notification_call is not None
|
||||||
assert '"' in notification_call[0][0]
|
assert '"' in notification_call[0][0]
|
||||||
|
|
@ -226,3 +213,113 @@ def test_copy_osc52_writes_correct_sequence(
|
||||||
handle = mock_file()
|
handle = mock_file()
|
||||||
handle.write.assert_called_once_with(expected_seq)
|
handle.write.assert_called_once_with(expected_seq)
|
||||||
handle.flush.assert_called_once()
|
handle.flush.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("builtins.open", new_callable=mock_open)
|
||||||
|
def test_copy_osc52_with_tmux(
|
||||||
|
mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("TMUX", "1")
|
||||||
|
test_text = "test text"
|
||||||
|
|
||||||
|
_copy_osc52(test_text)
|
||||||
|
|
||||||
|
encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii")
|
||||||
|
expected_seq = f"\033Ptmux;\033\033]52;c;{encoded}\a\033\\"
|
||||||
|
handle = mock_file()
|
||||||
|
handle.write.assert_called_once_with(expected_seq)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard.subprocess.run")
|
||||||
|
def test_copy_x11_clipboard(mock_subprocess: MagicMock) -> None:
|
||||||
|
test_text = "test text"
|
||||||
|
|
||||||
|
_copy_x11_clipboard(test_text)
|
||||||
|
|
||||||
|
mock_subprocess.assert_called_once_with(
|
||||||
|
["xclip", "-selection", "clipboard"],
|
||||||
|
input=test_text.encode("utf-8"),
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard.subprocess.run")
|
||||||
|
def test_copy_wayland_clipboard(mock_subprocess: MagicMock) -> None:
|
||||||
|
test_text = "test text"
|
||||||
|
|
||||||
|
_copy_wayland_clipboard(test_text)
|
||||||
|
|
||||||
|
mock_subprocess.assert_called_once_with(
|
||||||
|
["wl-copy"], input=test_text.encode("utf-8"), check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard.shutil.which")
|
||||||
|
def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> None:
|
||||||
|
mock_which.return_value = None
|
||||||
|
|
||||||
|
copy_fns = _get_copy_fns(mock_app)
|
||||||
|
|
||||||
|
assert len(copy_fns) == 3
|
||||||
|
assert copy_fns[0] == _copy_osc52
|
||||||
|
assert copy_fns[1] == pyperclip.copy
|
||||||
|
assert copy_fns[2] == mock_app.copy_to_clipboard
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard.shutil.which")
|
||||||
|
def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
|
||||||
|
def which_side_effect(cmd: str) -> str | None:
|
||||||
|
return "/usr/bin/xclip" if cmd == "xclip" else None
|
||||||
|
|
||||||
|
mock_which.side_effect = which_side_effect
|
||||||
|
|
||||||
|
copy_fns = _get_copy_fns(mock_app)
|
||||||
|
|
||||||
|
assert len(copy_fns) == 4
|
||||||
|
assert copy_fns[0] == _copy_x11_clipboard
|
||||||
|
assert copy_fns[1] == _copy_osc52
|
||||||
|
assert copy_fns[2] == pyperclip.copy
|
||||||
|
assert copy_fns[3] == mock_app.copy_to_clipboard
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard.shutil.which")
|
||||||
|
def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None:
|
||||||
|
def which_side_effect(cmd: str) -> str | None:
|
||||||
|
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
|
||||||
|
|
||||||
|
mock_which.side_effect = which_side_effect
|
||||||
|
|
||||||
|
copy_fns = _get_copy_fns(mock_app)
|
||||||
|
|
||||||
|
assert len(copy_fns) == 4
|
||||||
|
assert copy_fns[0] == _copy_wayland_clipboard
|
||||||
|
assert copy_fns[1] == _copy_osc52
|
||||||
|
assert copy_fns[2] == pyperclip.copy
|
||||||
|
assert copy_fns[3] == mock_app.copy_to_clipboard
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard.shutil.which")
|
||||||
|
def test_get_copy_fns_with_both_system_tools(
|
||||||
|
mock_which: MagicMock, mock_app: App
|
||||||
|
) -> None:
|
||||||
|
def which_side_effect(cmd: str) -> str | None:
|
||||||
|
match cmd:
|
||||||
|
case "wl-copy":
|
||||||
|
return "/usr/bin/wl-copy"
|
||||||
|
case "xclip":
|
||||||
|
return "/usr/bin/xclip"
|
||||||
|
case _:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_which.side_effect = which_side_effect
|
||||||
|
|
||||||
|
copy_fns = _get_copy_fns(mock_app)
|
||||||
|
|
||||||
|
assert len(copy_fns) == 5
|
||||||
|
# xclip is checked last, so it's added last and ends up first in the list
|
||||||
|
assert copy_fns[0] == _copy_x11_clipboard
|
||||||
|
# wl-copy is checked first, so it's added before xclip
|
||||||
|
assert copy_fns[1] == _copy_wayland_clipboard
|
||||||
|
assert copy_fns[2] == _copy_osc52
|
||||||
|
assert copy_fns[3] == pyperclip.copy
|
||||||
|
assert copy_fns[4] == mock_app.copy_to_clipboard
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ MOCK_DATA_ENV_VAR = "VIBE_MOCK_LLM_DATA"
|
||||||
|
|
||||||
def mock_llm_chunk(
|
def mock_llm_chunk(
|
||||||
content: str = "Hello!",
|
content: str = "Hello!",
|
||||||
|
reasoning_content: str | None = None,
|
||||||
role: Role = Role.assistant,
|
role: Role = Role.assistant,
|
||||||
tool_calls: list[ToolCall] | None = None,
|
tool_calls: list[ToolCall] | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
|
@ -19,6 +20,7 @@ def mock_llm_chunk(
|
||||||
message = LLMMessage(
|
message = LLMMessage(
|
||||||
role=role,
|
role=role,
|
||||||
content=content,
|
content=content,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
name=name,
|
name=name,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from textual.widgets import Input
|
||||||
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE
|
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE
|
||||||
from vibe.setup.onboarding import OnboardingApp
|
from vibe.setup.onboarding import OnboardingApp
|
||||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||||
from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen
|
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for(
|
async def _wait_for(
|
||||||
|
|
@ -78,16 +78,19 @@ async def test_ui_can_pick_a_theme_and_saves_selection(config_dir: Path) -> None
|
||||||
await pass_welcome_screen(pilot)
|
await pass_welcome_screen(pilot)
|
||||||
|
|
||||||
theme_screen = app.screen
|
theme_screen = app.screen
|
||||||
|
assert isinstance(theme_screen, ThemeSelectionScreen)
|
||||||
app.post_message(
|
app.post_message(
|
||||||
Resize(Size(40, 10), Size(40, 10))
|
Resize(Size(40, 10), Size(40, 10))
|
||||||
) # trigger the resize event handler
|
) # trigger the resize event handler
|
||||||
preview = theme_screen.query_one("#preview")
|
preview = theme_screen.query_one("#preview")
|
||||||
assert preview.styles.max_height is not None
|
assert preview.styles.max_height is not None
|
||||||
target_theme = "gruvbox"
|
target_theme = "gruvbox"
|
||||||
assert target_theme in THEMES
|
# Use the screen's available themes which accounts for terminal theme availability
|
||||||
start_index = THEMES.index(app.theme)
|
available_themes = theme_screen._available_themes
|
||||||
target_index = THEMES.index(target_theme)
|
assert target_theme in available_themes
|
||||||
steps_down = (target_index - start_index) % len(THEMES)
|
start_index = theme_screen._theme_index
|
||||||
|
target_index = available_themes.index(target_theme)
|
||||||
|
steps_down = (target_index - start_index) % len(available_themes)
|
||||||
await pilot.press(*["down"] * steps_down)
|
await pilot.press(*["down"] * steps_down)
|
||||||
assert app.theme == target_theme
|
assert app.theme == target_theme
|
||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
|
|
|
||||||
59
tests/skills/conftest.py
Normal file
59
tests/skills/conftest.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def skills_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Create a temporary skills directory."""
|
||||||
|
skills = tmp_path / "skills"
|
||||||
|
skills.mkdir()
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def skill_config(skills_dir: Path) -> VibeConfig:
|
||||||
|
return VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_skill(
|
||||||
|
skills_dir: Path,
|
||||||
|
name: str,
|
||||||
|
description: str = "A test skill",
|
||||||
|
*,
|
||||||
|
license: str | None = None,
|
||||||
|
compatibility: str | None = None,
|
||||||
|
metadata: dict[str, str] | None = None,
|
||||||
|
allowed_tools: str | None = None,
|
||||||
|
body: str = "## Instructions\n\nTest instructions here.",
|
||||||
|
) -> Path:
|
||||||
|
skill_dir = skills_dir / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
frontmatter: dict[str, object] = {"name": name, "description": description}
|
||||||
|
if license:
|
||||||
|
frontmatter["license"] = license
|
||||||
|
if compatibility:
|
||||||
|
frontmatter["compatibility"] = compatibility
|
||||||
|
if metadata:
|
||||||
|
frontmatter["metadata"] = metadata
|
||||||
|
if allowed_tools:
|
||||||
|
frontmatter["allowed-tools"] = allowed_tools
|
||||||
|
|
||||||
|
yaml_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
|
||||||
|
content = f"---\n{yaml_str}---\n\n{body}"
|
||||||
|
|
||||||
|
skill_file = skill_dir / "SKILL.md"
|
||||||
|
skill_file.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return skill_dir
|
||||||
275
tests/skills/test_manager.py
Normal file
275
tests/skills/test_manager.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.skills.conftest import create_skill
|
||||||
|
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||||
|
from vibe.core.skills.manager import SkillManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config() -> VibeConfig:
|
||||||
|
return VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def skill_manager(config: VibeConfig) -> SkillManager:
|
||||||
|
return SkillManager(config)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillManagerDiscovery:
|
||||||
|
def test_discovers_no_skills_when_directory_empty(
|
||||||
|
self, skill_manager: SkillManager
|
||||||
|
) -> None:
|
||||||
|
assert skill_manager.available_skills == {}
|
||||||
|
|
||||||
|
def test_discovers_skill_from_skill_paths(self, skills_dir: Path) -> None:
|
||||||
|
create_skill(skills_dir, "test-skill", "A test skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
assert "test-skill" in manager.available_skills
|
||||||
|
assert manager.available_skills["test-skill"].description == "A test skill"
|
||||||
|
|
||||||
|
def test_discovers_multiple_skills(self, skills_dir: Path) -> None:
|
||||||
|
create_skill(skills_dir, "skill-one", "First skill")
|
||||||
|
create_skill(skills_dir, "skill-two", "Second skill")
|
||||||
|
create_skill(skills_dir, "skill-three", "Third skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
assert len(manager.available_skills) == 3
|
||||||
|
assert "skill-one" in manager.available_skills
|
||||||
|
assert "skill-two" in manager.available_skills
|
||||||
|
assert "skill-three" in manager.available_skills
|
||||||
|
|
||||||
|
def test_ignores_directories_without_skill_md(self, skills_dir: Path) -> None:
|
||||||
|
# Create a directory that's not a skill
|
||||||
|
not_a_skill = skills_dir / "not-a-skill"
|
||||||
|
not_a_skill.mkdir()
|
||||||
|
(not_a_skill / "README.md").write_text("Not a skill")
|
||||||
|
|
||||||
|
# Create a valid skill
|
||||||
|
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skills = manager.available_skills
|
||||||
|
assert len(skills) == 1
|
||||||
|
assert "valid-skill" in skills
|
||||||
|
assert "not-a-skill" not in skills
|
||||||
|
|
||||||
|
def test_ignores_files_in_skills_directory(self, skills_dir: Path) -> None:
|
||||||
|
# Create a file in the skills directory (not a directory)
|
||||||
|
(skills_dir / "not-a-directory.md").write_text("Just a file")
|
||||||
|
|
||||||
|
# Create a valid skill
|
||||||
|
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skills = manager.available_skills
|
||||||
|
assert len(skills) == 1
|
||||||
|
assert "valid-skill" in skills
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillManagerParsing:
|
||||||
|
def test_parses_all_skill_fields(self, skills_dir: Path) -> None:
|
||||||
|
create_skill(
|
||||||
|
skills_dir,
|
||||||
|
"full-skill",
|
||||||
|
"A skill with all fields",
|
||||||
|
license="MIT",
|
||||||
|
compatibility="Requires git",
|
||||||
|
metadata={"author": "Test Author", "version": "1.0"},
|
||||||
|
allowed_tools="bash read_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skill = manager.get_skill("full-skill")
|
||||||
|
assert skill is not None
|
||||||
|
assert skill.name == "full-skill"
|
||||||
|
assert skill.description == "A skill with all fields"
|
||||||
|
assert skill.license == "MIT"
|
||||||
|
assert skill.compatibility == "Requires git"
|
||||||
|
assert skill.metadata == {"author": "Test Author", "version": "1.0"}
|
||||||
|
assert skill.allowed_tools == ["bash", "read_file"]
|
||||||
|
|
||||||
|
def test_sets_correct_skill_path(self, skills_dir: Path) -> None:
|
||||||
|
create_skill(skills_dir, "test-skill", "A test skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skill = manager.get_skill("test-skill")
|
||||||
|
assert skill is not None
|
||||||
|
assert skill.skill_path == skills_dir / "test-skill" / "SKILL.md"
|
||||||
|
assert skill.skill_dir == skills_dir / "test-skill"
|
||||||
|
|
||||||
|
def test_skips_skill_with_invalid_frontmatter(self, skills_dir: Path) -> None:
|
||||||
|
# Create an invalid skill
|
||||||
|
invalid_skill_dir = skills_dir / "invalid-skill"
|
||||||
|
invalid_skill_dir.mkdir()
|
||||||
|
(invalid_skill_dir / "SKILL.md").write_text("No frontmatter here")
|
||||||
|
|
||||||
|
# Create a valid skill
|
||||||
|
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skills = manager.available_skills
|
||||||
|
assert len(skills) == 1
|
||||||
|
assert "valid-skill" in skills
|
||||||
|
assert "invalid-skill" not in skills
|
||||||
|
|
||||||
|
def test_skips_skill_with_missing_required_fields(self, skills_dir: Path) -> None:
|
||||||
|
# Create skill missing description
|
||||||
|
missing_desc_dir = skills_dir / "missing-desc"
|
||||||
|
missing_desc_dir.mkdir()
|
||||||
|
(missing_desc_dir / "SKILL.md").write_text("---\nname: missing-desc\n---\n")
|
||||||
|
|
||||||
|
# Create a valid skill
|
||||||
|
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skills = manager.available_skills
|
||||||
|
assert len(skills) == 1
|
||||||
|
assert "valid-skill" in skills
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillManagerSearchPaths:
|
||||||
|
def test_discovers_from_multiple_skill_paths(self, tmp_path: Path) -> None:
|
||||||
|
# Create two separate skill directories
|
||||||
|
skills_dir_1 = tmp_path / "skills1"
|
||||||
|
skills_dir_1.mkdir()
|
||||||
|
create_skill(skills_dir_1, "skill-from-dir1", "Skill from directory 1")
|
||||||
|
|
||||||
|
skills_dir_2 = tmp_path / "skills2"
|
||||||
|
skills_dir_2.mkdir()
|
||||||
|
create_skill(skills_dir_2, "skill-from-dir2", "Skill from directory 2")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir_1, skills_dir_2],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skills = manager.available_skills
|
||||||
|
assert len(skills) == 2
|
||||||
|
assert "skill-from-dir1" in skills
|
||||||
|
assert "skill-from-dir2" in skills
|
||||||
|
|
||||||
|
def test_first_discovered_wins_for_duplicates(self, tmp_path: Path) -> None:
|
||||||
|
# Create two directories with the same skill name
|
||||||
|
skills_dir_1 = tmp_path / "skills1"
|
||||||
|
skills_dir_1.mkdir()
|
||||||
|
create_skill(skills_dir_1, "duplicate-skill", "First version")
|
||||||
|
|
||||||
|
skills_dir_2 = tmp_path / "skills2"
|
||||||
|
skills_dir_2.mkdir()
|
||||||
|
create_skill(skills_dir_2, "duplicate-skill", "Second version")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir_1, skills_dir_2],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skills = manager.available_skills
|
||||||
|
assert len(skills) == 1
|
||||||
|
assert skills["duplicate-skill"].description == "First version"
|
||||||
|
|
||||||
|
def test_ignores_nonexistent_skill_paths(self, tmp_path: Path) -> None:
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
create_skill(skills_dir, "valid-skill", "A valid skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir, tmp_path / "nonexistent"],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
assert len(manager.available_skills) == 1
|
||||||
|
assert "valid-skill" in manager.available_skills
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillManagerGetSkill:
|
||||||
|
def test_returns_skill_by_name(self, skills_dir: Path) -> None:
|
||||||
|
create_skill(skills_dir, "test-skill", "A test skill")
|
||||||
|
|
||||||
|
config = VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
skill_paths=[skills_dir],
|
||||||
|
)
|
||||||
|
manager = SkillManager(config)
|
||||||
|
|
||||||
|
skill = manager.get_skill("test-skill")
|
||||||
|
assert skill is not None
|
||||||
|
assert skill.name == "test-skill"
|
||||||
|
|
||||||
|
def test_returns_none_for_unknown_skill(self, skill_manager: SkillManager) -> None:
|
||||||
|
assert skill_manager.get_skill("nonexistent-skill") is None
|
||||||
188
tests/skills/test_models.py
Normal file
188
tests/skills/test_models.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillMetadata:
|
||||||
|
def test_creates_with_required_fields(self) -> None:
|
||||||
|
meta = SkillMetadata(name="test-skill", description="A test skill")
|
||||||
|
|
||||||
|
assert meta.name == "test-skill"
|
||||||
|
assert meta.description == "A test skill"
|
||||||
|
assert meta.license is None
|
||||||
|
assert meta.compatibility is None
|
||||||
|
assert meta.metadata == {}
|
||||||
|
assert meta.allowed_tools == []
|
||||||
|
|
||||||
|
def test_creates_with_all_fields(self) -> None:
|
||||||
|
meta = SkillMetadata(
|
||||||
|
name="full-skill",
|
||||||
|
description="A skill with all fields",
|
||||||
|
license="MIT",
|
||||||
|
compatibility="Requires git",
|
||||||
|
metadata={"author": "Test Author", "version": "1.0"},
|
||||||
|
allowed_tools=["bash", "read_file"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.name == "full-skill"
|
||||||
|
assert meta.description == "A skill with all fields"
|
||||||
|
assert meta.license == "MIT"
|
||||||
|
assert meta.compatibility == "Requires git"
|
||||||
|
assert meta.metadata == {"author": "Test Author", "version": "1.0"}
|
||||||
|
assert meta.allowed_tools == ["bash", "read_file"]
|
||||||
|
|
||||||
|
def test_raises_error_for_uppercase_name(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(name="Test-SKILL", description="A test skill")
|
||||||
|
assert "name" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_raises_error_for_invalid_chars_in_name(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(name="test_skill@v1.0", description="A test skill")
|
||||||
|
assert "name" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_raises_error_for_consecutive_hyphens(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(name="test--skill", description="A test skill")
|
||||||
|
assert "name" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_raises_error_for_leading_trailing_hyphens(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(name="-test-skill-", description="A test skill")
|
||||||
|
assert "name" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_parses_allowed_tools_from_space_delimited_string(self) -> None:
|
||||||
|
meta = SkillMetadata(
|
||||||
|
name="test",
|
||||||
|
description="A test skill",
|
||||||
|
allowed_tools="bash read_file grep", # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.allowed_tools == ["bash", "read_file", "grep"]
|
||||||
|
|
||||||
|
def test_parses_allowed_tools_from_list(self) -> None:
|
||||||
|
meta = SkillMetadata(
|
||||||
|
name="test", description="A test skill", allowed_tools=["bash", "read_file"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.allowed_tools == ["bash", "read_file"]
|
||||||
|
|
||||||
|
def test_parses_allowed_tools_handles_none(self) -> None:
|
||||||
|
meta = SkillMetadata(
|
||||||
|
name="test",
|
||||||
|
description="A test skill",
|
||||||
|
allowed_tools=None, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.allowed_tools == []
|
||||||
|
|
||||||
|
def test_normalizes_metadata_values_to_strings(self) -> None:
|
||||||
|
meta = SkillMetadata(
|
||||||
|
name="test",
|
||||||
|
description="A test skill",
|
||||||
|
metadata={"version": 1.0, "count": 42}, # type: ignore[dict-item]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.metadata == {"version": "1.0", "count": "42"}
|
||||||
|
|
||||||
|
def test_raises_error_for_missing_name(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(description="A test skill") # type: ignore[call-arg]
|
||||||
|
|
||||||
|
assert "name" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_raises_error_for_missing_description(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(name="test") # type: ignore[call-arg]
|
||||||
|
|
||||||
|
assert "description" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_raises_error_for_empty_name(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(name="", description="A test skill")
|
||||||
|
|
||||||
|
assert "name" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_raises_error_for_empty_description(self) -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SkillMetadata(name="test", description="")
|
||||||
|
|
||||||
|
assert "description" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillInfo:
|
||||||
|
def test_creates_from_metadata(self, tmp_path: Path) -> None:
|
||||||
|
skill_path = tmp_path / "test-skill" / "SKILL.md"
|
||||||
|
skill_path.parent.mkdir()
|
||||||
|
skill_path.touch()
|
||||||
|
|
||||||
|
meta = SkillMetadata(
|
||||||
|
name="test-skill", description="A test skill", license="MIT"
|
||||||
|
)
|
||||||
|
info = SkillInfo.from_metadata(meta, skill_path)
|
||||||
|
|
||||||
|
assert info.name == "test-skill"
|
||||||
|
assert info.description == "A test skill"
|
||||||
|
assert info.license == "MIT"
|
||||||
|
assert info.skill_path == skill_path.resolve()
|
||||||
|
assert info.skill_dir == skill_path.parent.resolve()
|
||||||
|
|
||||||
|
def test_creates_with_all_fields(self, tmp_path: Path) -> None:
|
||||||
|
skill_path = tmp_path / "full-skill" / "SKILL.md"
|
||||||
|
skill_path.parent.mkdir()
|
||||||
|
skill_path.touch()
|
||||||
|
|
||||||
|
info = SkillInfo(
|
||||||
|
name="full-skill",
|
||||||
|
description="A skill with all fields",
|
||||||
|
license="Apache-2.0",
|
||||||
|
compatibility="git, docker",
|
||||||
|
metadata={"author": "Test"},
|
||||||
|
allowed_tools=["bash"],
|
||||||
|
skill_path=skill_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert info.name == "full-skill"
|
||||||
|
assert info.description == "A skill with all fields"
|
||||||
|
assert info.license == "Apache-2.0"
|
||||||
|
assert info.compatibility == "git, docker"
|
||||||
|
assert info.metadata == {"author": "Test"}
|
||||||
|
assert info.allowed_tools == ["bash"]
|
||||||
|
assert info.skill_path == skill_path
|
||||||
|
assert info.skill_dir == skill_path.parent.resolve()
|
||||||
|
|
||||||
|
def test_from_metadata_resolves_paths(self, tmp_path: Path) -> None:
|
||||||
|
skill_path = tmp_path / "test-skill" / "SKILL.md"
|
||||||
|
skill_path.parent.mkdir()
|
||||||
|
skill_path.touch()
|
||||||
|
|
||||||
|
meta = SkillMetadata(name="test-skill", description="A test skill")
|
||||||
|
info = SkillInfo.from_metadata(meta, skill_path)
|
||||||
|
|
||||||
|
assert info.skill_path.is_absolute()
|
||||||
|
assert info.skill_dir.is_absolute()
|
||||||
|
|
||||||
|
def test_inherits_all_metadata_fields(self, tmp_path: Path) -> None:
|
||||||
|
skill_path = tmp_path / "test-skill" / "SKILL.md"
|
||||||
|
skill_path.parent.mkdir()
|
||||||
|
skill_path.touch()
|
||||||
|
|
||||||
|
meta = SkillMetadata(
|
||||||
|
name="test-skill",
|
||||||
|
description="A test skill",
|
||||||
|
license="MIT",
|
||||||
|
compatibility="Requires Python 3.12",
|
||||||
|
metadata={"key": "value"},
|
||||||
|
allowed_tools=["bash", "grep"],
|
||||||
|
)
|
||||||
|
info = SkillInfo.from_metadata(meta, skill_path)
|
||||||
|
|
||||||
|
assert info.license == meta.license
|
||||||
|
assert info.compatibility == meta.compatibility
|
||||||
|
assert info.metadata == meta.metadata
|
||||||
|
assert info.allowed_tools == meta.allowed_tools
|
||||||
115
tests/skills/test_parser.py
Normal file
115
tests/skills/test_parser.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseFrontmatter:
|
||||||
|
def test_parses_valid_frontmatter(self) -> None:
|
||||||
|
content = """---
|
||||||
|
name: test-skill
|
||||||
|
description: A test skill
|
||||||
|
---
|
||||||
|
|
||||||
|
## Body content here
|
||||||
|
"""
|
||||||
|
frontmatter, body = parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert frontmatter["name"] == "test-skill"
|
||||||
|
assert frontmatter["description"] == "A test skill"
|
||||||
|
assert "## Body content here" in body
|
||||||
|
|
||||||
|
def test_parses_frontmatter_with_all_fields(self) -> None:
|
||||||
|
content = """---
|
||||||
|
name: full-skill
|
||||||
|
description: A skill with all fields
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires git
|
||||||
|
metadata:
|
||||||
|
author: Test Author
|
||||||
|
version: "1.0"
|
||||||
|
allowed-tools: bash read_file
|
||||||
|
---
|
||||||
|
|
||||||
|
Instructions here.
|
||||||
|
"""
|
||||||
|
frontmatter, body = parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert frontmatter["name"] == "full-skill"
|
||||||
|
assert frontmatter["description"] == "A skill with all fields"
|
||||||
|
assert frontmatter["license"] == "MIT"
|
||||||
|
assert frontmatter["compatibility"] == "Requires git"
|
||||||
|
assert frontmatter["metadata"]["author"] == "Test Author"
|
||||||
|
assert frontmatter["metadata"]["version"] == "1.0"
|
||||||
|
assert frontmatter["allowed-tools"] == "bash read_file"
|
||||||
|
assert "Instructions here." in body
|
||||||
|
|
||||||
|
def test_raises_error_for_missing_frontmatter(self) -> None:
|
||||||
|
content = "Just markdown content without frontmatter"
|
||||||
|
|
||||||
|
with pytest.raises(SkillParseError) as exc_info:
|
||||||
|
parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert "Missing or invalid YAML frontmatter" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_raises_error_for_unclosed_frontmatter(self) -> None:
|
||||||
|
content = """---
|
||||||
|
name: incomplete
|
||||||
|
description: Missing closing delimiter
|
||||||
|
"""
|
||||||
|
|
||||||
|
with pytest.raises(SkillParseError) as exc_info:
|
||||||
|
parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert "Missing or invalid YAML frontmatter" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_raises_error_for_invalid_yaml(self) -> None:
|
||||||
|
content = """---
|
||||||
|
name: [invalid yaml
|
||||||
|
description: broken
|
||||||
|
---
|
||||||
|
|
||||||
|
Body here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with pytest.raises(SkillParseError) as exc_info:
|
||||||
|
parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert "Invalid YAML frontmatter" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_raises_error_for_non_dict_frontmatter(self) -> None:
|
||||||
|
content = """---
|
||||||
|
- item1
|
||||||
|
- item2
|
||||||
|
---
|
||||||
|
|
||||||
|
Body here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with pytest.raises(SkillParseError) as exc_info:
|
||||||
|
parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert "must be a mapping" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_handles_empty_frontmatter(self) -> None:
|
||||||
|
content = """---
|
||||||
|
---
|
||||||
|
|
||||||
|
Body content.
|
||||||
|
"""
|
||||||
|
frontmatter, body = parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert frontmatter == {}
|
||||||
|
assert "Body content." in body
|
||||||
|
|
||||||
|
def test_handles_frontmatter_with_no_body(self) -> None:
|
||||||
|
content = """---
|
||||||
|
name: minimal
|
||||||
|
description: No body
|
||||||
|
---
|
||||||
|
"""
|
||||||
|
frontmatter, body = parse_frontmatter(content)
|
||||||
|
|
||||||
|
assert frontmatter["name"] == "minimal"
|
||||||
|
assert body.strip() == ""
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 21 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 21 KiB |
146
tests/snapshots/test_ui_snapshot_reasoning_content.py
Normal file
146
tests/snapshots/test_ui_snapshot_reasoning_content.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.pilot import Pilot
|
||||||
|
|
||||||
|
from tests.mock.utils import mock_llm_chunk
|
||||||
|
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||||
|
from tests.snapshots.snap_compare import SnapCompare
|
||||||
|
from tests.stubs.fake_backend import FakeBackend
|
||||||
|
from vibe.cli.textual_ui.widgets.messages import ReasoningMessage
|
||||||
|
from vibe.core.agent import Agent
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
config = default_config()
|
||||||
|
fake_backend = FakeBackend(
|
||||||
|
chunks=[
|
||||||
|
mock_llm_chunk(
|
||||||
|
content="",
|
||||||
|
reasoning_content="Let me think about this step by step...",
|
||||||
|
),
|
||||||
|
mock_llm_chunk(
|
||||||
|
content="",
|
||||||
|
reasoning_content=" First, I need to understand the question.",
|
||||||
|
),
|
||||||
|
mock_llm_chunk(
|
||||||
|
content="", reasoning_content=" Then I can formulate a response."
|
||||||
|
),
|
||||||
|
mock_llm_chunk(content="The answer to your question is 42."),
|
||||||
|
mock_llm_chunk(content=" This is the ultimate answer."),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
super().__init__(config=config)
|
||||||
|
self.agent = Agent(
|
||||||
|
config,
|
||||||
|
mode=self._current_agent_mode,
|
||||||
|
enable_streaming=True,
|
||||||
|
backend=fake_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotTestAppWithInterleavedReasoning(BaseSnapshotTestApp):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
config = default_config()
|
||||||
|
fake_backend = FakeBackend(
|
||||||
|
chunks=[
|
||||||
|
mock_llm_chunk(
|
||||||
|
content="", reasoning_content="Let me think about this..."
|
||||||
|
),
|
||||||
|
mock_llm_chunk(content="Here's "),
|
||||||
|
mock_llm_chunk(content="the "),
|
||||||
|
mock_llm_chunk(content="first "),
|
||||||
|
mock_llm_chunk(content="part "),
|
||||||
|
mock_llm_chunk(content="of the answer. "),
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Now let me verify..."),
|
||||||
|
mock_llm_chunk(content="And here's the conclusion!"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
super().__init__(config=config)
|
||||||
|
self.agent = Agent(
|
||||||
|
config,
|
||||||
|
mode=self._current_agent_mode,
|
||||||
|
enable_streaming=True,
|
||||||
|
backend=fake_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_shows_reasoning_content(snap_compare: SnapCompare) -> None:
|
||||||
|
async def run_before(pilot: Pilot) -> None:
|
||||||
|
await pilot.press(*"What is the answer?")
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause(0.5)
|
||||||
|
|
||||||
|
assert snap_compare(
|
||||||
|
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithReasoningContent",
|
||||||
|
terminal_size=(120, 36),
|
||||||
|
run_before=run_before,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_shows_reasoning_content_expanded(snap_compare: SnapCompare) -> None:
|
||||||
|
async def run_before(pilot: Pilot) -> None:
|
||||||
|
await pilot.press(*"What is the answer?")
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause(0.5)
|
||||||
|
|
||||||
|
reasoning_msg = pilot.app.query_one(ReasoningMessage)
|
||||||
|
await pilot.click(reasoning_msg)
|
||||||
|
await pilot.pause(0.1)
|
||||||
|
|
||||||
|
assert snap_compare(
|
||||||
|
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithReasoningContent",
|
||||||
|
terminal_size=(120, 36),
|
||||||
|
run_before=run_before,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_shows_interleaved_reasoning(snap_compare: SnapCompare) -> None:
|
||||||
|
async def run_before(pilot: Pilot) -> None:
|
||||||
|
await pilot.press(*"Explain this to me")
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause(0.5)
|
||||||
|
|
||||||
|
assert snap_compare(
|
||||||
|
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithInterleavedReasoning",
|
||||||
|
terminal_size=(120, 36),
|
||||||
|
run_before=run_before,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotTestAppWithBufferedReasoningTransition(BaseSnapshotTestApp):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
config = default_config()
|
||||||
|
fake_backend = FakeBackend(
|
||||||
|
chunks=[
|
||||||
|
mock_llm_chunk(
|
||||||
|
content="", reasoning_content="Analyzing the problem..."
|
||||||
|
),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=" Considering options..."),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=" Making decision."),
|
||||||
|
mock_llm_chunk(content="Here is my carefully considered answer."),
|
||||||
|
mock_llm_chunk(content=" I hope this helps!"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
super().__init__(config=config)
|
||||||
|
self.agent = Agent(
|
||||||
|
config,
|
||||||
|
mode=self._current_agent_mode,
|
||||||
|
enable_streaming=True,
|
||||||
|
backend=fake_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_buffered_reasoning_yields_before_content(
|
||||||
|
snap_compare: SnapCompare,
|
||||||
|
) -> None:
|
||||||
|
async def run_before(pilot: Pilot) -> None:
|
||||||
|
await pilot.press(*"Give me an answer")
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause(0.5)
|
||||||
|
|
||||||
|
assert snap_compare(
|
||||||
|
"test_ui_snapshot_reasoning_content.py:SnapshotTestAppWithBufferedReasoningTransition",
|
||||||
|
terminal_size=(120, 36),
|
||||||
|
run_before=run_before,
|
||||||
|
)
|
||||||
|
|
@ -25,6 +25,7 @@ from vibe.core.types import (
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
FunctionCall,
|
FunctionCall,
|
||||||
LLMMessage,
|
LLMMessage,
|
||||||
|
ReasoningEvent,
|
||||||
Role,
|
Role,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
|
|
@ -376,3 +377,150 @@ async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> N
|
||||||
|
|
||||||
assert [role for role, _ in observed] == [Role.system, Role.user]
|
assert [role for role, _ in observed] == [Role.system, Role.user]
|
||||||
assert agent.interaction_logger.save_interaction.await_count == 1
|
assert agent.interaction_logger.save_interaction.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_events(events: list) -> list[tuple[str, str]]:
|
||||||
|
return [
|
||||||
|
(type(e).__name__, e.content)
|
||||||
|
for e in events
|
||||||
|
if isinstance(e, (AssistantEvent, ReasoningEvent))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reasoning_buffer_yields_before_content_on_transition() -> None:
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Let me think"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=" about this"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=" problem..."),
|
||||||
|
mock_llm_chunk(content="The answer is 42."),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("What's the answer?")]
|
||||||
|
|
||||||
|
assert _snapshot_events(events) == [
|
||||||
|
("ReasoningEvent", "Let me think about this problem..."),
|
||||||
|
("AssistantEvent", "The answer is 42."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reasoning_buffer_yields_before_content_with_batching() -> None:
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Step 1"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=", Step 2"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=", Step 3"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=", Step 4"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=", Step 5"), # Triggers batch
|
||||||
|
mock_llm_chunk(content="", reasoning_content=", Step 6"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=", Final"),
|
||||||
|
mock_llm_chunk(content="Done thinking!"),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Think step by step")]
|
||||||
|
|
||||||
|
assert _snapshot_events(events) == [
|
||||||
|
("ReasoningEvent", "Step 1, Step 2, Step 3, Step 4, Step 5"),
|
||||||
|
("ReasoningEvent", ", Step 6, Final"),
|
||||||
|
("AssistantEvent", "Done thinking!"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_content_buffer_yields_before_reasoning_on_transition() -> None:
|
||||||
|
"""When content is buffered and reasoning arrives, content yields first."""
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="Starting the response"),
|
||||||
|
mock_llm_chunk(content=" here..."),
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Wait, let me reconsider"),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=" this approach..."),
|
||||||
|
mock_llm_chunk(content="Actually, the final answer."),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Give me an answer")]
|
||||||
|
|
||||||
|
assert _snapshot_events(events) == [
|
||||||
|
("AssistantEvent", "Starting the response here..."),
|
||||||
|
("ReasoningEvent", "Wait, let me reconsider this approach..."),
|
||||||
|
("AssistantEvent", "Actually, the final answer."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interleaved_reasoning_content_preserves_order() -> None:
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Think 1"),
|
||||||
|
mock_llm_chunk(content="Answer 1 "),
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Think 2"),
|
||||||
|
mock_llm_chunk(content="Answer 2 "),
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Think 3"),
|
||||||
|
mock_llm_chunk(content="Answer 3"),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Interleaved test")]
|
||||||
|
|
||||||
|
assert _snapshot_events(events) == [
|
||||||
|
("ReasoningEvent", "Think 1"),
|
||||||
|
("AssistantEvent", "Answer 1 "),
|
||||||
|
("ReasoningEvent", "Think 2"),
|
||||||
|
("AssistantEvent", "Answer 2 "),
|
||||||
|
("ReasoningEvent", "Think 3"),
|
||||||
|
("AssistantEvent", "Answer 3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
assistant_msg = next(m for m in agent.messages if m.role == Role.assistant)
|
||||||
|
assert assistant_msg.reasoning_content == "Think 1Think 2Think 3"
|
||||||
|
assert assistant_msg.content == "Answer 1 Answer 2 Answer 3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_reasoning_chunks_yields_reasoning_event() -> None:
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Just thinking..."),
|
||||||
|
mock_llm_chunk(content="", reasoning_content=" nothing to say yet."),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Silent thinking")]
|
||||||
|
|
||||||
|
assert _snapshot_events(events) == [
|
||||||
|
("ReasoningEvent", "Just thinking... nothing to say yet.")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_final_buffers_flush_in_correct_order() -> None:
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Final thought"),
|
||||||
|
mock_llm_chunk(content="Final words"),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("End buffers test")]
|
||||||
|
|
||||||
|
assert _snapshot_events(events) == [
|
||||||
|
("ReasoningEvent", "Final thought"),
|
||||||
|
("AssistantEvent", "Final words"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_content_chunks_do_not_trigger_false_yields() -> None:
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Reasoning here"),
|
||||||
|
mock_llm_chunk(content=""), # Empty content shouldn't flush reasoning
|
||||||
|
mock_llm_chunk(content="", reasoning_content=" more reasoning"),
|
||||||
|
mock_llm_chunk(content="Actual content"),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Empty content test")]
|
||||||
|
|
||||||
|
assert _snapshot_events(events) == [
|
||||||
|
("ReasoningEvent", "Reasoning here more reasoning"),
|
||||||
|
("AssistantEvent", "Actual content"),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.mock.utils import mock_llm_chunk
|
from tests.mock.utils import mock_llm_chunk
|
||||||
|
|
@ -140,7 +140,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_approved_by_callback() -> None:
|
async def test_tool_call_approved_by_callback() -> None:
|
||||||
def approval_callback(
|
def approval_callback(
|
||||||
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||||
) -> tuple[ApprovalResponse, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
|
|
||||||
|
|
@ -177,7 +177,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
||||||
custom_feedback = "User declined tool execution"
|
custom_feedback = "User declined tool execution"
|
||||||
|
|
||||||
def approval_callback(
|
def approval_callback(
|
||||||
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||||
) -> tuple[ApprovalResponse, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return (ApprovalResponse.NO, custom_feedback)
|
return (ApprovalResponse.NO, custom_feedback)
|
||||||
|
|
||||||
|
|
@ -247,7 +247,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
|
||||||
agent_ref: Agent | None = None
|
agent_ref: Agent | None = None
|
||||||
|
|
||||||
def approval_callback(
|
def approval_callback(
|
||||||
tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||||
) -> tuple[ApprovalResponse, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
callback_invocations.append(tool_name)
|
callback_invocations.append(tool_name)
|
||||||
# Set permission to ALWAYS for this tool (simulating the new behavior)
|
# Set permission to ALWAYS for this tool (simulating the new behavior)
|
||||||
|
|
|
||||||
360
tests/test_reasoning_content.py
Normal file
360
tests/test_reasoning_content.py
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import mistralai
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
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 (
|
||||||
|
ModelConfig,
|
||||||
|
ProviderConfig,
|
||||||
|
SessionLoggingConfig,
|
||||||
|
VibeConfig,
|
||||||
|
)
|
||||||
|
from vibe.core.llm.backend.generic import GenericBackend
|
||||||
|
from vibe.core.llm.backend.mistral import MistralMapper, ParsedContent
|
||||||
|
from vibe.core.llm.format import APIToolFormatHandler
|
||||||
|
from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role
|
||||||
|
|
||||||
|
|
||||||
|
def make_config() -> VibeConfig:
|
||||||
|
return VibeConfig(
|
||||||
|
session_logging=SessionLoggingConfig(enabled=False),
|
||||||
|
auto_compact_threshold=0,
|
||||||
|
system_prompt_id="tests",
|
||||||
|
include_project_context=False,
|
||||||
|
include_prompt_detail=False,
|
||||||
|
include_model_info=False,
|
||||||
|
include_commit_signature=False,
|
||||||
|
enabled_tools=[],
|
||||||
|
tools={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMistralMapperParseContent:
|
||||||
|
def test_parse_content_string_returns_content_only(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
result = mapper.parse_content("Hello, world!")
|
||||||
|
|
||||||
|
assert result == ParsedContent(content="Hello, world!", reasoning_content=None)
|
||||||
|
|
||||||
|
def test_parse_content_text_chunk_returns_content_only(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
content: list[mistralai.ContentChunk] = [
|
||||||
|
mistralai.TextChunk(type="text", text="Hello from text chunk")
|
||||||
|
]
|
||||||
|
|
||||||
|
result = mapper.parse_content(content)
|
||||||
|
|
||||||
|
assert result == ParsedContent(
|
||||||
|
content="Hello from text chunk", reasoning_content=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_content_thinking_chunk_extracts_reasoning(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
content: list[mistralai.ContentChunk] = [
|
||||||
|
mistralai.ThinkChunk(
|
||||||
|
type="thinking",
|
||||||
|
thinking=[mistralai.TextChunk(type="text", text="Let me think...")],
|
||||||
|
),
|
||||||
|
mistralai.TextChunk(type="text", text="The answer is 42."),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = mapper.parse_content(content)
|
||||||
|
|
||||||
|
assert result == ParsedContent(
|
||||||
|
content="The answer is 42.", reasoning_content="Let me think..."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_content_multiple_thinking_chunks_concatenates(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
content: list[mistralai.ContentChunk] = [
|
||||||
|
mistralai.ThinkChunk(
|
||||||
|
type="thinking",
|
||||||
|
thinking=[mistralai.TextChunk(type="text", text="First thought. ")],
|
||||||
|
),
|
||||||
|
mistralai.ThinkChunk(
|
||||||
|
type="thinking",
|
||||||
|
thinking=[mistralai.TextChunk(type="text", text="Second thought.")],
|
||||||
|
),
|
||||||
|
mistralai.TextChunk(type="text", text="Final answer."),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = mapper.parse_content(content)
|
||||||
|
|
||||||
|
assert result == ParsedContent(
|
||||||
|
content="Final answer.", reasoning_content="First thought. Second thought."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_content_thinking_only_returns_empty_content(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
content: list[mistralai.ContentChunk] = [
|
||||||
|
mistralai.ThinkChunk(
|
||||||
|
type="thinking",
|
||||||
|
thinking=[mistralai.TextChunk(type="text", text="Just thinking...")],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = mapper.parse_content(content)
|
||||||
|
|
||||||
|
assert result == ParsedContent(content="", reasoning_content="Just thinking...")
|
||||||
|
|
||||||
|
def test_parse_content_empty_list_returns_empty(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
content: list[mistralai.ContentChunk] = []
|
||||||
|
|
||||||
|
result = mapper.parse_content(content)
|
||||||
|
|
||||||
|
assert result == ParsedContent(content="", reasoning_content=None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMistralMapperPrepareMessage:
|
||||||
|
def test_prepare_assistant_message_without_reasoning(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
msg = LLMMessage(role=Role.assistant, content="Hello!")
|
||||||
|
|
||||||
|
result = mapper.prepare_message(msg)
|
||||||
|
|
||||||
|
assert isinstance(result, mistralai.AssistantMessage)
|
||||||
|
assert result.content == "Hello!"
|
||||||
|
|
||||||
|
def test_prepare_assistant_message_with_reasoning_creates_chunks(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
msg = LLMMessage(
|
||||||
|
role=Role.assistant,
|
||||||
|
content="The answer is 42.",
|
||||||
|
reasoning_content="Let me calculate...",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = mapper.prepare_message(msg)
|
||||||
|
|
||||||
|
assert isinstance(result, mistralai.AssistantMessage)
|
||||||
|
assert isinstance(result.content, list)
|
||||||
|
assert len(result.content) == 2
|
||||||
|
|
||||||
|
think_chunk = result.content[0]
|
||||||
|
assert isinstance(think_chunk, mistralai.ThinkChunk)
|
||||||
|
assert think_chunk.type == "thinking"
|
||||||
|
assert len(think_chunk.thinking) == 1
|
||||||
|
inner_chunk = think_chunk.thinking[0]
|
||||||
|
assert isinstance(inner_chunk, mistralai.TextChunk)
|
||||||
|
assert inner_chunk.text == "Let me calculate..."
|
||||||
|
|
||||||
|
text_chunk = result.content[1]
|
||||||
|
assert isinstance(text_chunk, mistralai.TextChunk)
|
||||||
|
assert text_chunk.type == "text"
|
||||||
|
assert text_chunk.text == "The answer is 42."
|
||||||
|
|
||||||
|
def test_prepare_assistant_message_with_reasoning_and_none_content(self):
|
||||||
|
mapper = MistralMapper()
|
||||||
|
msg = LLMMessage(
|
||||||
|
role=Role.assistant, content=None, reasoning_content="Just thinking..."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = mapper.prepare_message(msg)
|
||||||
|
|
||||||
|
assert isinstance(result, mistralai.AssistantMessage)
|
||||||
|
assert isinstance(result.content, list)
|
||||||
|
assert len(result.content) == 2
|
||||||
|
|
||||||
|
think_chunk = result.content[0]
|
||||||
|
assert isinstance(think_chunk, mistralai.ThinkChunk)
|
||||||
|
assert think_chunk.type == "thinking"
|
||||||
|
assert len(think_chunk.thinking) == 1
|
||||||
|
inner_chunk = think_chunk.thinking[0]
|
||||||
|
assert isinstance(inner_chunk, mistralai.TextChunk)
|
||||||
|
assert inner_chunk.text == "Just thinking..."
|
||||||
|
|
||||||
|
text_chunk = result.content[1]
|
||||||
|
assert isinstance(text_chunk, mistralai.TextChunk)
|
||||||
|
assert text_chunk.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenericBackendReasoningContent:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_extracts_reasoning_content(self):
|
||||||
|
base_url = "https://api.example.com"
|
||||||
|
json_response = {
|
||||||
|
"id": "fake_id",
|
||||||
|
"created": 1234567890,
|
||||||
|
"model": "test-model",
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||||||
|
"object": "chat.completion",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "The answer is 42.",
|
||||||
|
"reasoning_content": "Let me think step by step...",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
|
mock_api.post("/v1/chat/completions").mock(
|
||||||
|
return_value=httpx.Response(status_code=200, json=json_response)
|
||||||
|
)
|
||||||
|
provider = ProviderConfig(
|
||||||
|
name="test", api_base=f"{base_url}/v1", api_key_env_var="API_KEY"
|
||||||
|
)
|
||||||
|
backend = GenericBackend(provider=provider)
|
||||||
|
model = ModelConfig(name="test-model", provider="test", alias="test")
|
||||||
|
messages = [LLMMessage(role=Role.user, content="What is the answer?")]
|
||||||
|
|
||||||
|
result = await backend.complete(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=0.2,
|
||||||
|
tools=None,
|
||||||
|
max_tokens=None,
|
||||||
|
tool_choice=None,
|
||||||
|
extra_headers=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.message.content == "The answer is 42."
|
||||||
|
assert result.message.reasoning_content == "Let me think step by step..."
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_streaming_extracts_reasoning_content(self):
|
||||||
|
base_url = "https://api.example.com"
|
||||||
|
chunks = [
|
||||||
|
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Thinking..."},"finish_reason":null}]}',
|
||||||
|
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]}',
|
||||||
|
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}',
|
||||||
|
b"data: [DONE]",
|
||||||
|
]
|
||||||
|
|
||||||
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
|
mock_api.post("/v1/chat/completions").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
status_code=200,
|
||||||
|
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||||
|
headers={"Content-Type": "text/event-stream"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = ProviderConfig(
|
||||||
|
name="test", api_base=f"{base_url}/v1", api_key_env_var="API_KEY"
|
||||||
|
)
|
||||||
|
backend = GenericBackend(provider=provider)
|
||||||
|
model = ModelConfig(name="test-model", provider="test", alias="test")
|
||||||
|
messages = [LLMMessage(role=Role.user, content="Stream please")]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
async for chunk in backend.complete_streaming(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=0.2,
|
||||||
|
tools=None,
|
||||||
|
max_tokens=None,
|
||||||
|
tool_choice=None,
|
||||||
|
extra_headers=None,
|
||||||
|
):
|
||||||
|
results.append(chunk)
|
||||||
|
|
||||||
|
assert results[0].message.reasoning_content == "Thinking..."
|
||||||
|
assert results[0].message.content == ""
|
||||||
|
assert results[1].message.content == "Answer"
|
||||||
|
assert results[1].message.reasoning_content is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPIToolFormatHandlerReasoningContent:
|
||||||
|
def test_process_api_response_message_extracts_reasoning_content(self):
|
||||||
|
handler = APIToolFormatHandler()
|
||||||
|
|
||||||
|
mock_message = MagicMock()
|
||||||
|
mock_message.role = "assistant"
|
||||||
|
mock_message.content = "The answer is 42."
|
||||||
|
mock_message.reasoning_content = "Let me think..."
|
||||||
|
mock_message.tool_calls = None
|
||||||
|
|
||||||
|
result = handler.process_api_response_message(mock_message)
|
||||||
|
|
||||||
|
assert result.content == "The answer is 42."
|
||||||
|
assert result.reasoning_content == "Let me think..."
|
||||||
|
|
||||||
|
def test_process_api_response_message_handles_missing_reasoning_content(self):
|
||||||
|
handler = APIToolFormatHandler()
|
||||||
|
|
||||||
|
mock_message = MagicMock(spec=["role", "content", "tool_calls"])
|
||||||
|
mock_message.role = "assistant"
|
||||||
|
mock_message.content = "Hello"
|
||||||
|
mock_message.tool_calls = None
|
||||||
|
|
||||||
|
result = handler.process_api_response_message(mock_message)
|
||||||
|
|
||||||
|
assert result.content == "Hello"
|
||||||
|
assert result.reasoning_content is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentStreamingReasoningEvents:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_accumulates_reasoning_in_message(self):
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="", reasoning_content="First thought. "),
|
||||||
|
mock_llm_chunk(content="", reasoning_content="Second thought."),
|
||||||
|
mock_llm_chunk(content="Final answer."),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
[_ async for _ in agent.act("Think and answer")]
|
||||||
|
|
||||||
|
assistant_msg = next(m for m in agent.messages if m.role == Role.assistant)
|
||||||
|
assert assistant_msg.reasoning_content == "First thought. Second thought."
|
||||||
|
assert assistant_msg.content == "Final answer."
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_content_only_no_reasoning(self):
|
||||||
|
backend = FakeBackend([
|
||||||
|
mock_llm_chunk(content="Hello "),
|
||||||
|
mock_llm_chunk(content="world!"),
|
||||||
|
])
|
||||||
|
agent = Agent(make_config(), backend=backend, enable_streaming=True)
|
||||||
|
|
||||||
|
events = [event async for event in agent.act("Say hello")]
|
||||||
|
|
||||||
|
reasoning_events = [e for e in events if isinstance(e, ReasoningEvent)]
|
||||||
|
assert len(reasoning_events) == 0
|
||||||
|
|
||||||
|
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
|
||||||
|
assert len(assistant_events) == 1
|
||||||
|
|
||||||
|
assistant_msg = next(m for m in agent.messages if m.role == Role.assistant)
|
||||||
|
assert assistant_msg.reasoning_content is None
|
||||||
|
assert assistant_msg.content == "Hello world!"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLLMMessageReasoningContent:
|
||||||
|
def test_llm_message_from_dict_with_reasoning_content(self):
|
||||||
|
data = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Answer",
|
||||||
|
"reasoning_content": "Thinking...",
|
||||||
|
}
|
||||||
|
|
||||||
|
msg = LLMMessage.model_validate(data)
|
||||||
|
|
||||||
|
assert msg.reasoning_content == "Thinking..."
|
||||||
|
|
||||||
|
def test_llm_message_model_dump_includes_reasoning_content(self):
|
||||||
|
msg = LLMMessage(
|
||||||
|
role=Role.assistant, content="Answer", reasoning_content="Thinking..."
|
||||||
|
)
|
||||||
|
|
||||||
|
dumped = msg.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
assert dumped["reasoning_content"] == "Thinking..."
|
||||||
|
|
||||||
|
def test_llm_message_model_dump_excludes_none_reasoning_content(self):
|
||||||
|
msg = LLMMessage(role=Role.assistant, content="Answer")
|
||||||
|
|
||||||
|
dumped = msg.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
assert "reasoning_content" not in dumped
|
||||||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -661,7 +661,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "1.2.2"
|
version = "1.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
@ -675,6 +675,7 @@ dependencies = [
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyperclip" },
|
{ name = "pyperclip" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "textual" },
|
{ name = "textual" },
|
||||||
{ name = "textual-speedups" },
|
{ name = "textual-speedups" },
|
||||||
|
|
@ -714,6 +715,7 @@ requires-dist = [
|
||||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||||
{ name = "pyperclip", specifier = ">=1.11.0" },
|
{ name = "pyperclip", specifier = ">=1.11.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||||
{ name = "rich", specifier = ">=14.0.0" },
|
{ name = "rich", specifier = ">=14.0.0" },
|
||||||
{ name = "textual", specifier = ">=1.0.0" },
|
{ name = "textual", specifier = ">=1.0.0" },
|
||||||
{ name = "textual-speedups", specifier = ">=0.2.1" },
|
{ name = "textual-speedups", specifier = ">=0.2.1" },
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VIBE_ROOT = Path(__file__).parent
|
VIBE_ROOT = Path(__file__).parent
|
||||||
__version__ = "1.2.2"
|
__version__ = "1.3.0"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import asyncio
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, cast, override
|
from typing import cast, override
|
||||||
|
|
||||||
from acp import (
|
from acp import (
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
|
|
@ -244,7 +244,7 @@ class VibeAcpAgent(AcpAgent):
|
||||||
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
||||||
|
|
||||||
async def approval_callback(
|
async def approval_callback(
|
||||||
tool_name: str, args: dict[str, Any], tool_call_id: str
|
tool_name: str, args: BaseModel, tool_call_id: str
|
||||||
) -> tuple[ApprovalResponse, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
# Create the tool call update
|
# Create the tool call update
|
||||||
tool_call = ToolCall(toolCallId=tool_call_id)
|
tool_call = ToolCall(toolCallId=tool_call_id)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ def get_initial_mode(args: argparse.Namespace) -> AgentMode:
|
||||||
return AgentMode.PLAN
|
return AgentMode.PLAN
|
||||||
if args.auto_approve:
|
if args.auto_approve:
|
||||||
return AgentMode.AUTO_APPROVE
|
return AgentMode.AUTO_APPROVE
|
||||||
|
if args.prompt is not None:
|
||||||
|
return AgentMode.AUTO_APPROVE
|
||||||
return AgentMode.DEFAULT
|
return AgentMode.DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
from collections.abc import Callable
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import pyperclip
|
import pyperclip
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
|
@ -20,6 +24,33 @@ def _copy_osc52(text: str) -> None:
|
||||||
tty.flush()
|
tty.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_x11_clipboard(text: str) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_wayland_clipboard(text: str) -> None:
|
||||||
|
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_cmd(cmd: str) -> bool:
|
||||||
|
return shutil.which(cmd) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_copy_fns(app: App) -> list[Callable[[str], None]]:
|
||||||
|
copy_fns: list[Callable[[str], None]] = [
|
||||||
|
_copy_osc52,
|
||||||
|
pyperclip.copy,
|
||||||
|
app.copy_to_clipboard,
|
||||||
|
]
|
||||||
|
if platform.system() == "Linux" and _has_cmd("wl-copy"):
|
||||||
|
copy_fns = [_copy_wayland_clipboard, *copy_fns]
|
||||||
|
if platform.system() == "Linux" and _has_cmd("xclip"):
|
||||||
|
copy_fns = [_copy_x11_clipboard, *copy_fns]
|
||||||
|
return copy_fns
|
||||||
|
|
||||||
|
|
||||||
def _shorten_preview(texts: list[str]) -> str:
|
def _shorten_preview(texts: list[str]) -> str:
|
||||||
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
||||||
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
||||||
|
|
@ -53,18 +84,23 @@ def copy_selection_to_clipboard(app: App) -> None:
|
||||||
|
|
||||||
combined_text = "\n".join(selected_texts)
|
combined_text = "\n".join(selected_texts)
|
||||||
|
|
||||||
for copy_fn in [_copy_osc52, pyperclip.copy, app.copy_to_clipboard]:
|
success = False
|
||||||
|
copy_fns = _get_copy_fns(app)
|
||||||
|
|
||||||
|
for copy_fn in copy_fns:
|
||||||
try:
|
try:
|
||||||
copy_fn(combined_text)
|
copy_fn(combined_text)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
app.notify(
|
success = True
|
||||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
|
||||||
severity="information",
|
if success:
|
||||||
timeout=2,
|
app.notify(
|
||||||
)
|
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||||
break
|
severity="information",
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
app.notify(
|
app.notify(
|
||||||
"Failed to copy - no clipboard method available",
|
"Failed to copy - no clipboard method available",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import subprocess
|
||||||
import time
|
import time
|
||||||
from typing import Any, ClassVar, assert_never
|
from typing import Any, ClassVar, assert_never
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding, BindingType
|
from textual.binding import Binding, BindingType
|
||||||
from textual.containers import Horizontal, VerticalScroll
|
from textual.containers import Horizontal, VerticalScroll
|
||||||
|
|
@ -18,6 +19,10 @@ from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||||
from vibe.cli.commands import CommandRegistry
|
from vibe.cli.commands import CommandRegistry
|
||||||
from vibe.cli.terminal_setup import setup_terminal
|
from vibe.cli.terminal_setup import setup_terminal
|
||||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||||
|
from vibe.cli.textual_ui.terminal_theme import (
|
||||||
|
TERMINAL_THEME_NAME,
|
||||||
|
capture_terminal_theme,
|
||||||
|
)
|
||||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||||
|
|
@ -29,6 +34,8 @@ from vibe.cli.textual_ui.widgets.messages import (
|
||||||
BashOutputMessage,
|
BashOutputMessage,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
InterruptMessage,
|
InterruptMessage,
|
||||||
|
ReasoningMessage,
|
||||||
|
StreamingMessageBase,
|
||||||
UserCommandMessage,
|
UserCommandMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
WarningMessage,
|
WarningMessage,
|
||||||
|
|
@ -116,13 +123,13 @@ class VibeApp(App):
|
||||||
self._mode_indicator: ModeIndicator | None = None
|
self._mode_indicator: ModeIndicator | None = None
|
||||||
self._context_progress: ContextProgress | None = None
|
self._context_progress: ContextProgress | None = None
|
||||||
self._current_bottom_app: BottomApp = BottomApp.Input
|
self._current_bottom_app: BottomApp = BottomApp.Input
|
||||||
self.theme = config.textual_theme
|
|
||||||
|
|
||||||
self.history_file = HISTORY_FILE.path
|
self.history_file = HISTORY_FILE.path
|
||||||
|
|
||||||
self._tools_collapsed = True
|
self._tools_collapsed = True
|
||||||
self._todos_collapsed = False
|
self._todos_collapsed = False
|
||||||
self._current_streaming_message: AssistantMessage | None = None
|
self._current_streaming_message: AssistantMessage | None = None
|
||||||
|
self._current_streaming_reasoning: ReasoningMessage | None = None
|
||||||
self._version_update_notifier = version_update_notifier
|
self._version_update_notifier = version_update_notifier
|
||||||
self._update_cache_repository = update_cache_repository
|
self._update_cache_repository = update_cache_repository
|
||||||
self._is_update_check_enabled = config.enable_update_checks
|
self._is_update_check_enabled = config.enable_update_checks
|
||||||
|
|
@ -138,6 +145,7 @@ class VibeApp(App):
|
||||||
self._agent_init_interrupted = False
|
self._agent_init_interrupted = False
|
||||||
self._auto_scroll = True
|
self._auto_scroll = True
|
||||||
self._last_escape_time: float | None = None
|
self._last_escape_time: float | None = None
|
||||||
|
self._terminal_theme = capture_terminal_theme()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with VerticalScroll(id="chat"):
|
with VerticalScroll(id="chat"):
|
||||||
|
|
@ -166,6 +174,15 @@ class VibeApp(App):
|
||||||
yield ContextProgress()
|
yield ContextProgress()
|
||||||
|
|
||||||
async def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
|
if self._terminal_theme:
|
||||||
|
self.register_theme(self._terminal_theme)
|
||||||
|
|
||||||
|
if self.config.textual_theme == TERMINAL_THEME_NAME:
|
||||||
|
if self._terminal_theme:
|
||||||
|
self.theme = TERMINAL_THEME_NAME
|
||||||
|
else:
|
||||||
|
self.theme = self.config.textual_theme
|
||||||
|
|
||||||
self.event_handler = EventHandler(
|
self.event_handler = EventHandler(
|
||||||
mount_callback=self._mount_and_scroll,
|
mount_callback=self._mount_and_scroll,
|
||||||
scroll_callback=self._scroll_to_bottom_deferred,
|
scroll_callback=self._scroll_to_bottom_deferred,
|
||||||
|
|
@ -280,7 +297,11 @@ class VibeApp(App):
|
||||||
|
|
||||||
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
|
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
|
||||||
if message.key == "textual_theme":
|
if message.key == "textual_theme":
|
||||||
self.theme = message.value
|
if message.value == TERMINAL_THEME_NAME:
|
||||||
|
if self._terminal_theme:
|
||||||
|
self.theme = TERMINAL_THEME_NAME
|
||||||
|
else:
|
||||||
|
self.theme = message.value
|
||||||
|
|
||||||
async def on_config_app_config_closed(
|
async def on_config_app_config_closed(
|
||||||
self, message: ConfigApp.ConfigClosed
|
self, message: ConfigApp.ConfigClosed
|
||||||
|
|
@ -539,7 +560,7 @@ class VibeApp(App):
|
||||||
return self._agent_init_task
|
return self._agent_init_task
|
||||||
|
|
||||||
async def _approval_callback(
|
async def _approval_callback(
|
||||||
self, tool: str, args: dict, tool_call_id: str
|
self, tool: str, args: BaseModel, tool_call_id: str
|
||||||
) -> tuple[ApprovalResponse, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
self._pending_approval = asyncio.Future()
|
self._pending_approval = asyncio.Future()
|
||||||
await self._switch_to_approval_app(tool, args)
|
await self._switch_to_approval_app(tool, args)
|
||||||
|
|
@ -894,13 +915,17 @@ class VibeApp(App):
|
||||||
if self._mode_indicator:
|
if self._mode_indicator:
|
||||||
self._mode_indicator.display = False
|
self._mode_indicator.display = False
|
||||||
|
|
||||||
config_app = ConfigApp(self.config)
|
config_app = ConfigApp(
|
||||||
|
self.config, has_terminal_theme=self._terminal_theme is not None
|
||||||
|
)
|
||||||
await bottom_container.mount(config_app)
|
await bottom_container.mount(config_app)
|
||||||
self._current_bottom_app = BottomApp.Config
|
self._current_bottom_app = BottomApp.Config
|
||||||
|
|
||||||
self.call_after_refresh(config_app.focus)
|
self.call_after_refresh(config_app.focus)
|
||||||
|
|
||||||
async def _switch_to_approval_app(self, tool_name: str, tool_args: dict) -> None:
|
async def _switch_to_approval_app(
|
||||||
|
self, tool_name: str, tool_args: BaseModel
|
||||||
|
) -> None:
|
||||||
bottom_container = self.query_one("#bottom-app-container")
|
bottom_container = self.query_one("#bottom-app-container")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1133,12 +1158,36 @@ class VibeApp(App):
|
||||||
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
|
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
|
||||||
|
|
||||||
async def _finalize_current_streaming_message(self) -> None:
|
async def _finalize_current_streaming_message(self) -> None:
|
||||||
|
if self._current_streaming_reasoning is not None:
|
||||||
|
self._current_streaming_reasoning.stop_spinning()
|
||||||
|
await self._current_streaming_reasoning.stop_stream()
|
||||||
|
self._current_streaming_reasoning = None
|
||||||
|
|
||||||
if self._current_streaming_message is None:
|
if self._current_streaming_message is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
await self._current_streaming_message.stop_stream()
|
await self._current_streaming_message.stop_stream()
|
||||||
self._current_streaming_message = None
|
self._current_streaming_message = None
|
||||||
|
|
||||||
|
async def _handle_streaming_widget[T: StreamingMessageBase](
|
||||||
|
self,
|
||||||
|
widget: T,
|
||||||
|
current_stream: T | None,
|
||||||
|
other_stream: StreamingMessageBase | None,
|
||||||
|
messages_area: Widget,
|
||||||
|
) -> T | None:
|
||||||
|
if other_stream is not None:
|
||||||
|
await other_stream.stop_stream()
|
||||||
|
|
||||||
|
if current_stream is not None:
|
||||||
|
if widget._content:
|
||||||
|
await current_stream.append_content(widget._content)
|
||||||
|
return None
|
||||||
|
|
||||||
|
await messages_area.mount(widget)
|
||||||
|
await widget.write_initial_content()
|
||||||
|
return widget
|
||||||
|
|
||||||
async def _mount_and_scroll(self, widget: Widget) -> None:
|
async def _mount_and_scroll(self, widget: Widget) -> None:
|
||||||
messages_area = self.query_one("#messages")
|
messages_area = self.query_one("#messages")
|
||||||
chat = self.query_one("#chat", VerticalScroll)
|
chat = self.query_one("#chat", VerticalScroll)
|
||||||
|
|
@ -1147,15 +1196,28 @@ class VibeApp(App):
|
||||||
if was_at_bottom:
|
if was_at_bottom:
|
||||||
self._auto_scroll = True
|
self._auto_scroll = True
|
||||||
|
|
||||||
if isinstance(widget, AssistantMessage):
|
if isinstance(widget, ReasoningMessage):
|
||||||
if self._current_streaming_message is not None:
|
result = await self._handle_streaming_widget(
|
||||||
content = widget._content or ""
|
widget,
|
||||||
if content:
|
self._current_streaming_reasoning,
|
||||||
await self._current_streaming_message.append_content(content)
|
self._current_streaming_message,
|
||||||
else:
|
messages_area,
|
||||||
self._current_streaming_message = widget
|
)
|
||||||
await messages_area.mount(widget)
|
if result is not None:
|
||||||
await widget.write_initial_content()
|
self._current_streaming_reasoning = result
|
||||||
|
self._current_streaming_message = None
|
||||||
|
elif isinstance(widget, AssistantMessage):
|
||||||
|
if self._current_streaming_reasoning is not None:
|
||||||
|
self._current_streaming_reasoning.stop_spinning()
|
||||||
|
result = await self._handle_streaming_widget(
|
||||||
|
widget,
|
||||||
|
self._current_streaming_message,
|
||||||
|
self._current_streaming_reasoning,
|
||||||
|
messages_area,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
self._current_streaming_message = result
|
||||||
|
self._current_streaming_reasoning = None
|
||||||
else:
|
else:
|
||||||
await self._finalize_current_streaming_message()
|
await self._finalize_current_streaming_message()
|
||||||
await messages_area.mount(widget)
|
await messages_area.mount(widget)
|
||||||
|
|
@ -1279,7 +1341,6 @@ def run_textual_ui(
|
||||||
initial_prompt: str | None = None,
|
initial_prompt: str | None = None,
|
||||||
loaded_messages: list[LLMMessage] | None = None,
|
loaded_messages: list[LLMMessage] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run the Textual UI."""
|
|
||||||
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
|
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
|
||||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||||
app = VibeApp(
|
app = VibeApp(
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,67 @@ Markdown MarkdownFence {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reasoning-message {
|
||||||
|
margin-top: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-message-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-message-header {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-indicator {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-right: 1;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: $text-success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-collapsed-text {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
color: $text-muted;
|
||||||
|
text-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-triangle {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
color: $text-muted;
|
||||||
|
text-style: italic;
|
||||||
|
margin-left: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-message-content {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
padding-left: 2;
|
||||||
|
margin: 0;
|
||||||
|
color: $text-muted;
|
||||||
|
text-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-message-content * {
|
||||||
|
color: $text-muted;
|
||||||
|
text-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-message-content > *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.interrupt-message {
|
.interrupt-message {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ from typing import TYPE_CHECKING
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||||
from vibe.cli.textual_ui.widgets.messages import AssistantMessage
|
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage
|
||||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
BaseEvent,
|
BaseEvent,
|
||||||
CompactEndEvent,
|
CompactEndEvent,
|
||||||
CompactStartEvent,
|
CompactStartEvent,
|
||||||
|
ReasoningEvent,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
)
|
)
|
||||||
|
|
@ -50,21 +51,18 @@ class EventHandler:
|
||||||
return await self._handle_tool_call(event, loading_widget)
|
return await self._handle_tool_call(event, loading_widget)
|
||||||
case ToolResultEvent():
|
case ToolResultEvent():
|
||||||
sanitized_event = self._sanitize_event(event)
|
sanitized_event = self._sanitize_event(event)
|
||||||
|
|
||||||
await self._handle_tool_result(sanitized_event)
|
await self._handle_tool_result(sanitized_event)
|
||||||
return None
|
case ReasoningEvent():
|
||||||
|
await self._handle_reasoning_message(event)
|
||||||
case AssistantEvent():
|
case AssistantEvent():
|
||||||
await self._handle_assistant_message(event)
|
await self._handle_assistant_message(event)
|
||||||
return None
|
|
||||||
case CompactStartEvent():
|
case CompactStartEvent():
|
||||||
await self._handle_compact_start()
|
await self._handle_compact_start()
|
||||||
return None
|
|
||||||
case CompactEndEvent():
|
case CompactEndEvent():
|
||||||
await self._handle_compact_end(event)
|
await self._handle_compact_end(event)
|
||||||
return None
|
|
||||||
case _:
|
case _:
|
||||||
await self._handle_unknown_event(event)
|
await self._handle_unknown_event(event)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _sanitize_event(self, event: ToolResultEvent) -> ToolResultEvent:
|
def _sanitize_event(self, event: ToolResultEvent) -> ToolResultEvent:
|
||||||
if isinstance(event, ToolResultEvent):
|
if isinstance(event, ToolResultEvent):
|
||||||
|
|
@ -125,6 +123,12 @@ class EventHandler:
|
||||||
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
|
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
|
||||||
await self.mount_callback(AssistantMessage(event.content))
|
await self.mount_callback(AssistantMessage(event.content))
|
||||||
|
|
||||||
|
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
|
||||||
|
tools_collapsed = self.get_tools_collapsed()
|
||||||
|
await self.mount_callback(
|
||||||
|
ReasoningMessage(event.content, collapsed=tools_collapsed)
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_compact_start(self) -> None:
|
async def _handle_compact_start(self) -> None:
|
||||||
compact_msg = CompactMessage()
|
compact_msg = CompactMessage()
|
||||||
self.current_compact = compact_msg
|
self.current_compact = compact_msg
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from vibe.cli.textual_ui.renderers.tool_renderers import get_renderer
|
|
||||||
|
|
||||||
__all__ = ["get_renderer"]
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import difflib
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from vibe.core.tools.ui import ToolResultDisplay
|
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.tool_widgets import (
|
|
||||||
BashApprovalWidget,
|
|
||||||
BashResultWidget,
|
|
||||||
GrepApprovalWidget,
|
|
||||||
GrepResultWidget,
|
|
||||||
ReadFileApprovalWidget,
|
|
||||||
ReadFileResultWidget,
|
|
||||||
SearchReplaceApprovalWidget,
|
|
||||||
SearchReplaceResultWidget,
|
|
||||||
TodoApprovalWidget,
|
|
||||||
TodoResultWidget,
|
|
||||||
ToolApprovalWidget,
|
|
||||||
ToolResultWidget,
|
|
||||||
WriteFileApprovalWidget,
|
|
||||||
WriteFileResultWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ToolRenderer:
|
|
||||||
def get_approval_widget(
|
|
||||||
self, tool_args: dict
|
|
||||||
) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
|
|
||||||
return ToolApprovalWidget, tool_args
|
|
||||||
|
|
||||||
def get_result_widget(
|
|
||||||
self, display: ToolResultDisplay, collapsed: bool
|
|
||||||
) -> tuple[type[ToolResultWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"success": display.success,
|
|
||||||
"message": display.message,
|
|
||||||
"details": self._clean_details(display.details),
|
|
||||||
"warnings": display.warnings,
|
|
||||||
}
|
|
||||||
return ToolResultWidget, data
|
|
||||||
|
|
||||||
def _clean_details(self, details: dict) -> dict:
|
|
||||||
clean = {}
|
|
||||||
for key, value in details.items():
|
|
||||||
if value is None or value in ("", []):
|
|
||||||
continue
|
|
||||||
value_str = str(value).strip().replace("\n", " ").replace("\r", "")
|
|
||||||
value_str = " ".join(value_str.split())
|
|
||||||
if value_str:
|
|
||||||
clean[key] = value_str
|
|
||||||
return clean
|
|
||||||
|
|
||||||
|
|
||||||
class BashRenderer(ToolRenderer):
|
|
||||||
def get_approval_widget(
|
|
||||||
self, tool_args: dict
|
|
||||||
) -> tuple[type[BashApprovalWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"command": tool_args.get("command", ""),
|
|
||||||
"description": tool_args.get("description", ""),
|
|
||||||
}
|
|
||||||
return BashApprovalWidget, data
|
|
||||||
|
|
||||||
def get_result_widget(
|
|
||||||
self, display: ToolResultDisplay, collapsed: bool
|
|
||||||
) -> tuple[type[BashResultWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"success": display.success,
|
|
||||||
"message": display.message,
|
|
||||||
"details": self._clean_details(display.details),
|
|
||||||
"warnings": display.warnings,
|
|
||||||
}
|
|
||||||
return BashResultWidget, data
|
|
||||||
|
|
||||||
|
|
||||||
class WriteFileRenderer(ToolRenderer):
|
|
||||||
def get_approval_widget(
|
|
||||||
self, tool_args: dict
|
|
||||||
) -> tuple[type[WriteFileApprovalWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"path": tool_args.get("path", ""),
|
|
||||||
"content": tool_args.get("content", ""),
|
|
||||||
"file_extension": tool_args.get("file_extension", "text"),
|
|
||||||
}
|
|
||||||
return WriteFileApprovalWidget, data
|
|
||||||
|
|
||||||
def get_result_widget(
|
|
||||||
self, display: ToolResultDisplay, collapsed: bool
|
|
||||||
) -> tuple[type[WriteFileResultWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"success": display.success,
|
|
||||||
"message": display.message,
|
|
||||||
"path": display.details.get("path", ""),
|
|
||||||
"bytes_written": display.details.get("bytes_written"),
|
|
||||||
"content": display.details.get("content", ""),
|
|
||||||
"file_extension": display.details.get("file_extension", "text"),
|
|
||||||
}
|
|
||||||
return WriteFileResultWidget, data
|
|
||||||
|
|
||||||
|
|
||||||
class SearchReplaceRenderer(ToolRenderer):
|
|
||||||
def get_approval_widget(
|
|
||||||
self, tool_args: dict
|
|
||||||
) -> tuple[type[SearchReplaceApprovalWidget], dict[str, Any]]:
|
|
||||||
file_path = tool_args.get("file_path", "")
|
|
||||||
content = str(tool_args.get("content", ""))
|
|
||||||
|
|
||||||
diff_lines = self._parse_search_replace_blocks(content)
|
|
||||||
|
|
||||||
data = {"file_path": file_path, "diff_lines": diff_lines}
|
|
||||||
return SearchReplaceApprovalWidget, data
|
|
||||||
|
|
||||||
def get_result_widget(
|
|
||||||
self, display: ToolResultDisplay, collapsed: bool
|
|
||||||
) -> tuple[type[SearchReplaceResultWidget], dict[str, Any]]:
|
|
||||||
diff_lines = self._parse_search_replace_blocks(
|
|
||||||
display.details.get("content", "")
|
|
||||||
)
|
|
||||||
data = {
|
|
||||||
"success": display.success,
|
|
||||||
"message": display.message,
|
|
||||||
"diff_lines": diff_lines if not collapsed else [],
|
|
||||||
}
|
|
||||||
return SearchReplaceResultWidget, data
|
|
||||||
|
|
||||||
def _parse_search_replace_blocks(self, content: str) -> list[str]:
|
|
||||||
if "<<<<<<< SEARCH" not in content:
|
|
||||||
return [content]
|
|
||||||
|
|
||||||
try:
|
|
||||||
sections = content.split("<<<<<<< SEARCH")
|
|
||||||
rest = sections[1].split("=======")
|
|
||||||
search_section = rest[0].strip()
|
|
||||||
replace_part = rest[1].split(">>>>>>> REPLACE")
|
|
||||||
replace_section = replace_part[0].strip()
|
|
||||||
|
|
||||||
search_lines = search_section.split("\n")
|
|
||||||
replace_lines = replace_section.split("\n")
|
|
||||||
|
|
||||||
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
|
|
||||||
return list(diff)[2:] # Skip file headers
|
|
||||||
except (IndexError, AttributeError):
|
|
||||||
return [content[:500]]
|
|
||||||
|
|
||||||
|
|
||||||
class TodoRenderer(ToolRenderer):
|
|
||||||
def get_approval_widget(
|
|
||||||
self, tool_args: dict
|
|
||||||
) -> tuple[type[TodoApprovalWidget], dict[str, Any]]:
|
|
||||||
data = {"description": tool_args.get("description", "")}
|
|
||||||
return TodoApprovalWidget, data
|
|
||||||
|
|
||||||
def get_result_widget(
|
|
||||||
self, display: ToolResultDisplay, collapsed: bool
|
|
||||||
) -> tuple[type[TodoResultWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"success": display.success,
|
|
||||||
"message": display.message,
|
|
||||||
"todos_by_status": display.details.get("todos_by_status", {}),
|
|
||||||
}
|
|
||||||
return TodoResultWidget, data
|
|
||||||
|
|
||||||
|
|
||||||
class ReadFileRenderer(ToolRenderer):
|
|
||||||
def get_approval_widget(
|
|
||||||
self, tool_args: dict
|
|
||||||
) -> tuple[type[ReadFileApprovalWidget], dict[str, Any]]:
|
|
||||||
return ReadFileApprovalWidget, tool_args
|
|
||||||
|
|
||||||
def get_result_widget(
|
|
||||||
self, display: ToolResultDisplay, collapsed: bool
|
|
||||||
) -> tuple[type[ReadFileResultWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"success": display.success,
|
|
||||||
"message": display.message,
|
|
||||||
"path": display.details.get("path", ""),
|
|
||||||
"warnings": display.warnings,
|
|
||||||
"content": display.details.get("content", "") if not collapsed else "",
|
|
||||||
"file_extension": display.details.get("file_extension", "text"),
|
|
||||||
}
|
|
||||||
return ReadFileResultWidget, data
|
|
||||||
|
|
||||||
|
|
||||||
class GrepRenderer(ToolRenderer):
|
|
||||||
def get_approval_widget(
|
|
||||||
self, tool_args: dict
|
|
||||||
) -> tuple[type[GrepApprovalWidget], dict[str, Any]]:
|
|
||||||
return GrepApprovalWidget, tool_args
|
|
||||||
|
|
||||||
def get_result_widget(
|
|
||||||
self, display: ToolResultDisplay, collapsed: bool
|
|
||||||
) -> tuple[type[GrepResultWidget], dict[str, Any]]:
|
|
||||||
data = {
|
|
||||||
"success": display.success,
|
|
||||||
"message": display.message,
|
|
||||||
"warnings": display.warnings,
|
|
||||||
"matches": display.details.get("matches", "") if not collapsed else "",
|
|
||||||
}
|
|
||||||
return GrepResultWidget, data
|
|
||||||
|
|
||||||
|
|
||||||
_RENDERER_REGISTRY: dict[str, type[ToolRenderer]] = {
|
|
||||||
"write_file": WriteFileRenderer,
|
|
||||||
"search_replace": SearchReplaceRenderer,
|
|
||||||
"todo": TodoRenderer,
|
|
||||||
"read_file": ReadFileRenderer,
|
|
||||||
"bash": BashRenderer,
|
|
||||||
"grep": GrepRenderer,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_renderer(tool_name: str) -> ToolRenderer:
|
|
||||||
renderer_class = _RENDERER_REGISTRY.get(tool_name, ToolRenderer)
|
|
||||||
return renderer_class()
|
|
||||||
266
vibe/cli/textual_ui/terminal_theme.py
Normal file
266
vibe/cli/textual_ui/terminal_theme.py
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from textual.theme import Theme
|
||||||
|
|
||||||
|
try:
|
||||||
|
import select
|
||||||
|
import termios
|
||||||
|
|
||||||
|
_UNIX_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
select = None # type: ignore[assignment]
|
||||||
|
termios: Any = None
|
||||||
|
_UNIX_AVAILABLE = False
|
||||||
|
|
||||||
|
TERMINAL_THEME_NAME = "terminal"
|
||||||
|
|
||||||
|
_LUMINANCE_THRESHOLD = 0.5
|
||||||
|
_RGB_16BIT_LEN = 4
|
||||||
|
_RGB_8BIT_LEN = 2
|
||||||
|
|
||||||
|
# OSC codes for terminal colors
|
||||||
|
_OSC_FOREGROUND = "10"
|
||||||
|
_OSC_BACKGROUND = "11"
|
||||||
|
|
||||||
|
# ANSI color indices (0-15) mapped to field names
|
||||||
|
_ANSI_COLORS = (
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"bright_black",
|
||||||
|
"bright_red",
|
||||||
|
"bright_green",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_blue",
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
)
|
||||||
|
|
||||||
|
# DA1 (Primary Device Attributes) query - used to detect unsupported terminals
|
||||||
|
# Terminals respond to DA1 even if they don't support OSC color queries
|
||||||
|
_DA1_QUERY = b"\x1b[c"
|
||||||
|
_DA1_RESPONSE_PREFIX = b"\x1b[?"
|
||||||
|
|
||||||
|
_OSC_RESPONSE_RE = re.compile(
|
||||||
|
rb"\x1b\](10|11|4;\d+);rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)(?:\x1b\\|\x07)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TerminalColors:
|
||||||
|
foreground: str | None = None
|
||||||
|
background: str | None = None
|
||||||
|
black: str | None = None
|
||||||
|
red: str | None = None
|
||||||
|
green: str | None = None
|
||||||
|
yellow: str | None = None
|
||||||
|
blue: str | None = None
|
||||||
|
magenta: str | None = None
|
||||||
|
cyan: str | None = None
|
||||||
|
white: str | None = None
|
||||||
|
bright_black: str | None = None
|
||||||
|
bright_red: str | None = None
|
||||||
|
bright_green: str | None = None
|
||||||
|
bright_yellow: str | None = None
|
||||||
|
bright_blue: str | None = None
|
||||||
|
bright_magenta: str | None = None
|
||||||
|
bright_cyan: str | None = None
|
||||||
|
bright_white: str | None = None
|
||||||
|
|
||||||
|
def is_complete(self) -> bool:
|
||||||
|
return all(getattr(self, f.name) is not None for f in fields(self))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_osc_query(code: str) -> bytes:
|
||||||
|
"""Build an OSC query: ESC ] <code> ; ? ST"""
|
||||||
|
return f"\x1b]{code};?\x1b\\".encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_color_queries() -> tuple[bytes, dict[bytes, str]]:
|
||||||
|
"""Build all OSC color queries and the mapping from OSC codes to field names."""
|
||||||
|
queries = bytearray()
|
||||||
|
osc_to_field: dict[bytes, str] = {}
|
||||||
|
|
||||||
|
# Foreground and background
|
||||||
|
queries.extend(_build_osc_query(_OSC_FOREGROUND))
|
||||||
|
queries.extend(_build_osc_query(_OSC_BACKGROUND))
|
||||||
|
osc_to_field[_OSC_FOREGROUND.encode()] = "foreground"
|
||||||
|
osc_to_field[_OSC_BACKGROUND.encode()] = "background"
|
||||||
|
|
||||||
|
# ANSI colors 0-15
|
||||||
|
for i, name in enumerate(_ANSI_COLORS):
|
||||||
|
code = f"4;{i}"
|
||||||
|
queries.extend(_build_osc_query(code))
|
||||||
|
osc_to_field[code.encode()] = name
|
||||||
|
|
||||||
|
return bytes(queries), osc_to_field
|
||||||
|
|
||||||
|
|
||||||
|
_COLOR_QUERIES, _OSC_TO_FIELD = _build_color_queries()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _raw_mode(fd: int) -> Iterator[None]:
|
||||||
|
"""Context manager to temporarily set terminal to raw mode."""
|
||||||
|
assert termios is not None # Only called on Unix so typing doesn't freak out
|
||||||
|
try:
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
except termios.error:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_settings = termios.tcgetattr(fd)
|
||||||
|
new_settings[3] &= ~(termios.ECHO | termios.ICANON)
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, new_settings)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
except termios.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rgb(r_hex: bytes, g_hex: bytes, b_hex: bytes) -> str | None:
|
||||||
|
"""Parse RGB hex values to a #rrggbb string.
|
||||||
|
|
||||||
|
Terminals return either 16-bit (4 hex chars) or 8-bit (2 hex chars) per channel.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if len(r_hex) == _RGB_16BIT_LEN:
|
||||||
|
r, g, b = int(r_hex[:2], 16), int(g_hex[:2], 16), int(b_hex[:2], 16)
|
||||||
|
elif len(r_hex) == _RGB_8BIT_LEN:
|
||||||
|
r, g, b = int(r_hex, 16), int(g_hex, 16), int(b_hex, 16)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_responses(fd: int, timeout: float = 1.0) -> bytes:
|
||||||
|
"""Read terminal responses until DA1 response or timeout.
|
||||||
|
|
||||||
|
Uses the DA1 trick: we send color queries followed by DA1. Since terminals
|
||||||
|
respond in order, receiving the DA1 response means all color responses
|
||||||
|
(if any) have been received.
|
||||||
|
"""
|
||||||
|
assert select is not None # Only called on Unix so typing doesn't freak out
|
||||||
|
response = bytearray()
|
||||||
|
while True:
|
||||||
|
ready, _, _ = select.select([fd], [], [], timeout)
|
||||||
|
if not ready:
|
||||||
|
break
|
||||||
|
chunk = os.read(fd, 4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response.extend(chunk)
|
||||||
|
# DA1 response received - we have all the color responses
|
||||||
|
if _DA1_RESPONSE_PREFIX in response:
|
||||||
|
break
|
||||||
|
return bytes(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_osc_responses(response: bytes) -> TerminalColors:
|
||||||
|
colors = TerminalColors()
|
||||||
|
for match in _OSC_RESPONSE_RE.finditer(response):
|
||||||
|
osc_code, r_hex, g_hex, b_hex = match.groups()
|
||||||
|
field = _OSC_TO_FIELD.get(osc_code)
|
||||||
|
if field and (color := _parse_rgb(r_hex, g_hex, b_hex)):
|
||||||
|
setattr(colors, field, color)
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
def _query_terminal_colors() -> TerminalColors:
|
||||||
|
if not _UNIX_AVAILABLE:
|
||||||
|
return TerminalColors()
|
||||||
|
|
||||||
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||||
|
return TerminalColors()
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _raw_mode(fd):
|
||||||
|
os.write(sys.stdout.fileno(), _COLOR_QUERIES + _DA1_QUERY)
|
||||||
|
response = _read_responses(fd)
|
||||||
|
return _parse_osc_responses(response)
|
||||||
|
except OSError:
|
||||||
|
return TerminalColors()
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_hex(r: int, g: int, b: int) -> str:
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def _adjust_brightness(hex_color: str, factor: float) -> str:
|
||||||
|
r, g, b = _hex_to_rgb(hex_color)
|
||||||
|
return _rgb_to_hex(
|
||||||
|
min(255, max(0, int(r * factor))),
|
||||||
|
min(255, max(0, int(g * factor))),
|
||||||
|
min(255, max(0, int(b * factor))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blend(c1: str, c2: str, ratio: float = 0.5) -> str:
|
||||||
|
r1, g1, b1 = _hex_to_rgb(c1)
|
||||||
|
r2, g2, b2 = _hex_to_rgb(c2)
|
||||||
|
return _rgb_to_hex(
|
||||||
|
int(r1 * (1 - ratio) + r2 * ratio),
|
||||||
|
int(g1 * (1 - ratio) + g2 * ratio),
|
||||||
|
int(b1 * (1 - ratio) + b2 * ratio),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _luminance(hex_color: str) -> float:
|
||||||
|
"""Calculate perceived luminance (0-1) using ITU-R BT.601 coefficients."""
|
||||||
|
r, g, b = _hex_to_rgb(hex_color)
|
||||||
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||||
|
|
||||||
|
|
||||||
|
def capture_terminal_theme() -> Theme | None:
|
||||||
|
colors = _query_terminal_colors()
|
||||||
|
|
||||||
|
if not colors.background or not colors.foreground:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_dark = _luminance(colors.background) < _LUMINANCE_THRESHOLD
|
||||||
|
fg = colors.foreground
|
||||||
|
bg = colors.background
|
||||||
|
|
||||||
|
surface = _adjust_brightness(bg, 1.15 if is_dark else 0.95)
|
||||||
|
panel = _blend(bg, surface)
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
name=TERMINAL_THEME_NAME,
|
||||||
|
primary=colors.blue or fg,
|
||||||
|
secondary=colors.cyan or fg,
|
||||||
|
warning=colors.yellow or fg,
|
||||||
|
error=colors.red or fg,
|
||||||
|
success=colors.green or fg,
|
||||||
|
accent=colors.magenta or fg,
|
||||||
|
foreground=fg,
|
||||||
|
background=bg,
|
||||||
|
surface=surface,
|
||||||
|
panel=panel,
|
||||||
|
dark=is_dark,
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from textual import events
|
from textual import events
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.binding import Binding, BindingType
|
from textual.binding import Binding, BindingType
|
||||||
|
|
@ -9,7 +10,7 @@ from textual.containers import Container, Vertical, VerticalScroll
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from vibe.cli.textual_ui.renderers import get_renderer
|
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,14 +30,14 @@ class ApprovalApp(Container):
|
||||||
]
|
]
|
||||||
|
|
||||||
class ApprovalGranted(Message):
|
class ApprovalGranted(Message):
|
||||||
def __init__(self, tool_name: str, tool_args: dict) -> None:
|
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.tool_name = tool_name
|
self.tool_name = tool_name
|
||||||
self.tool_args = tool_args
|
self.tool_args = tool_args
|
||||||
|
|
||||||
class ApprovalGrantedAlwaysTool(Message):
|
class ApprovalGrantedAlwaysTool(Message):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, tool_name: str, tool_args: dict, save_permanently: bool
|
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.tool_name = tool_name
|
self.tool_name = tool_name
|
||||||
|
|
@ -44,13 +45,13 @@ class ApprovalApp(Container):
|
||||||
self.save_permanently = save_permanently
|
self.save_permanently = save_permanently
|
||||||
|
|
||||||
class ApprovalRejected(Message):
|
class ApprovalRejected(Message):
|
||||||
def __init__(self, tool_name: str, tool_args: dict) -> None:
|
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.tool_name = tool_name
|
self.tool_name = tool_name
|
||||||
self.tool_args = tool_args
|
self.tool_args = tool_args
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, tool_name: str, tool_args: dict, workdir: str, config: VibeConfig
|
self, tool_name: str, tool_args: BaseModel, workdir: str, config: VibeConfig
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(id="approval-app")
|
super().__init__(id="approval-app")
|
||||||
self.tool_name = tool_name
|
self.tool_name = tool_name
|
||||||
|
|
@ -100,11 +101,8 @@ class ApprovalApp(Container):
|
||||||
if not self.tool_info_container:
|
if not self.tool_info_container:
|
||||||
return
|
return
|
||||||
|
|
||||||
renderer = get_renderer(self.tool_name)
|
approval_widget = get_approval_widget(self.tool_name, self.tool_args)
|
||||||
widget_class, data = renderer.get_approval_widget(self.tool_args)
|
|
||||||
|
|
||||||
await self.tool_info_container.remove_children()
|
await self.tool_info_container.remove_children()
|
||||||
approval_widget = widget_class(data)
|
|
||||||
await self.tool_info_container.mount(approval_widget)
|
await self.tool_info_container.mount(approval_widget)
|
||||||
|
|
||||||
def _update_options(self) -> None:
|
def _update_options(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,11 @@ class ChatInputContainer(Vertical):
|
||||||
def set_safety(self, safety: ModeSafety) -> None:
|
def set_safety(self, safety: ModeSafety) -> None:
|
||||||
self._safety = safety
|
self._safety = safety
|
||||||
|
|
||||||
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
|
try:
|
||||||
|
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
for border_class in SAFETY_BORDER_CLASSES.values():
|
for border_class in SAFETY_BORDER_CLASSES.values():
|
||||||
input_box.remove_class(border_class)
|
input_box.remove_class(border_class)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,14 @@ from textual.message import Message
|
||||||
from textual.theme import BUILTIN_THEMES
|
from textual.theme import BUILTIN_THEMES
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
|
|
||||||
THEMES = sorted(k for k in BUILTIN_THEMES if k != "textual-ansi")
|
_ALL_THEMES = [TERMINAL_THEME_NAME] + sorted(
|
||||||
|
k for k in BUILTIN_THEMES if k != "textual-ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SettingDefinition(TypedDict):
|
class SettingDefinition(TypedDict):
|
||||||
|
|
@ -46,12 +50,18 @@ class ConfigApp(Container):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.changes = changes
|
self.changes = changes
|
||||||
|
|
||||||
def __init__(self, config: VibeConfig) -> None:
|
def __init__(self, config: VibeConfig, *, has_terminal_theme: bool = False) -> None:
|
||||||
super().__init__(id="config-app")
|
super().__init__(id="config-app")
|
||||||
self.config = config
|
self.config = config
|
||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
self.changes: dict[str, str] = {}
|
self.changes: dict[str, str] = {}
|
||||||
|
|
||||||
|
themes = (
|
||||||
|
_ALL_THEMES
|
||||||
|
if has_terminal_theme
|
||||||
|
else [t for t in _ALL_THEMES if t != TERMINAL_THEME_NAME]
|
||||||
|
)
|
||||||
|
|
||||||
self.settings: list[SettingDefinition] = [
|
self.settings: list[SettingDefinition] = [
|
||||||
{
|
{
|
||||||
"key": "active_model",
|
"key": "active_model",
|
||||||
|
|
@ -64,7 +74,7 @@ class ConfigApp(Container):
|
||||||
"key": "textual_theme",
|
"key": "textual_theme",
|
||||||
"label": "Theme",
|
"label": "Theme",
|
||||||
"type": "cycle",
|
"type": "cycle",
|
||||||
"options": THEMES,
|
"options": themes,
|
||||||
"value": self.config.textual_theme,
|
"value": self.config.textual_theme,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class LoadingWidget(Static):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_default_status(self) -> str:
|
def _get_default_status(self) -> str:
|
||||||
return self._get_easter_egg() or "Thinking"
|
return self._get_easter_egg() or "Generating"
|
||||||
|
|
||||||
def _apply_easter_egg(self, status: str) -> str:
|
def _apply_easter_egg(self, status: str) -> str:
|
||||||
return self._get_easter_egg() or status
|
return self._get_easter_egg() or status
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from textual.containers import Horizontal, Vertical
|
||||||
from textual.widgets import Markdown, Static
|
from textual.widgets import Markdown, Static
|
||||||
from textual.widgets._markdown import MarkdownStream
|
from textual.widgets._markdown import MarkdownStream
|
||||||
|
|
||||||
|
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||||
|
|
||||||
|
|
||||||
class NonSelectableStatic(Static):
|
class NonSelectableStatic(Static):
|
||||||
@property
|
@property
|
||||||
|
|
@ -57,25 +59,18 @@ class UserMessage(Static):
|
||||||
self.remove_class("pending")
|
self.remove_class("pending")
|
||||||
|
|
||||||
|
|
||||||
class AssistantMessage(Static):
|
class StreamingMessageBase(Static):
|
||||||
def __init__(self, content: str) -> None:
|
def __init__(self, content: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_class("assistant-message")
|
|
||||||
self._content = content
|
self._content = content
|
||||||
self._markdown: Markdown | None = None
|
self._markdown: Markdown | None = None
|
||||||
self._stream: MarkdownStream | None = None
|
self._stream: MarkdownStream | None = None
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Horizontal(classes="assistant-message-container"):
|
|
||||||
yield NonSelectableStatic("● ", classes="assistant-message-dot")
|
|
||||||
with Vertical(classes="assistant-message-content"):
|
|
||||||
markdown = Markdown("")
|
|
||||||
self._markdown = markdown
|
|
||||||
yield markdown
|
|
||||||
|
|
||||||
def _get_markdown(self) -> Markdown:
|
def _get_markdown(self) -> Markdown:
|
||||||
if self._markdown is None:
|
if self._markdown is None:
|
||||||
self._markdown = self.query_one(Markdown)
|
raise RuntimeError(
|
||||||
|
"Markdown widget not initialized. compose() must be called first."
|
||||||
|
)
|
||||||
return self._markdown
|
return self._markdown
|
||||||
|
|
||||||
def _ensure_stream(self) -> MarkdownStream:
|
def _ensure_stream(self) -> MarkdownStream:
|
||||||
|
|
@ -88,11 +83,12 @@ class AssistantMessage(Static):
|
||||||
return
|
return
|
||||||
|
|
||||||
self._content += content
|
self._content += content
|
||||||
stream = self._ensure_stream()
|
if self._should_write_content():
|
||||||
await stream.write(content)
|
stream = self._ensure_stream()
|
||||||
|
await stream.write(content)
|
||||||
|
|
||||||
async def write_initial_content(self) -> None:
|
async def write_initial_content(self) -> None:
|
||||||
if self._content:
|
if self._content and self._should_write_content():
|
||||||
stream = self._ensure_stream()
|
stream = self._ensure_stream()
|
||||||
await stream.write(self._content)
|
await stream.write(self._content)
|
||||||
|
|
||||||
|
|
@ -103,6 +99,86 @@ class AssistantMessage(Static):
|
||||||
await self._stream.stop()
|
await self._stream.stop()
|
||||||
self._stream = None
|
self._stream = None
|
||||||
|
|
||||||
|
def _should_write_content(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantMessage(StreamingMessageBase):
|
||||||
|
def __init__(self, content: str) -> None:
|
||||||
|
super().__init__(content)
|
||||||
|
self.add_class("assistant-message")
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal(classes="assistant-message-container"):
|
||||||
|
yield NonSelectableStatic("● ", classes="assistant-message-dot")
|
||||||
|
with Vertical(classes="assistant-message-content"):
|
||||||
|
markdown = Markdown("")
|
||||||
|
self._markdown = markdown
|
||||||
|
yield markdown
|
||||||
|
|
||||||
|
|
||||||
|
class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
|
||||||
|
SPINNER_TYPE = SpinnerType.LINE
|
||||||
|
SPINNING_TEXT = "Thinking"
|
||||||
|
COMPLETED_TEXT = "Thought"
|
||||||
|
|
||||||
|
def __init__(self, content: str, collapsed: bool = True) -> None:
|
||||||
|
super().__init__(content)
|
||||||
|
self.add_class("reasoning-message")
|
||||||
|
self.collapsed = collapsed
|
||||||
|
self._indicator_widget: Static | None = None
|
||||||
|
self._triangle_widget: Static | None = None
|
||||||
|
self.init_spinner()
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(classes="reasoning-message-wrapper"):
|
||||||
|
with Horizontal(classes="reasoning-message-header"):
|
||||||
|
self._indicator_widget = NonSelectableStatic(
|
||||||
|
self._spinner.current_frame(), classes="reasoning-indicator"
|
||||||
|
)
|
||||||
|
yield self._indicator_widget
|
||||||
|
self._status_text_widget = Static(
|
||||||
|
self.SPINNING_TEXT, markup=False, classes="reasoning-collapsed-text"
|
||||||
|
)
|
||||||
|
yield self._status_text_widget
|
||||||
|
self._triangle_widget = NonSelectableStatic(
|
||||||
|
"▶" if self.collapsed else "▼", classes="reasoning-triangle"
|
||||||
|
)
|
||||||
|
yield self._triangle_widget
|
||||||
|
markdown = Markdown("", classes="reasoning-message-content")
|
||||||
|
markdown.display = not self.collapsed
|
||||||
|
self._markdown = markdown
|
||||||
|
yield markdown
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.start_spinner_timer()
|
||||||
|
|
||||||
|
async def on_click(self) -> None:
|
||||||
|
await self._toggle_collapsed()
|
||||||
|
|
||||||
|
async def _toggle_collapsed(self) -> None:
|
||||||
|
await self.set_collapsed(not self.collapsed)
|
||||||
|
|
||||||
|
def _should_write_content(self) -> bool:
|
||||||
|
return not self.collapsed
|
||||||
|
|
||||||
|
async def set_collapsed(self, collapsed: bool) -> None:
|
||||||
|
if self.collapsed == collapsed:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.collapsed = collapsed
|
||||||
|
if self._triangle_widget:
|
||||||
|
self._triangle_widget.update("▶" if collapsed else "▼")
|
||||||
|
if self._markdown:
|
||||||
|
self._markdown.display = not collapsed
|
||||||
|
if not collapsed and self._content:
|
||||||
|
if self._stream is not None:
|
||||||
|
await self._stream.stop()
|
||||||
|
self._stream = None
|
||||||
|
await self._markdown.update("")
|
||||||
|
stream = self._ensure_stream()
|
||||||
|
await stream.write(self._content)
|
||||||
|
|
||||||
|
|
||||||
class UserCommandMessage(Static):
|
class UserCommandMessage(Static):
|
||||||
def __init__(self, content: str) -> None:
|
def __init__(self, content: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
|
from collections.abc import Callable
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import ClassVar
|
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from textual.timer import Timer
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class HasSetInterval(Protocol):
|
||||||
|
def set_interval(
|
||||||
|
self, interval: float, callback: Callable[[], None], *, name: str | None = None
|
||||||
|
) -> Timer: ...
|
||||||
|
|
||||||
|
|
||||||
class Spinner(ABC):
|
class Spinner(ABC):
|
||||||
|
|
@ -85,3 +98,48 @@ _SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
|
||||||
def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
|
def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
|
||||||
spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
|
spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
|
||||||
return spinner_class()
|
return spinner_class()
|
||||||
|
|
||||||
|
|
||||||
|
class SpinnerMixin:
|
||||||
|
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
|
||||||
|
SPINNING_TEXT: ClassVar[str] = ""
|
||||||
|
COMPLETED_TEXT: ClassVar[str] = ""
|
||||||
|
|
||||||
|
_spinner: Spinner
|
||||||
|
_spinner_timer: Any
|
||||||
|
_is_spinning: bool
|
||||||
|
_indicator_widget: Static | None
|
||||||
|
_status_text_widget: Static | None
|
||||||
|
|
||||||
|
def init_spinner(self) -> None:
|
||||||
|
self._spinner = create_spinner(self.SPINNER_TYPE)
|
||||||
|
self._spinner_timer = None
|
||||||
|
self._is_spinning = True
|
||||||
|
self._status_text_widget = None
|
||||||
|
|
||||||
|
def start_spinner_timer(self) -> None:
|
||||||
|
if not isinstance(self, HasSetInterval):
|
||||||
|
raise TypeError(
|
||||||
|
"SpinnerMixin requires a class that implements HasSetInterval protocol"
|
||||||
|
)
|
||||||
|
self._spinner_timer = self.set_interval(0.1, self._update_spinner_frame)
|
||||||
|
|
||||||
|
def _update_spinner_frame(self) -> None:
|
||||||
|
if not self._is_spinning or not self._indicator_widget:
|
||||||
|
return
|
||||||
|
self._indicator_widget.update(self._spinner.next_frame())
|
||||||
|
|
||||||
|
def stop_spinning(self, success: bool = True) -> None:
|
||||||
|
self._is_spinning = False
|
||||||
|
if self._spinner_timer:
|
||||||
|
self._spinner_timer.stop()
|
||||||
|
self._spinner_timer = None
|
||||||
|
if self._indicator_widget:
|
||||||
|
if success:
|
||||||
|
self._indicator_widget.update("✓")
|
||||||
|
self._indicator_widget.add_class("success")
|
||||||
|
else:
|
||||||
|
self._indicator_widget.update("✕")
|
||||||
|
self._indicator_widget.add_class("error")
|
||||||
|
if self._status_text_widget and self.COMPLETED_TEXT:
|
||||||
|
self._status_text_widget.update(self.COMPLETED_TEXT)
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,17 @@ from textual.containers import Horizontal
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
|
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
|
||||||
from vibe.cli.textual_ui.widgets.spinner import Spinner, SpinnerType, create_spinner
|
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||||
|
|
||||||
|
|
||||||
class StatusMessage(Static):
|
class StatusMessage(SpinnerMixin, Static):
|
||||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
|
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
|
||||||
|
|
||||||
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
|
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._initial_text = initial_text
|
||||||
self._indicator_widget: Static | None = None
|
self._indicator_widget: Static | None = None
|
||||||
self._text_widget: Static | None = None
|
self._text_widget: Static | None = None
|
||||||
|
self.init_spinner()
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
|
|
@ -38,9 +35,9 @@ class StatusMessage(Static):
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.update_display()
|
self.update_display()
|
||||||
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
|
self.start_spinner_timer()
|
||||||
|
|
||||||
def _update_spinner(self) -> None:
|
def _update_spinner_frame(self) -> None:
|
||||||
if not self._is_spinning:
|
if not self._is_spinning:
|
||||||
return
|
return
|
||||||
self.update_display()
|
self.update_display()
|
||||||
|
|
@ -72,4 +69,7 @@ class StatusMessage(Static):
|
||||||
def stop_spinning(self, success: bool = True) -> None:
|
def stop_spinning(self, success: bool = True) -> None:
|
||||||
self._is_spinning = False
|
self._is_spinning = False
|
||||||
self.success = success
|
self.success = success
|
||||||
|
if self._spinner_timer:
|
||||||
|
self._spinner_timer.stop()
|
||||||
|
self._spinner_timer = None
|
||||||
self.update_display()
|
self.update_display()
|
||||||
|
|
|
||||||
|
|
@ -1,221 +1,287 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Vertical
|
from textual.containers import Vertical
|
||||||
from textual.widgets import Markdown, Static
|
from textual.widgets import Markdown, Static
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
||||||
|
from vibe.core.tools.builtins.bash import BashArgs, BashResult
|
||||||
|
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
|
||||||
|
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
|
||||||
|
from vibe.core.tools.builtins.search_replace import (
|
||||||
|
SEARCH_REPLACE_BLOCK_RE,
|
||||||
|
SearchReplaceArgs,
|
||||||
|
SearchReplaceResult,
|
||||||
|
)
|
||||||
|
from vibe.core.tools.builtins.todo import TodoArgs, TodoResult
|
||||||
|
from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
|
||||||
|
|
||||||
|
|
||||||
class ToolApprovalWidget(Vertical):
|
def _truncate_lines(content: str, max_lines: int) -> str:
|
||||||
def __init__(self, data: dict) -> None:
|
"""Truncate content to max_lines, adding indicator if truncated."""
|
||||||
|
lines = content.split("\n")
|
||||||
|
if len(lines) <= max_lines:
|
||||||
|
return content
|
||||||
|
remaining = len(lines) - max_lines
|
||||||
|
return "\n".join(lines[:max_lines] + [f"… ({remaining} more lines)"])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_search_replace_to_diff(content: str) -> list[str]:
|
||||||
|
"""Parse SEARCH/REPLACE blocks and generate unified diff lines."""
|
||||||
|
all_diff_lines: list[str] = []
|
||||||
|
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
|
||||||
|
if not matches:
|
||||||
|
return [content[:500]] if content else []
|
||||||
|
|
||||||
|
for i, (search_text, replace_text) in enumerate(matches):
|
||||||
|
if i > 0:
|
||||||
|
all_diff_lines.append("") # Separator between blocks
|
||||||
|
search_lines = search_text.strip().split("\n")
|
||||||
|
replace_lines = replace_text.strip().split("\n")
|
||||||
|
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
|
||||||
|
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
|
||||||
|
|
||||||
|
return all_diff_lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_diff_line(line: str) -> Static:
|
||||||
|
"""Render a single diff line with appropriate styling."""
|
||||||
|
if line.startswith("---") or line.startswith("+++"):
|
||||||
|
return Static(line, markup=False, classes="diff-header")
|
||||||
|
elif line.startswith("-"):
|
||||||
|
return Static(line, markup=False, classes="diff-removed")
|
||||||
|
elif line.startswith("+"):
|
||||||
|
return Static(line, markup=False, classes="diff-added")
|
||||||
|
elif line.startswith("@@"):
|
||||||
|
return Static(line, markup=False, classes="diff-range")
|
||||||
|
else:
|
||||||
|
return Static(line, markup=False, classes="diff-context")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
||||||
|
"""Base class for approval widgets with typed args."""
|
||||||
|
|
||||||
|
def __init__(self, args: TArgs) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.data = data
|
self.args = args
|
||||||
self.add_class("tool-approval-widget")
|
self.add_class("tool-approval-widget")
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
MAX_APPROVAL_MSG_SIZE = 150
|
MAX_MSG_SIZE = 150
|
||||||
|
for field_name in type(self.args).model_fields:
|
||||||
for key, value in self.data.items():
|
value = getattr(self.args, field_name)
|
||||||
|
if value is None or value in ("", []):
|
||||||
|
continue
|
||||||
value_str = str(value)
|
value_str = str(value)
|
||||||
if len(value_str) > MAX_APPROVAL_MSG_SIZE:
|
if len(value_str) > MAX_MSG_SIZE:
|
||||||
hidden = len(value_str) - MAX_APPROVAL_MSG_SIZE
|
hidden = len(value_str) - MAX_MSG_SIZE
|
||||||
value_str = (
|
value_str = value_str[:MAX_MSG_SIZE] + f"… ({hidden} more characters)"
|
||||||
value_str[:MAX_APPROVAL_MSG_SIZE] + f"… ({hidden} more characters)"
|
|
||||||
)
|
|
||||||
yield Static(
|
yield Static(
|
||||||
f"{key}: {value_str}", markup=False, classes="approval-description"
|
f"{field_name}: {value_str}",
|
||||||
|
markup=False,
|
||||||
|
classes="approval-description",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ToolResultWidget(Static):
|
class ToolResultWidget[TResult: BaseModel](Static):
|
||||||
|
"""Base class for result widgets with typed result."""
|
||||||
|
|
||||||
SHORTCUT = DEFAULT_TOOL_SHORTCUT
|
SHORTCUT = DEFAULT_TOOL_SHORTCUT
|
||||||
|
|
||||||
def __init__(self, data: dict, collapsed: bool = True) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
result: TResult | None,
|
||||||
|
success: bool,
|
||||||
|
message: str,
|
||||||
|
collapsed: bool = True,
|
||||||
|
warnings: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.data = data
|
self.result = result
|
||||||
|
self.success = success
|
||||||
|
self.message = message
|
||||||
self.collapsed = collapsed
|
self.collapsed = collapsed
|
||||||
|
self.warnings = warnings or []
|
||||||
self.add_class("tool-result-widget")
|
self.add_class("tool-result-widget")
|
||||||
|
|
||||||
def _hint(self) -> str:
|
def _hint(self) -> str:
|
||||||
action = "expand" if self.collapsed else "collapse"
|
action = "expand" if self.collapsed else "collapse"
|
||||||
return f"({self.SHORTCUT} to {action})"
|
return f"({self.SHORTCUT} to {action})"
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def _header(self) -> ComposeResult:
|
||||||
message = self.data.get("message", "")
|
"""Yield the standard header. Subclasses can call this then add content."""
|
||||||
|
|
||||||
if self.collapsed:
|
if self.collapsed:
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
yield Static(f"{self.message} {self._hint()}", markup=False)
|
||||||
else:
|
else:
|
||||||
yield Static(message, markup=False)
|
yield Static(self.message, markup=False)
|
||||||
|
|
||||||
if not self.collapsed and (details := self.data.get("details")):
|
def compose(self) -> ComposeResult:
|
||||||
for key, value in details.items():
|
"""Default: show message and optionally result fields."""
|
||||||
if value:
|
yield from self._header()
|
||||||
|
|
||||||
|
if not self.collapsed and self.result:
|
||||||
|
for field_name in type(self.result).model_fields:
|
||||||
|
value = getattr(self.result, field_name)
|
||||||
|
if value is not None and value not in ("", []):
|
||||||
yield Static(
|
yield Static(
|
||||||
f"{key}: {value}", markup=False, classes="tool-result-detail"
|
f"{field_name}: {value}",
|
||||||
|
markup=False,
|
||||||
|
classes="tool-result-detail",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BashApprovalWidget(ToolApprovalWidget):
|
class BashApprovalWidget(ToolApprovalWidget[BashArgs]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
command = self.data.get("command", "")
|
yield Markdown(f"```bash\n{self.args.command}\n```")
|
||||||
description = self.data.get("description", "")
|
|
||||||
|
|
||||||
if description:
|
|
||||||
yield Static(description, markup=False, classes="approval-description")
|
class BashResultWidget(ToolResultWidget[BashResult]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield from self._header()
|
||||||
|
if self.collapsed or not self.result:
|
||||||
|
return
|
||||||
|
yield Static(
|
||||||
|
f"returncode: {self.result.returncode}",
|
||||||
|
markup=False,
|
||||||
|
classes="tool-result-detail",
|
||||||
|
)
|
||||||
|
if self.result.stdout:
|
||||||
|
sep = "\n" if "\n" in self.result.stdout else " "
|
||||||
|
yield Static(
|
||||||
|
f"stdout:{sep}{self.result.stdout}",
|
||||||
|
markup=False,
|
||||||
|
classes="tool-result-detail",
|
||||||
|
)
|
||||||
|
if self.result.stderr:
|
||||||
|
sep = "\n" if "\n" in self.result.stderr else " "
|
||||||
|
yield Static(
|
||||||
|
f"stderr:{sep}{self.result.stderr}",
|
||||||
|
markup=False,
|
||||||
|
classes="tool-result-detail",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
path = Path(self.args.path)
|
||||||
|
file_extension = path.suffix.lstrip(".") or "text"
|
||||||
|
|
||||||
|
yield Static(
|
||||||
|
f"File: {self.args.path}", markup=False, classes="approval-description"
|
||||||
|
)
|
||||||
|
yield Static("")
|
||||||
|
yield Markdown(f"```{file_extension}\n{self.args.content}\n```")
|
||||||
|
|
||||||
|
|
||||||
|
class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield from self._header()
|
||||||
|
if self.collapsed or not self.result:
|
||||||
|
return
|
||||||
|
yield Static(
|
||||||
|
f"Path: {self.result.path}", markup=False, classes="tool-result-detail"
|
||||||
|
)
|
||||||
|
yield Static(
|
||||||
|
f"Bytes: {self.result.bytes_written}",
|
||||||
|
markup=False,
|
||||||
|
classes="tool-result-detail",
|
||||||
|
)
|
||||||
|
if self.result.content:
|
||||||
yield Static("")
|
yield Static("")
|
||||||
|
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||||
yield Markdown(f"```bash\n{command}\n```")
|
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
|
||||||
|
|
||||||
|
|
||||||
class BashResultWidget(ToolResultWidget):
|
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
message = self.data.get("message", "")
|
yield Static(
|
||||||
|
f"File: {self.args.file_path}", markup=False, classes="approval-description"
|
||||||
if self.collapsed:
|
)
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
|
||||||
else:
|
|
||||||
yield Static(message, markup=False)
|
|
||||||
|
|
||||||
if not self.collapsed and (details := self.data.get("details")):
|
|
||||||
for key, value in details.items():
|
|
||||||
if value:
|
|
||||||
yield Static(
|
|
||||||
f"{key}: {value}", markup=False, classes="tool-result-detail"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WriteFileApprovalWidget(ToolApprovalWidget):
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
path = self.data.get("path", "")
|
|
||||||
content = self.data.get("content", "")
|
|
||||||
file_extension = self.data.get("file_extension", "text")
|
|
||||||
|
|
||||||
yield Static(f"File: {path}", markup=False, classes="approval-description")
|
|
||||||
yield Static("")
|
yield Static("")
|
||||||
|
|
||||||
yield Markdown(f"```{file_extension}\n{content}\n```")
|
diff_lines = parse_search_replace_to_diff(self.args.content)
|
||||||
|
for line in diff_lines:
|
||||||
|
yield render_diff_line(line)
|
||||||
|
|
||||||
|
|
||||||
class WriteFileResultWidget(ToolResultWidget):
|
class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
MAX_LINES = 10
|
yield from self._header()
|
||||||
message = self.data.get("message", "")
|
if self.collapsed or not self.result:
|
||||||
|
return
|
||||||
if self.collapsed:
|
yield Static(
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
f"File: {self.result.file}", markup=False, classes="tool-result-detail"
|
||||||
else:
|
)
|
||||||
yield Static(message, markup=False)
|
yield Static(
|
||||||
|
f"Blocks applied: {self.result.blocks_applied}",
|
||||||
if not self.collapsed:
|
markup=False,
|
||||||
if path := self.data.get("path"):
|
classes="tool-result-detail",
|
||||||
yield Static(
|
)
|
||||||
f"Path: {path}", markup=False, classes="tool-result-detail"
|
yield Static(
|
||||||
)
|
f"Lines changed: {self.result.lines_changed}",
|
||||||
|
markup=False,
|
||||||
if bytes_written := self.data.get("bytes_written"):
|
classes="tool-result-detail",
|
||||||
yield Static(
|
)
|
||||||
f"Bytes: {bytes_written}",
|
for warning in self.result.warnings:
|
||||||
markup=False,
|
yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning")
|
||||||
classes="tool-result-detail",
|
if self.result.content:
|
||||||
)
|
|
||||||
|
|
||||||
if content := self.data.get("content"):
|
|
||||||
yield Static("")
|
|
||||||
file_extension = self.data.get("file_extension", "text")
|
|
||||||
|
|
||||||
lines = content.split("\n")
|
|
||||||
total_lines = len(lines)
|
|
||||||
|
|
||||||
if total_lines > MAX_LINES:
|
|
||||||
shown_lines = lines[:MAX_LINES]
|
|
||||||
remaining = total_lines - MAX_LINES
|
|
||||||
truncated_content = "\n".join(
|
|
||||||
shown_lines + [f"… ({remaining} more lines)"]
|
|
||||||
)
|
|
||||||
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
|
|
||||||
else:
|
|
||||||
yield Markdown(f"```{file_extension}\n{content}\n```")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchReplaceApprovalWidget(ToolApprovalWidget):
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
file_path = self.data.get("file_path", "")
|
|
||||||
diff_lines = self.data.get("diff_lines", [])
|
|
||||||
|
|
||||||
yield Static(f"File: {file_path}", markup=False, classes="approval-description")
|
|
||||||
yield Static("")
|
|
||||||
|
|
||||||
if diff_lines:
|
|
||||||
for line in diff_lines:
|
|
||||||
if line.startswith("---") or line.startswith("+++"):
|
|
||||||
yield Static(line, markup=False, classes="diff-header")
|
|
||||||
elif line.startswith("-"):
|
|
||||||
yield Static(line, markup=False, classes="diff-removed")
|
|
||||||
elif line.startswith("+"):
|
|
||||||
yield Static(line, markup=False, classes="diff-added")
|
|
||||||
elif line.startswith("@@"):
|
|
||||||
yield Static(line, markup=False, classes="diff-range")
|
|
||||||
else:
|
|
||||||
yield Static(line, markup=False, classes="diff-context")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchReplaceResultWidget(ToolResultWidget):
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
message = self.data.get("message", "")
|
|
||||||
|
|
||||||
if self.collapsed:
|
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
|
||||||
else:
|
|
||||||
yield Static(message, markup=False)
|
|
||||||
|
|
||||||
if not self.collapsed and (diff_lines := self.data.get("diff_lines")):
|
|
||||||
yield Static("")
|
yield Static("")
|
||||||
for line in diff_lines:
|
for line in parse_search_replace_to_diff(self.result.content):
|
||||||
if line.startswith("---") or line.startswith("+++"):
|
yield render_diff_line(line)
|
||||||
yield Static(line, markup=False, classes="diff-header")
|
|
||||||
elif line.startswith("-"):
|
|
||||||
yield Static(line, markup=False, classes="diff-removed")
|
|
||||||
elif line.startswith("+"):
|
|
||||||
yield Static(line, markup=False, classes="diff-added")
|
|
||||||
elif line.startswith("@@"):
|
|
||||||
yield Static(line, markup=False, classes="diff-range")
|
|
||||||
else:
|
|
||||||
yield Static(line, markup=False, classes="diff-context")
|
|
||||||
|
|
||||||
|
|
||||||
class TodoApprovalWidget(ToolApprovalWidget):
|
class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
description = self.data.get("description", "")
|
yield Static(
|
||||||
if description:
|
f"Action: {self.args.action}", markup=False, classes="approval-description"
|
||||||
yield Static(description, markup=False, classes="approval-description")
|
)
|
||||||
|
if self.args.todos:
|
||||||
|
yield Static(
|
||||||
|
f"Todos: {len(self.args.todos)} items",
|
||||||
|
markup=False,
|
||||||
|
classes="approval-description",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TodoResultWidget(ToolResultWidget):
|
class TodoResultWidget(ToolResultWidget[TodoResult]):
|
||||||
SHORTCUT = TOOL_SHORTCUTS["todo"]
|
SHORTCUT = TOOL_SHORTCUTS["todo"]
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
message = self.data.get("message", "")
|
|
||||||
|
|
||||||
if self.collapsed:
|
if self.collapsed:
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
yield Static(f"{self.message} {self._hint()}", markup=False)
|
||||||
else:
|
else:
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
yield Static(f"{self.message} {self._hint()}", markup=False)
|
||||||
yield Static("")
|
yield Static("")
|
||||||
|
|
||||||
by_status = self.data.get("todos_by_status", {})
|
if not self.result or not self.result.todos:
|
||||||
if not any(by_status.values()):
|
|
||||||
yield Static("No todos", markup=False, classes="todo-empty")
|
yield Static("No todos", markup=False, classes="todo-empty")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Group todos by status
|
||||||
|
by_status: dict[str, list] = {
|
||||||
|
"in_progress": [],
|
||||||
|
"pending": [],
|
||||||
|
"completed": [],
|
||||||
|
"cancelled": [],
|
||||||
|
}
|
||||||
|
for todo in self.result.todos:
|
||||||
|
status = (
|
||||||
|
todo.status.value
|
||||||
|
if hasattr(todo.status, "value")
|
||||||
|
else str(todo.status)
|
||||||
|
)
|
||||||
|
if status in by_status:
|
||||||
|
by_status[status].append(todo)
|
||||||
|
|
||||||
for status in ["in_progress", "pending", "completed", "cancelled"]:
|
for status in ["in_progress", "pending", "completed", "cancelled"]:
|
||||||
todos = by_status.get(status, [])
|
for todo in by_status[status]:
|
||||||
for todo in todos:
|
|
||||||
content = todo.get("content", "")
|
|
||||||
icon = self._get_status_icon(status)
|
icon = self._get_status_icon(status)
|
||||||
yield Static(
|
yield Static(
|
||||||
f"{icon} {content}", markup=False, classes=f"todo-{status}"
|
f"{icon} {todo.content}", markup=False, classes=f"todo-{status}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_status_icon(self, status: str) -> str:
|
def _get_status_icon(self, status: str) -> str:
|
||||||
|
|
@ -223,95 +289,103 @@ class TodoResultWidget(ToolResultWidget):
|
||||||
return icons.get(status, "☐")
|
return icons.get(status, "☐")
|
||||||
|
|
||||||
|
|
||||||
class ReadFileApprovalWidget(ToolApprovalWidget):
|
class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
for key, value in self.data.items():
|
yield Static(
|
||||||
if value:
|
f"path: {self.args.path}", markup=False, classes="approval-description"
|
||||||
yield Static(
|
)
|
||||||
f"{key}: {value}", markup=False, classes="approval-description"
|
if self.args.offset > 0:
|
||||||
)
|
yield Static(
|
||||||
|
f"offset: {self.args.offset}",
|
||||||
|
markup=False,
|
||||||
|
classes="approval-description",
|
||||||
|
)
|
||||||
|
if self.args.limit is not None:
|
||||||
|
yield Static(
|
||||||
|
f"limit: {self.args.limit}",
|
||||||
|
markup=False,
|
||||||
|
classes="approval-description",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReadFileResultWidget(ToolResultWidget):
|
class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
MAX_LINES = 10
|
yield from self._header()
|
||||||
message = self.data.get("message", "")
|
|
||||||
|
|
||||||
if self.collapsed:
|
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
|
||||||
else:
|
|
||||||
yield Static(message, markup=False)
|
|
||||||
|
|
||||||
if self.collapsed:
|
if self.collapsed:
|
||||||
return
|
return
|
||||||
|
if self.result:
|
||||||
if path := self.data.get("path"):
|
yield Static(
|
||||||
yield Static(f"Path: {path}", markup=False, classes="tool-result-detail")
|
f"Path: {self.result.path}", markup=False, classes="tool-result-detail"
|
||||||
|
)
|
||||||
if warnings := self.data.get("warnings"):
|
for warning in self.warnings:
|
||||||
for warning in warnings:
|
yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning")
|
||||||
yield Static(
|
if self.result and self.result.content:
|
||||||
f"⚠ {warning}", markup=False, classes="tool-result-warning"
|
|
||||||
)
|
|
||||||
|
|
||||||
if content := self.data.get("content"):
|
|
||||||
yield Static("")
|
yield Static("")
|
||||||
file_extension = self.data.get("file_extension", "text")
|
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||||
|
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
|
||||||
lines = content.split("\n")
|
|
||||||
total_lines = len(lines)
|
|
||||||
|
|
||||||
if total_lines > MAX_LINES:
|
|
||||||
shown_lines = lines[:MAX_LINES]
|
|
||||||
remaining = total_lines - MAX_LINES
|
|
||||||
truncated_content = "\n".join(
|
|
||||||
shown_lines + [f"… ({remaining} more lines)"]
|
|
||||||
)
|
|
||||||
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
|
|
||||||
else:
|
|
||||||
yield Markdown(f"```{file_extension}\n{content}\n```")
|
|
||||||
|
|
||||||
|
|
||||||
class GrepApprovalWidget(ToolApprovalWidget):
|
class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
for key, value in self.data.items():
|
yield Static(
|
||||||
if value:
|
f"pattern: {self.args.pattern}",
|
||||||
yield Static(
|
markup=False,
|
||||||
f"{key}: {value!s}", classes="approval-description", markup=False
|
classes="approval-description",
|
||||||
)
|
)
|
||||||
|
yield Static(
|
||||||
|
f"path: {self.args.path}", markup=False, classes="approval-description"
|
||||||
|
)
|
||||||
|
if self.args.max_matches is not None:
|
||||||
|
yield Static(
|
||||||
|
f"max_matches: {self.args.max_matches}",
|
||||||
|
markup=False,
|
||||||
|
classes="approval-description",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GrepResultWidget(ToolResultWidget):
|
class GrepResultWidget(ToolResultWidget[GrepResult]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
MAX_LINES = 30
|
yield from self._header()
|
||||||
message = self.data.get("message", "")
|
|
||||||
|
|
||||||
if self.collapsed:
|
|
||||||
yield Static(f"{message} {self._hint()}", markup=False)
|
|
||||||
else:
|
|
||||||
yield Static(message, markup=False)
|
|
||||||
|
|
||||||
if self.collapsed:
|
if self.collapsed:
|
||||||
return
|
return
|
||||||
|
for warning in self.warnings:
|
||||||
if warnings := self.data.get("warnings"):
|
yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning")
|
||||||
for warning in warnings:
|
if self.result and self.result.matches:
|
||||||
yield Static(
|
|
||||||
f"⚠ {warning}", classes="tool-result-warning", markup=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if matches := self.data.get("matches"):
|
|
||||||
yield Static("")
|
yield Static("")
|
||||||
|
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
|
||||||
|
|
||||||
lines = matches.split("\n")
|
|
||||||
total_lines = len(lines)
|
|
||||||
|
|
||||||
if total_lines > MAX_LINES:
|
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
|
||||||
shown_lines = lines[:MAX_LINES]
|
"bash": BashApprovalWidget,
|
||||||
remaining = total_lines - MAX_LINES
|
"read_file": ReadFileApprovalWidget,
|
||||||
truncated_content = "\n".join(
|
"write_file": WriteFileApprovalWidget,
|
||||||
shown_lines + [f"… ({remaining} more lines)"]
|
"search_replace": SearchReplaceApprovalWidget,
|
||||||
)
|
"grep": GrepApprovalWidget,
|
||||||
yield Markdown(f"```\n{truncated_content}\n```")
|
"todo": TodoApprovalWidget,
|
||||||
else:
|
}
|
||||||
yield Markdown(f"```\n{matches}\n```")
|
|
||||||
|
RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
|
||||||
|
"bash": BashResultWidget,
|
||||||
|
"read_file": ReadFileResultWidget,
|
||||||
|
"write_file": WriteFileResultWidget,
|
||||||
|
"search_replace": SearchReplaceResultWidget,
|
||||||
|
"grep": GrepResultWidget,
|
||||||
|
"todo": TodoResultWidget,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_approval_widget(tool_name: str, args: BaseModel) -> ToolApprovalWidget:
|
||||||
|
widget_class = APPROVAL_WIDGETS.get(tool_name, ToolApprovalWidget)
|
||||||
|
return widget_class(args)
|
||||||
|
|
||||||
|
|
||||||
|
def get_result_widget(
|
||||||
|
tool_name: str,
|
||||||
|
result: BaseModel | None,
|
||||||
|
success: bool,
|
||||||
|
message: str,
|
||||||
|
collapsed: bool = True,
|
||||||
|
warnings: list[str] | None = None,
|
||||||
|
) -> ToolResultWidget:
|
||||||
|
widget_class = RESULT_WIDGETS.get(tool_name, ToolResultWidget)
|
||||||
|
return widget_class(result, success, message, collapsed, warnings)
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ from textual.app import ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from vibe.cli.textual_ui.renderers import get_renderer
|
|
||||||
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
|
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.status_message import StatusMessage
|
||||||
|
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
|
||||||
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
||||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||||
|
|
@ -129,11 +129,16 @@ class ToolResultMessage(Static):
|
||||||
|
|
||||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||||
display = adapter.get_result_display(self._event)
|
display = adapter.get_result_display(self._event)
|
||||||
renderer = get_renderer(self._event.tool_name)
|
|
||||||
widget_class, data = renderer.get_result_widget(display, self.collapsed)
|
widget = get_result_widget(
|
||||||
await self._content_container.mount(
|
self._event.tool_name,
|
||||||
widget_class(data, collapsed=self.collapsed)
|
self._event.result,
|
||||||
|
success=display.success,
|
||||||
|
message=display.message,
|
||||||
|
collapsed=self.collapsed,
|
||||||
|
warnings=display.warnings,
|
||||||
)
|
)
|
||||||
|
await self._content_container.mount(widget)
|
||||||
|
|
||||||
async def _render_simple(self) -> None:
|
async def _render_simple(self) -> None:
|
||||||
if self._content_container is None:
|
if self._content_container is None:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import asyncio
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
import time
|
import time
|
||||||
from typing import Any, cast
|
from typing import cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -28,6 +28,7 @@ from vibe.core.middleware import (
|
||||||
)
|
)
|
||||||
from vibe.core.modes import AgentMode
|
from vibe.core.modes import AgentMode
|
||||||
from vibe.core.prompts import UtilityPrompt
|
from vibe.core.prompts import UtilityPrompt
|
||||||
|
from vibe.core.skills.manager import SkillManager
|
||||||
from vibe.core.system_prompt import get_universal_system_prompt
|
from vibe.core.system_prompt import get_universal_system_prompt
|
||||||
from vibe.core.tools.base import (
|
from vibe.core.tools.base import (
|
||||||
BaseTool,
|
BaseTool,
|
||||||
|
|
@ -48,6 +49,7 @@ from vibe.core.types import (
|
||||||
LLMChunk,
|
LLMChunk,
|
||||||
LLMMessage,
|
LLMMessage,
|
||||||
LLMUsage,
|
LLMUsage,
|
||||||
|
ReasoningEvent,
|
||||||
Role,
|
Role,
|
||||||
SyncApprovalCallback,
|
SyncApprovalCallback,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
|
|
@ -103,6 +105,7 @@ class Agent:
|
||||||
self._max_price = max_price
|
self._max_price = max_price
|
||||||
|
|
||||||
self.tool_manager = ToolManager(config)
|
self.tool_manager = ToolManager(config)
|
||||||
|
self.skill_manager = SkillManager(config)
|
||||||
self.format_handler = APIToolFormatHandler()
|
self.format_handler = APIToolFormatHandler()
|
||||||
|
|
||||||
self.backend_factory = lambda: backend or self._select_backend()
|
self.backend_factory = lambda: backend or self._select_backend()
|
||||||
|
|
@ -114,7 +117,9 @@ class Agent:
|
||||||
self.enable_streaming = enable_streaming
|
self.enable_streaming = enable_streaming
|
||||||
self._setup_middleware()
|
self._setup_middleware()
|
||||||
|
|
||||||
system_prompt = get_universal_system_prompt(self.tool_manager, config)
|
system_prompt = get_universal_system_prompt(
|
||||||
|
self.tool_manager, config, self.skill_manager
|
||||||
|
)
|
||||||
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
|
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
|
||||||
|
|
||||||
if self.message_observer:
|
if self.message_observer:
|
||||||
|
|
@ -308,24 +313,49 @@ class Agent:
|
||||||
async for event in self._handle_tool_calls(resolved):
|
async for event in self._handle_tool_calls(resolved):
|
||||||
yield event
|
yield event
|
||||||
|
|
||||||
async def _stream_assistant_events(self) -> AsyncGenerator[AssistantEvent]:
|
async def _stream_assistant_events(
|
||||||
batched_chunk = LLMChunk(message=LLMMessage(role=Role.assistant))
|
self,
|
||||||
|
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
|
||||||
|
content_buffer = ""
|
||||||
|
reasoning_buffer = ""
|
||||||
chunks_with_content = 0
|
chunks_with_content = 0
|
||||||
|
chunks_with_reasoning = 0
|
||||||
BATCH_SIZE = 5
|
BATCH_SIZE = 5
|
||||||
|
|
||||||
async for chunk in self._chat_streaming():
|
async for chunk in self._chat_streaming():
|
||||||
batched_chunk += chunk
|
if chunk.message.reasoning_content:
|
||||||
|
if content_buffer:
|
||||||
|
yield AssistantEvent(content=content_buffer)
|
||||||
|
content_buffer = ""
|
||||||
|
chunks_with_content = 0
|
||||||
|
|
||||||
|
reasoning_buffer += chunk.message.reasoning_content
|
||||||
|
chunks_with_reasoning += 1
|
||||||
|
|
||||||
|
if chunks_with_reasoning >= BATCH_SIZE:
|
||||||
|
yield ReasoningEvent(content=reasoning_buffer)
|
||||||
|
reasoning_buffer = ""
|
||||||
|
chunks_with_reasoning = 0
|
||||||
|
|
||||||
if chunk.message.content:
|
if chunk.message.content:
|
||||||
|
if reasoning_buffer:
|
||||||
|
yield ReasoningEvent(content=reasoning_buffer)
|
||||||
|
reasoning_buffer = ""
|
||||||
|
chunks_with_reasoning = 0
|
||||||
|
|
||||||
|
content_buffer += chunk.message.content
|
||||||
chunks_with_content += 1
|
chunks_with_content += 1
|
||||||
|
|
||||||
if chunks_with_content >= BATCH_SIZE:
|
if chunks_with_content >= BATCH_SIZE:
|
||||||
yield AssistantEvent(content=cast(str, batched_chunk.message.content))
|
yield AssistantEvent(content=content_buffer)
|
||||||
batched_chunk = LLMChunk(message=LLMMessage(role=Role.assistant))
|
content_buffer = ""
|
||||||
chunks_with_content = 0
|
chunks_with_content = 0
|
||||||
|
|
||||||
if batched_chunk.message.content:
|
if reasoning_buffer:
|
||||||
yield AssistantEvent(content=batched_chunk.message.content)
|
yield ReasoningEvent(content=reasoning_buffer)
|
||||||
|
|
||||||
|
if content_buffer:
|
||||||
|
yield AssistantEvent(content=content_buffer)
|
||||||
|
|
||||||
async def _get_assistant_event(self) -> AssistantEvent:
|
async def _get_assistant_event(self) -> AssistantEvent:
|
||||||
llm_result = await self._chat()
|
llm_result = await self._chat()
|
||||||
|
|
@ -381,7 +411,7 @@ class Agent:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
decision = await self._should_execute_tool(
|
decision = await self._should_execute_tool(
|
||||||
tool_instance, tool_call.args_dict, tool_call_id
|
tool_instance, tool_call.validated_args, tool_call_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if decision.verdict == ToolExecutionResponse.SKIP:
|
if decision.verdict == ToolExecutionResponse.SKIP:
|
||||||
|
|
@ -605,15 +635,12 @@ class Agent:
|
||||||
self.stats.tokens_per_second = usage.completion_tokens / time_seconds
|
self.stats.tokens_per_second = usage.completion_tokens / time_seconds
|
||||||
|
|
||||||
async def _should_execute_tool(
|
async def _should_execute_tool(
|
||||||
self, tool: BaseTool, args: dict[str, Any], tool_call_id: str
|
self, tool: BaseTool, args: BaseModel, tool_call_id: str
|
||||||
) -> ToolDecision:
|
) -> ToolDecision:
|
||||||
if self.auto_approve:
|
if self.auto_approve:
|
||||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||||
|
|
||||||
args_model, _ = tool._get_tool_args_results()
|
allowlist_denylist_result = tool.check_allowlist_denylist(args)
|
||||||
validated_args = args_model.model_validate(args)
|
|
||||||
|
|
||||||
allowlist_denylist_result = tool.check_allowlist_denylist(validated_args)
|
|
||||||
if allowlist_denylist_result == ToolPermission.ALWAYS:
|
if allowlist_denylist_result == ToolPermission.ALWAYS:
|
||||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||||
elif allowlist_denylist_result == ToolPermission.NEVER:
|
elif allowlist_denylist_result == ToolPermission.NEVER:
|
||||||
|
|
@ -638,7 +665,7 @@ class Agent:
|
||||||
return await self._ask_approval(tool_name, args, tool_call_id)
|
return await self._ask_approval(tool_name, args, tool_call_id)
|
||||||
|
|
||||||
async def _ask_approval(
|
async def _ask_approval(
|
||||||
self, tool_name: str, args: dict[str, Any], tool_call_id: str
|
self, tool_name: str, args: BaseModel, tool_call_id: str
|
||||||
) -> ToolDecision:
|
) -> ToolDecision:
|
||||||
if not self.approval_callback:
|
if not self.approval_callback:
|
||||||
return ToolDecision(
|
return ToolDecision(
|
||||||
|
|
@ -844,8 +871,11 @@ class Agent:
|
||||||
self._max_price = max_price
|
self._max_price = max_price
|
||||||
|
|
||||||
self.tool_manager = ToolManager(self.config)
|
self.tool_manager = ToolManager(self.config)
|
||||||
|
self.skill_manager = SkillManager(self.config)
|
||||||
|
|
||||||
new_system_prompt = get_universal_system_prompt(self.tool_manager, self.config)
|
new_system_prompt = get_universal_system_prompt(
|
||||||
|
self.tool_manager, self.config, self.skill_manager
|
||||||
|
)
|
||||||
self.messages = [LLMMessage(role=Role.system, content=new_system_prompt)]
|
self.messages = [LLMMessage(role=Role.system, content=new_system_prompt)]
|
||||||
|
|
||||||
if preserved_messages:
|
if preserved_messages:
|
||||||
|
|
|
||||||
|
|
@ -273,12 +273,12 @@ DEFAULT_MODELS = [
|
||||||
|
|
||||||
class VibeConfig(BaseSettings):
|
class VibeConfig(BaseSettings):
|
||||||
active_model: str = "devstral-2"
|
active_model: str = "devstral-2"
|
||||||
|
textual_theme: str = "terminal"
|
||||||
vim_keybindings: bool = False
|
vim_keybindings: bool = False
|
||||||
disable_welcome_banner_animation: bool = False
|
disable_welcome_banner_animation: bool = False
|
||||||
displayed_workdir: str = ""
|
displayed_workdir: str = ""
|
||||||
auto_compact_threshold: int = 200_000
|
auto_compact_threshold: int = 200_000
|
||||||
context_warnings: bool = False
|
context_warnings: bool = False
|
||||||
textual_theme: str = "textual-dark"
|
|
||||||
instructions: str = ""
|
instructions: str = ""
|
||||||
workdir: Path | None = Field(default=None, exclude=True)
|
workdir: Path | None = Field(default=None, exclude=True)
|
||||||
system_prompt_id: str = "cli"
|
system_prompt_id: str = "cli"
|
||||||
|
|
@ -296,7 +296,7 @@ class VibeConfig(BaseSettings):
|
||||||
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
|
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
|
||||||
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
|
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
|
||||||
tools: dict[str, BaseToolConfig] = Field(default_factory=dict)
|
tools: dict[str, BaseToolConfig] = Field(default_factory=dict)
|
||||||
tool_paths: list[str] = Field(
|
tool_paths: list[Path] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description=(
|
description=(
|
||||||
"Additional directories to search for custom tools. "
|
"Additional directories to search for custom tools. "
|
||||||
|
|
@ -326,6 +326,14 @@ class VibeConfig(BaseSettings):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
skill_paths: list[Path] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description=(
|
||||||
|
"Additional directories to search for skills. "
|
||||||
|
"Each path may be absolute or relative to the current working directory."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
|
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
|
||||||
)
|
)
|
||||||
|
|
@ -418,6 +426,20 @@ class VibeConfig(BaseSettings):
|
||||||
pass
|
pass
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@field_validator("tool_paths", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _expand_tool_paths(cls, v: Any) -> list[Path]:
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
return [Path(p).expanduser().resolve() for p in v]
|
||||||
|
|
||||||
|
@field_validator("skill_paths", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _expand_skill_paths(cls, v: Any) -> list[Path]:
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
return [Path(p).expanduser().resolve() for p in v]
|
||||||
|
|
||||||
@field_validator("workdir", mode="before")
|
@field_validator("workdir", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _expand_workdir(cls, v: Any) -> Path | None:
|
def _expand_workdir(cls, v: Any) -> Path | None:
|
||||||
|
|
@ -517,26 +539,7 @@ class VibeConfig(BaseSettings):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _migrate(cls) -> None:
|
def _migrate(cls) -> None:
|
||||||
if not CONFIG_FILE.path.is_file():
|
pass
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with CONFIG_FILE.path.open("rb") as f:
|
|
||||||
config = tomllib.load(f)
|
|
||||||
except (OSError, tomllib.TOMLDecodeError):
|
|
||||||
return
|
|
||||||
|
|
||||||
needs_save = False
|
|
||||||
|
|
||||||
if (
|
|
||||||
"auto_compact_threshold" not in config
|
|
||||||
or config["auto_compact_threshold"] == 100_000 # noqa: PLR2004
|
|
||||||
):
|
|
||||||
config["auto_compact_threshold"] = 200_000
|
|
||||||
needs_save = True
|
|
||||||
|
|
||||||
if needs_save:
|
|
||||||
cls.dump_config(config)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, agent: str | None = None, **overrides: Any) -> VibeConfig:
|
def load(cls, agent: str | None = None, **overrides: Any) -> VibeConfig:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import types
|
import types
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import mistralai
|
import mistralai
|
||||||
|
|
@ -27,6 +27,11 @@ if TYPE_CHECKING:
|
||||||
from vibe.core.config import ModelConfig, ProviderConfig
|
from vibe.core.config import ModelConfig, ProviderConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedContent(NamedTuple):
|
||||||
|
content: Content
|
||||||
|
reasoning_content: Content | None
|
||||||
|
|
||||||
|
|
||||||
class MistralMapper:
|
class MistralMapper:
|
||||||
def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
|
def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
|
||||||
match msg.role:
|
match msg.role:
|
||||||
|
|
@ -35,9 +40,25 @@ class MistralMapper:
|
||||||
case Role.user:
|
case Role.user:
|
||||||
return mistralai.UserMessage(role="user", content=msg.content)
|
return mistralai.UserMessage(role="user", content=msg.content)
|
||||||
case Role.assistant:
|
case Role.assistant:
|
||||||
|
content: mistralai.AssistantMessageContent
|
||||||
|
if msg.reasoning_content:
|
||||||
|
content = [
|
||||||
|
mistralai.ThinkChunk(
|
||||||
|
type="thinking",
|
||||||
|
thinking=[
|
||||||
|
mistralai.TextChunk(
|
||||||
|
type="text", text=msg.reasoning_content
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
mistralai.TextChunk(type="text", text=msg.content or ""),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
content = msg.content or ""
|
||||||
|
|
||||||
return mistralai.AssistantMessage(
|
return mistralai.AssistantMessage(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=msg.content,
|
content=content,
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
mistralai.ToolCall(
|
mistralai.ToolCall(
|
||||||
function=mistralai.FunctionCall(
|
function=mistralai.FunctionCall(
|
||||||
|
|
@ -80,20 +101,37 @@ class MistralMapper:
|
||||||
function=mistralai.FunctionName(name=tool_choice.function.name),
|
function=mistralai.FunctionName(name=tool_choice.function.name),
|
||||||
)
|
)
|
||||||
|
|
||||||
def parse_content(self, content: mistralai.AssistantMessageContent) -> Content:
|
def _extract_thinking_text(self, chunk: mistralai.ThinkChunk) -> str:
|
||||||
|
thinking_content = getattr(chunk, "thinking", None)
|
||||||
|
if not thinking_content:
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
for inner in thinking_content:
|
||||||
|
if hasattr(inner, "type") and inner.type == "text":
|
||||||
|
parts.append(getattr(inner, "text", ""))
|
||||||
|
elif isinstance(inner, str):
|
||||||
|
parts.append(inner)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
def parse_content(
|
||||||
|
self, content: mistralai.AssistantMessageContent
|
||||||
|
) -> ParsedContent:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return content
|
return ParsedContent(content=content, reasoning_content=None)
|
||||||
|
|
||||||
concat_content = ""
|
concat_content = ""
|
||||||
|
concat_reasoning = ""
|
||||||
for chunk in content:
|
for chunk in content:
|
||||||
if isinstance(chunk, mistralai.FileChunk):
|
if isinstance(chunk, mistralai.FileChunk):
|
||||||
continue
|
continue
|
||||||
match chunk.type:
|
if isinstance(chunk, mistralai.TextChunk):
|
||||||
case "text":
|
concat_content += chunk.text
|
||||||
concat_content += chunk.text
|
elif isinstance(chunk, mistralai.ThinkChunk):
|
||||||
case _:
|
concat_reasoning += self._extract_thinking_text(chunk)
|
||||||
pass
|
return ParsedContent(
|
||||||
return concat_content
|
content=concat_content,
|
||||||
|
reasoning_content=concat_reasoning if concat_reasoning else None,
|
||||||
|
)
|
||||||
|
|
||||||
def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
|
def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
|
||||||
return [
|
return [
|
||||||
|
|
@ -187,14 +225,16 @@ class MistralBackend:
|
||||||
stream=False,
|
stream=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parsed = (
|
||||||
|
self._mapper.parse_content(response.choices[0].message.content)
|
||||||
|
if response.choices[0].message.content
|
||||||
|
else ParsedContent(content="", reasoning_content=None)
|
||||||
|
)
|
||||||
return LLMChunk(
|
return LLMChunk(
|
||||||
message=LLMMessage(
|
message=LLMMessage(
|
||||||
role=Role.assistant,
|
role=Role.assistant,
|
||||||
content=self._mapper.parse_content(
|
content=parsed.content,
|
||||||
response.choices[0].message.content
|
reasoning_content=parsed.reasoning_content,
|
||||||
)
|
|
||||||
if response.choices[0].message.content
|
|
||||||
else "",
|
|
||||||
tool_calls=self._mapper.parse_tool_calls(
|
tool_calls=self._mapper.parse_tool_calls(
|
||||||
response.choices[0].message.tool_calls
|
response.choices[0].message.tool_calls
|
||||||
)
|
)
|
||||||
|
|
@ -256,14 +296,16 @@ class MistralBackend:
|
||||||
else None,
|
else None,
|
||||||
http_headers=extra_headers,
|
http_headers=extra_headers,
|
||||||
):
|
):
|
||||||
|
parsed = (
|
||||||
|
self._mapper.parse_content(chunk.data.choices[0].delta.content)
|
||||||
|
if chunk.data.choices[0].delta.content
|
||||||
|
else ParsedContent(content="", reasoning_content=None)
|
||||||
|
)
|
||||||
yield LLMChunk(
|
yield LLMChunk(
|
||||||
message=LLMMessage(
|
message=LLMMessage(
|
||||||
role=Role.assistant,
|
role=Role.assistant,
|
||||||
content=self._mapper.parse_content(
|
content=parsed.content,
|
||||||
chunk.data.choices[0].delta.content
|
reasoning_content=parsed.reasoning_content,
|
||||||
)
|
|
||||||
if chunk.data.choices[0].delta.content
|
|
||||||
else "",
|
|
||||||
tool_calls=self._mapper.parse_tool_calls(
|
tool_calls=self._mapper.parse_tool_calls(
|
||||||
chunk.data.choices[0].delta.tool_calls
|
chunk.data.choices[0].delta.tool_calls
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,11 @@ class APIToolFormatHandler:
|
||||||
return "auto"
|
return "auto"
|
||||||
|
|
||||||
def process_api_response_message(self, message: Any) -> LLMMessage:
|
def process_api_response_message(self, message: Any) -> LLMMessage:
|
||||||
clean_message = {"role": message.role, "content": message.content}
|
clean_message = {
|
||||||
|
"role": message.role,
|
||||||
|
"content": message.content,
|
||||||
|
"reasoning_content": getattr(message, "reasoning_content", None),
|
||||||
|
}
|
||||||
|
|
||||||
if message.tool_calls:
|
if message.tool_calls:
|
||||||
clean_message["tool_calls"] = [
|
clean_message["tool_calls"] = [
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,21 @@ def _resolve_config_path(basename: str, type: Literal["file", "dir"]) -> Path:
|
||||||
|
|
||||||
|
|
||||||
def resolve_local_tools_dir(dir: Path) -> Path | None:
|
def resolve_local_tools_dir(dir: Path) -> Path | None:
|
||||||
|
if not trusted_folders_manager.is_trusted(dir):
|
||||||
|
return None
|
||||||
if (candidate := dir / ".vibe" / "tools").is_dir():
|
if (candidate := dir / ".vibe" / "tools").is_dir():
|
||||||
return candidate
|
return candidate
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_local_skills_dir(dir: Path) -> Path | None:
|
||||||
|
if not trusted_folders_manager.is_trusted(dir):
|
||||||
|
return None
|
||||||
|
if (candidate := dir / ".vibe" / "skills").is_dir():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def unlock_config_paths() -> None:
|
def unlock_config_paths() -> None:
|
||||||
global _config_paths_locked
|
global _config_paths_locked
|
||||||
_config_paths_locked = False
|
_config_paths_locked = False
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ VIBE_HOME = GlobalPath(_get_vibe_home)
|
||||||
GLOBAL_CONFIG_FILE = GlobalPath(lambda: VIBE_HOME.path / "config.toml")
|
GLOBAL_CONFIG_FILE = GlobalPath(lambda: VIBE_HOME.path / "config.toml")
|
||||||
GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
|
GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
|
||||||
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
|
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
|
||||||
|
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
|
||||||
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
|
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
|
||||||
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
|
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
|
||||||
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
|
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
|
||||||
|
|
|
||||||
7
vibe/core/skills/__init__.py
Normal file
7
vibe/core/skills/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from vibe.core.skills.manager import SkillManager
|
||||||
|
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
||||||
|
from vibe.core.skills.parser import SkillParseError
|
||||||
|
|
||||||
|
__all__ = ["SkillInfo", "SkillManager", "SkillMetadata", "SkillParseError"]
|
||||||
113
vibe/core/skills/manager.py
Normal file
113
vibe/core/skills/manager.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from vibe.core.paths.config_paths import resolve_local_skills_dir
|
||||||
|
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
|
||||||
|
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
||||||
|
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from vibe.core.config import VibeConfig
|
||||||
|
|
||||||
|
logger = getLogger("vibe")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillManager:
|
||||||
|
def __init__(self, config: VibeConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._search_paths = self._compute_search_paths(config)
|
||||||
|
self.available_skills = self._discover_skills()
|
||||||
|
|
||||||
|
if self.available_skills:
|
||||||
|
logger.info(
|
||||||
|
"Discovered %d skill(s) from %d search path(s)",
|
||||||
|
len(self.available_skills),
|
||||||
|
len(self._search_paths),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_search_paths(config: VibeConfig) -> list[Path]:
|
||||||
|
paths: list[Path] = []
|
||||||
|
|
||||||
|
for path in config.skill_paths:
|
||||||
|
if path.is_dir():
|
||||||
|
paths.append(path)
|
||||||
|
|
||||||
|
if (
|
||||||
|
skills_dir := resolve_local_skills_dir(config.effective_workdir)
|
||||||
|
) is not None:
|
||||||
|
paths.append(skills_dir)
|
||||||
|
|
||||||
|
if GLOBAL_SKILLS_DIR.path.is_dir():
|
||||||
|
paths.append(GLOBAL_SKILLS_DIR.path)
|
||||||
|
|
||||||
|
unique: list[Path] = []
|
||||||
|
for p in paths:
|
||||||
|
rp = p.resolve()
|
||||||
|
if rp not in unique:
|
||||||
|
unique.append(rp)
|
||||||
|
|
||||||
|
return unique
|
||||||
|
|
||||||
|
def _discover_skills(self) -> dict[str, SkillInfo]:
|
||||||
|
skills: dict[str, SkillInfo] = {}
|
||||||
|
for base in self._search_paths:
|
||||||
|
if not base.is_dir():
|
||||||
|
continue
|
||||||
|
for name, info in self._discover_skills_in_dir(base).items():
|
||||||
|
if name not in skills:
|
||||||
|
skills[name] = info
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping duplicate skill '%s' at %s (already loaded from %s)",
|
||||||
|
name,
|
||||||
|
info.skill_path,
|
||||||
|
skills[name].skill_path,
|
||||||
|
)
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _discover_skills_in_dir(self, base: Path) -> dict[str, SkillInfo]:
|
||||||
|
skills: dict[str, SkillInfo] = {}
|
||||||
|
for skill_dir in base.iterdir():
|
||||||
|
if not skill_dir.is_dir():
|
||||||
|
continue
|
||||||
|
skill_file = skill_dir / "SKILL.md"
|
||||||
|
if not skill_file.is_file():
|
||||||
|
continue
|
||||||
|
if (skill_info := self._try_load_skill(skill_file)) is not None:
|
||||||
|
skills[skill_info.name] = skill_info
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _try_load_skill(self, skill_file: Path) -> SkillInfo | None:
|
||||||
|
try:
|
||||||
|
skill_info = self._parse_skill_file(skill_file)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse skill at %s: %s", skill_file, e)
|
||||||
|
return None
|
||||||
|
return skill_info
|
||||||
|
|
||||||
|
def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
|
||||||
|
try:
|
||||||
|
content = skill_path.read_text(encoding="utf-8")
|
||||||
|
except OSError as e:
|
||||||
|
raise SkillParseError(f"Cannot read file: {e}") from e
|
||||||
|
|
||||||
|
frontmatter, _ = parse_frontmatter(content)
|
||||||
|
metadata = SkillMetadata.model_validate(frontmatter)
|
||||||
|
|
||||||
|
skill_name_from_dir = skill_path.parent.name
|
||||||
|
if metadata.name != skill_name_from_dir:
|
||||||
|
logger.warning(
|
||||||
|
"Skill name '%s' doesn't match directory name '%s' at %s",
|
||||||
|
metadata.name,
|
||||||
|
skill_name_from_dir,
|
||||||
|
skill_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SkillInfo.from_metadata(metadata, skill_path)
|
||||||
|
|
||||||
|
def get_skill(self, name: str) -> SkillInfo | None:
|
||||||
|
return self.available_skills.get(name)
|
||||||
85
vibe/core/skills/models.py
Normal file
85
vibe/core/skills/models.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class SkillMetadata(BaseModel):
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
name: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||||
|
description="Skill identifier. Lowercase letters, numbers, and hyphens only.",
|
||||||
|
)
|
||||||
|
description: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=1024,
|
||||||
|
description="What this skill does and when to use it.",
|
||||||
|
)
|
||||||
|
license: str | None = Field(
|
||||||
|
default=None, description="License name or reference to a bundled license file."
|
||||||
|
)
|
||||||
|
compatibility: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=500,
|
||||||
|
description="Environment requirements (intended product, system packages, etc.).",
|
||||||
|
)
|
||||||
|
metadata: dict[str, str] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Arbitrary key-value mapping for additional metadata.",
|
||||||
|
)
|
||||||
|
allowed_tools: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
validation_alias="allowed-tools",
|
||||||
|
description="Space-delimited list of pre-approved tools (experimental).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("allowed_tools", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_allowed_tools(cls, v: str | list[str] | None) -> list[str]:
|
||||||
|
if v is None:
|
||||||
|
return []
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.split()
|
||||||
|
return list(v)
|
||||||
|
|
||||||
|
@field_validator("metadata", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_metadata(cls, v: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
if v is None:
|
||||||
|
return {}
|
||||||
|
return {str(k): str(val) for k, val in v.items()}
|
||||||
|
|
||||||
|
|
||||||
|
class SkillInfo(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
license: str | None = None
|
||||||
|
compatibility: str | None = None
|
||||||
|
metadata: dict[str, str] = Field(default_factory=dict)
|
||||||
|
allowed_tools: list[str] = Field(default_factory=list)
|
||||||
|
skill_path: Path
|
||||||
|
|
||||||
|
model_config = {"arbitrary_types_allowed": True}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skill_dir(self) -> Path:
|
||||||
|
return self.skill_path.parent.resolve()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_metadata(cls, meta: SkillMetadata, skill_path: Path) -> SkillInfo:
|
||||||
|
return cls(
|
||||||
|
name=meta.name,
|
||||||
|
description=meta.description,
|
||||||
|
license=meta.license,
|
||||||
|
compatibility=meta.compatibility,
|
||||||
|
metadata=meta.metadata,
|
||||||
|
allowed_tools=meta.allowed_tools,
|
||||||
|
skill_path=skill_path.resolve(),
|
||||||
|
)
|
||||||
39
vibe/core/skills/parser.py
Normal file
39
vibe/core/skills/parser.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class SkillParseError(Exception):
|
||||||
|
def __init__(self, reason: str) -> None:
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
FM_BOUNDARY = re.compile(r"^-{3,}\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
||||||
|
splits = FM_BOUNDARY.split(content, 2)
|
||||||
|
if len(splits) < 3 or splits[0].strip(): # noqa: PLR2004
|
||||||
|
raise SkillParseError(
|
||||||
|
"Missing or invalid YAML frontmatter (metadata section must start and end with ---)"
|
||||||
|
)
|
||||||
|
|
||||||
|
yaml_content = splits[1]
|
||||||
|
markdown_body = splits[2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
frontmatter = yaml.safe_load(yaml_content)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise SkillParseError(f"Invalid YAML frontmatter: {e}") from e
|
||||||
|
|
||||||
|
if frontmatter is None:
|
||||||
|
frontmatter = {}
|
||||||
|
|
||||||
|
if not isinstance(frontmatter, dict):
|
||||||
|
raise SkillParseError("YAML frontmatter must be a mapping/dictionary")
|
||||||
|
|
||||||
|
return frontmatter, markdown_body
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
import html
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -17,6 +18,7 @@ from vibe.core.utils import is_dangerous_directory, is_windows
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from vibe.core.config import ProjectContextConfig, VibeConfig
|
from vibe.core.config import ProjectContextConfig, VibeConfig
|
||||||
|
from vibe.core.skills.manager import SkillManager
|
||||||
from vibe.core.tools.manager import ToolManager
|
from vibe.core.tools.manager import ToolManager
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -375,7 +377,42 @@ def _add_commit_signature() -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_universal_system_prompt(tool_manager: ToolManager, config: VibeConfig) -> str:
|
def _get_available_skills_section(skill_manager: SkillManager | None) -> str:
|
||||||
|
if skill_manager is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
skills = skill_manager.available_skills
|
||||||
|
if not skills:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# Available Skills",
|
||||||
|
"",
|
||||||
|
"You have access to the following skills. When a task matches a skill's description,",
|
||||||
|
"read the full SKILL.md file to load detailed instructions.",
|
||||||
|
"",
|
||||||
|
"<available_skills>",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, info in sorted(skills.items()):
|
||||||
|
lines.append(" <skill>")
|
||||||
|
lines.append(f" <name>{html.escape(str(name))}</name>")
|
||||||
|
lines.append(
|
||||||
|
f" <description>{html.escape(str(info.description))}</description>"
|
||||||
|
)
|
||||||
|
lines.append(f" <path>{html.escape(str(info.skill_path))}</path>")
|
||||||
|
lines.append(" </skill>")
|
||||||
|
|
||||||
|
lines.append("</available_skills>")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_universal_system_prompt(
|
||||||
|
tool_manager: ToolManager,
|
||||||
|
config: VibeConfig,
|
||||||
|
skill_manager: SkillManager | None = None,
|
||||||
|
) -> str:
|
||||||
sections = [config.system_prompt]
|
sections = [config.system_prompt]
|
||||||
|
|
||||||
if config.include_commit_signature:
|
if config.include_commit_signature:
|
||||||
|
|
@ -398,6 +435,10 @@ def get_universal_system_prompt(tool_manager: ToolManager, config: VibeConfig) -
|
||||||
if user_instructions.strip():
|
if user_instructions.strip():
|
||||||
sections.append(user_instructions)
|
sections.append(user_instructions)
|
||||||
|
|
||||||
|
skills_section = _get_available_skills_section(skill_manager)
|
||||||
|
if skills_section:
|
||||||
|
sections.append(skills_section)
|
||||||
|
|
||||||
if config.include_project_context:
|
if config.include_project_context:
|
||||||
is_dangerous, reason = is_dangerous_directory()
|
is_dangerous, reason = is_dangerous_directory()
|
||||||
if is_dangerous:
|
if is_dangerous:
|
||||||
|
|
|
||||||
|
|
@ -284,15 +284,7 @@ class Grep(
|
||||||
if not event.args.use_default_ignore:
|
if not event.args.use_default_ignore:
|
||||||
summary += " [no-ignore]"
|
summary += " [no-ignore]"
|
||||||
|
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary=summary)
|
||||||
summary=summary,
|
|
||||||
details={
|
|
||||||
"pattern": event.args.pattern,
|
|
||||||
"path": event.args.path,
|
|
||||||
"max_matches": event.args.max_matches,
|
|
||||||
"use_default_ignore": event.args.use_default_ignore,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||||
|
|
@ -309,18 +301,8 @@ class Grep(
|
||||||
if event.result.was_truncated:
|
if event.result.was_truncated:
|
||||||
warnings.append("Output was truncated due to size/match limits")
|
warnings.append("Output was truncated due to size/match limits")
|
||||||
|
|
||||||
return ToolResultDisplay(
|
return ToolResultDisplay(success=True, message=message, warnings=warnings)
|
||||||
success=True,
|
|
||||||
message=message,
|
|
||||||
warnings=warnings,
|
|
||||||
details={
|
|
||||||
"match_count": event.result.match_count,
|
|
||||||
"was_truncated": event.result.was_truncated,
|
|
||||||
"matches": event.result.matches,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_status_text(cls) -> str:
|
def get_status_text(cls) -> str:
|
||||||
"""Return status message for spinner."""
|
|
||||||
return "Searching files"
|
return "Searching files"
|
||||||
|
|
|
||||||
|
|
@ -188,14 +188,7 @@ class ReadFile(
|
||||||
parts.append(f"limit {event.args.limit} lines")
|
parts.append(f"limit {event.args.limit} lines")
|
||||||
summary += f" ({', '.join(parts)})"
|
summary += f" ({', '.join(parts)})"
|
||||||
|
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary=summary)
|
||||||
summary=summary,
|
|
||||||
details={
|
|
||||||
"path": event.args.path,
|
|
||||||
"offset": event.args.offset,
|
|
||||||
"limit": event.args.limit,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||||
|
|
@ -215,15 +208,6 @@ class ReadFile(
|
||||||
warnings=["File was truncated due to size limit"]
|
warnings=["File was truncated due to size limit"]
|
||||||
if event.result.was_truncated
|
if event.result.was_truncated
|
||||||
else [],
|
else [],
|
||||||
details={
|
|
||||||
"path": str(event.result.path),
|
|
||||||
"lines_read": event.result.lines_read,
|
|
||||||
"was_truncated": event.result.was_truncated,
|
|
||||||
"content": event.result.content,
|
|
||||||
"file_extension": path_obj.suffix.lstrip(".")
|
|
||||||
if path_obj.suffix
|
|
||||||
else "text",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, ToolEr
|
||||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||||
|
|
||||||
_BLOCK_RE = re.compile(
|
SEARCH_REPLACE_BLOCK_RE = re.compile(
|
||||||
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
|
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
|
||||||
)
|
)
|
||||||
|
|
||||||
_BLOCK_WITH_FENCE_RE = re.compile(
|
SEARCH_REPLACE_BLOCK_WITH_FENCE_RE = re.compile(
|
||||||
r"```[\s\S]*?\n<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE\s*\n```",
|
r"```[\s\S]*?\n<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE\s*\n```",
|
||||||
flags=re.DOTALL,
|
flags=re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
@ -88,11 +88,6 @@ class SearchReplace(
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(
|
||||||
summary=f"Patching {args.file_path} ({len(blocks)} blocks)",
|
summary=f"Patching {args.file_path} ({len(blocks)} blocks)",
|
||||||
content=args.content,
|
content=args.content,
|
||||||
details={
|
|
||||||
"path": args.file_path,
|
|
||||||
"blocks_count": len(blocks),
|
|
||||||
"original_path": args.file_path,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -102,10 +97,6 @@ class SearchReplace(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'}",
|
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'}",
|
||||||
warnings=event.result.warnings,
|
warnings=event.result.warnings,
|
||||||
details={
|
|
||||||
"lines_changed": event.result.lines_changed,
|
|
||||||
"content": event.result.content,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return ToolResultDisplay(success=True, message="Patch applied")
|
return ToolResultDisplay(success=True, message="Patch applied")
|
||||||
|
|
@ -406,10 +397,10 @@ class SearchReplace(
|
||||||
1. With code block fences (```...```)
|
1. With code block fences (```...```)
|
||||||
2. Without code block fences
|
2. Without code block fences
|
||||||
"""
|
"""
|
||||||
matches = _BLOCK_WITH_FENCE_RE.findall(content)
|
matches = SEARCH_REPLACE_BLOCK_WITH_FENCE_RE.findall(content)
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
matches = _BLOCK_RE.findall(content)
|
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
SearchReplaceBlock(
|
SearchReplaceBlock(
|
||||||
|
|
|
||||||
|
|
@ -75,20 +75,12 @@ class Todo(
|
||||||
|
|
||||||
match args.action:
|
match args.action:
|
||||||
case "read":
|
case "read":
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary="Reading todos")
|
||||||
summary="Reading todos", details={"action": "read"}
|
|
||||||
)
|
|
||||||
case "write":
|
case "write":
|
||||||
count = len(args.todos) if args.todos else 0
|
count = len(args.todos) if args.todos else 0
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary=f"Writing {count} todos")
|
||||||
summary=f"Writing {count} todos",
|
|
||||||
details={"action": "write", "count": count},
|
|
||||||
)
|
|
||||||
case _:
|
case _:
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary=f"Unknown action: {args.action}")
|
||||||
summary=f"Unknown action: {args.action}",
|
|
||||||
details={"action": args.action},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||||
|
|
@ -97,16 +89,7 @@ class Todo(
|
||||||
|
|
||||||
result = event.result
|
result = event.result
|
||||||
|
|
||||||
by_status = {"in_progress": [], "pending": [], "completed": [], "cancelled": []}
|
return ToolResultDisplay(success=True, message=result.message)
|
||||||
|
|
||||||
for todo in result.todos:
|
|
||||||
by_status[todo.status].append({"content": todo.content, "id": todo.id})
|
|
||||||
|
|
||||||
return ToolResultDisplay(
|
|
||||||
success=True,
|
|
||||||
message=result.message,
|
|
||||||
details={"todos_by_status": by_status, "total_count": result.total_count},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_status_text(cls) -> str:
|
def get_status_text(cls) -> str:
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,10 @@ class WriteFile(
|
||||||
return ToolCallDisplay(summary="Invalid arguments")
|
return ToolCallDisplay(summary="Invalid arguments")
|
||||||
|
|
||||||
args = event.args
|
args = event.args
|
||||||
file_ext = Path(args.path).suffix.lstrip(".")
|
|
||||||
|
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(
|
||||||
summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
|
summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
|
||||||
content=args.content,
|
content=args.content,
|
||||||
details={
|
|
||||||
"path": args.path,
|
|
||||||
"overwrite": args.overwrite,
|
|
||||||
"file_extension": file_ext,
|
|
||||||
"content": args.content,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -74,13 +67,7 @@ class WriteFile(
|
||||||
if isinstance(event.result, WriteFileResult):
|
if isinstance(event.result, WriteFileResult):
|
||||||
action = "Overwritten" if event.result.file_existed else "Created"
|
action = "Overwritten" if event.result.file_existed else "Created"
|
||||||
return ToolResultDisplay(
|
return ToolResultDisplay(
|
||||||
success=True,
|
success=True, message=f"{action} {Path(event.result.path).name}"
|
||||||
message=f"{action} {Path(event.result.path).name}",
|
|
||||||
details={
|
|
||||||
"bytes_written": event.result.bytes_written,
|
|
||||||
"path": event.result.path,
|
|
||||||
"content": event.result.content,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return ToolResultDisplay(success=True, message="File written")
|
return ToolResultDisplay(success=True, message="File written")
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ from vibe.core.tools.mcp import (
|
||||||
list_tools_http,
|
list_tools_http,
|
||||||
list_tools_stdio,
|
list_tools_stdio,
|
||||||
)
|
)
|
||||||
from vibe.core.trusted_folders import trusted_folders_manager
|
|
||||||
from vibe.core.utils import run_sync
|
from vibe.core.utils import run_sync
|
||||||
|
|
||||||
logger = getLogger("vibe")
|
logger = getLogger("vibe")
|
||||||
|
|
@ -53,17 +52,11 @@ class ToolManager:
|
||||||
def _compute_search_paths(config: VibeConfig) -> list[Path]:
|
def _compute_search_paths(config: VibeConfig) -> list[Path]:
|
||||||
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
|
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
|
||||||
|
|
||||||
for p in config.tool_paths:
|
for path in config.tool_paths:
|
||||||
path = Path(p).expanduser().resolve()
|
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
paths.append(path)
|
paths.append(path)
|
||||||
|
|
||||||
is_folder_trusted = trusted_folders_manager.is_trusted(config.effective_workdir)
|
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
|
||||||
if (
|
|
||||||
is_folder_trusted is True
|
|
||||||
and (tools_dir := resolve_local_tools_dir(config.effective_workdir))
|
|
||||||
is not None
|
|
||||||
):
|
|
||||||
paths.append(tools_dir)
|
paths.append(tools_dir)
|
||||||
|
|
||||||
if GLOBAL_TOOLS_DIR.path.is_dir():
|
if GLOBAL_TOOLS_DIR.path.is_dir():
|
||||||
|
|
|
||||||
|
|
@ -173,12 +173,7 @@ def create_mcp_http_proxy_tool_class(
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary=f"{published_name}")
|
||||||
summary=f"{published_name}",
|
|
||||||
details=event.args.model_dump()
|
|
||||||
if hasattr(event.args, "model_dump")
|
|
||||||
else {},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||||
|
|
@ -189,15 +184,7 @@ def create_mcp_http_proxy_tool_class(
|
||||||
)
|
)
|
||||||
|
|
||||||
message = f"MCP tool {event.result.tool} completed"
|
message = f"MCP tool {event.result.tool} completed"
|
||||||
details = {}
|
return ToolResultDisplay(success=event.result.ok, message=message)
|
||||||
if event.result.text:
|
|
||||||
details["text"] = event.result.text
|
|
||||||
if event.result.structured:
|
|
||||||
details["structured"] = event.result.structured
|
|
||||||
|
|
||||||
return ToolResultDisplay(
|
|
||||||
success=event.result.ok, message=message, details=details
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_status_text(cls) -> str:
|
def get_status_text(cls) -> str:
|
||||||
|
|
@ -279,12 +266,7 @@ def create_mcp_stdio_proxy_tool_class(
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary=f"{published_name}")
|
||||||
summary=f"{published_name}",
|
|
||||||
details=event.args.model_dump()
|
|
||||||
if hasattr(event.args, "model_dump")
|
|
||||||
else {},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||||
|
|
@ -295,15 +277,7 @@ def create_mcp_stdio_proxy_tool_class(
|
||||||
)
|
)
|
||||||
|
|
||||||
message = f"MCP tool {event.result.tool} completed"
|
message = f"MCP tool {event.result.tool} completed"
|
||||||
details = {}
|
return ToolResultDisplay(success=event.result.ok, message=message)
|
||||||
if event.result.text:
|
|
||||||
details["text"] = event.result.text
|
|
||||||
if event.result.structured:
|
|
||||||
details["structured"] = event.result.structured
|
|
||||||
|
|
||||||
return ToolResultDisplay(
|
|
||||||
success=event.result.ok, message=message, details=details
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_status_text(cls) -> str:
|
def get_status_text(cls) -> str:
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,12 @@ if TYPE_CHECKING:
|
||||||
class ToolCallDisplay(BaseModel):
|
class ToolCallDisplay(BaseModel):
|
||||||
summary: str # Brief description: "Writing file.txt", "Patching code.py"
|
summary: str # Brief description: "Writing file.txt", "Patching code.py"
|
||||||
content: str | None = None # Optional content preview
|
content: str | None = None # Optional content preview
|
||||||
details: dict[str, Any] = Field(default_factory=dict) # Tool-specific data
|
|
||||||
|
|
||||||
|
|
||||||
class ToolResultDisplay(BaseModel):
|
class ToolResultDisplay(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
warnings: list[str] = Field(default_factory=list)
|
warnings: list[str] = Field(default_factory=list)
|
||||||
details: dict[str, Any] = Field(default_factory=dict) # Tool-specific data
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
|
|
@ -46,9 +44,7 @@ class ToolUIDataAdapter:
|
||||||
|
|
||||||
args_dict = event.args.model_dump() if hasattr(event.args, "model_dump") else {}
|
args_dict = event.args.model_dump() if hasattr(event.args, "model_dump") else {}
|
||||||
args_str = ", ".join(f"{k}={v!r}" for k, v in list(args_dict.items())[:3])
|
args_str = ", ".join(f"{k}={v!r}" for k, v in list(args_dict.items())[:3])
|
||||||
return ToolCallDisplay(
|
return ToolCallDisplay(summary=f"{event.tool_name}({args_str})")
|
||||||
summary=f"{event.tool_name}({args_str})", details=args_dict
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_result_display(self, event: ToolResultEvent) -> ToolResultDisplay:
|
def get_result_display(self, event: ToolResultEvent) -> ToolResultDisplay:
|
||||||
if event.error:
|
if event.error:
|
||||||
|
|
@ -62,15 +58,7 @@ class ToolUIDataAdapter:
|
||||||
if self.ui_data_class:
|
if self.ui_data_class:
|
||||||
return self.ui_data_class.get_result_display(event)
|
return self.ui_data_class.get_result_display(event)
|
||||||
|
|
||||||
return ToolResultDisplay(
|
return ToolResultDisplay(success=True, message="Success")
|
||||||
success=True,
|
|
||||||
message="Success",
|
|
||||||
details=(
|
|
||||||
event.result.model_dump()
|
|
||||||
if event.result and hasattr(event.result, "model_dump")
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_status_text(self) -> str:
|
def get_status_text(self) -> str:
|
||||||
if self.ui_data_class:
|
if self.ui_data_class:
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ class LLMMessage(BaseModel):
|
||||||
|
|
||||||
role: Role
|
role: Role
|
||||||
content: Content | None = None
|
content: Content | None = None
|
||||||
|
reasoning_content: Content | None = None
|
||||||
tool_calls: list[ToolCall] | None = None
|
tool_calls: list[ToolCall] | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
tool_call_id: str | None = None
|
tool_call_id: str | None = None
|
||||||
|
|
@ -178,10 +179,15 @@ class LLMMessage(BaseModel):
|
||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
v.setdefault("content", "")
|
v.setdefault("content", "")
|
||||||
v.setdefault("role", "assistant")
|
v.setdefault("role", "assistant")
|
||||||
|
v.setdefault(
|
||||||
|
"reasoning_content", v.get("reasoning_content") or v.get("reasoning")
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
return {
|
return {
|
||||||
"role": str(getattr(v, "role", "assistant")),
|
"role": str(getattr(v, "role", "assistant")),
|
||||||
"content": getattr(v, "content", ""),
|
"content": getattr(v, "content", ""),
|
||||||
|
"reasoning_content": getattr(v, "reasoning_content", None)
|
||||||
|
or getattr(v, "reasoning", None),
|
||||||
"tool_calls": getattr(v, "tool_calls", None),
|
"tool_calls": getattr(v, "tool_calls", None),
|
||||||
"name": getattr(v, "name", None),
|
"name": getattr(v, "name", None),
|
||||||
"tool_call_id": getattr(v, "tool_call_id", None),
|
"tool_call_id": getattr(v, "tool_call_id", None),
|
||||||
|
|
@ -202,6 +208,12 @@ class LLMMessage(BaseModel):
|
||||||
if not content:
|
if not content:
|
||||||
content = None
|
content = None
|
||||||
|
|
||||||
|
reasoning_content = (self.reasoning_content or "") + (
|
||||||
|
other.reasoning_content or ""
|
||||||
|
)
|
||||||
|
if not reasoning_content:
|
||||||
|
reasoning_content = None
|
||||||
|
|
||||||
tool_calls_map = OrderedDict[int, ToolCall]()
|
tool_calls_map = OrderedDict[int, ToolCall]()
|
||||||
for tool_calls in [self.tool_calls or [], other.tool_calls or []]:
|
for tool_calls in [self.tool_calls or [], other.tool_calls or []]:
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
|
|
@ -226,6 +238,7 @@ class LLMMessage(BaseModel):
|
||||||
return LLMMessage(
|
return LLMMessage(
|
||||||
role=self.role,
|
role=self.role,
|
||||||
content=content,
|
content=content,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
tool_calls=list(tool_calls_map.values()) or None,
|
tool_calls=list(tool_calls_map.values()) or None,
|
||||||
name=self.name,
|
name=self.name,
|
||||||
tool_call_id=self.tool_call_id,
|
tool_call_id=self.tool_call_id,
|
||||||
|
|
@ -275,6 +288,10 @@ class AssistantEvent(BaseEvent):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReasoningEvent(BaseEvent):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class ToolCallEvent(BaseEvent):
|
class ToolCallEvent(BaseEvent):
|
||||||
tool_name: str
|
tool_name: str
|
||||||
tool_class: type[BaseTool]
|
tool_class: type[BaseTool]
|
||||||
|
|
@ -311,11 +328,11 @@ class OutputFormat(StrEnum):
|
||||||
|
|
||||||
|
|
||||||
type AsyncApprovalCallback = Callable[
|
type AsyncApprovalCallback = Callable[
|
||||||
[str, dict[str, Any], str], Awaitable[tuple[ApprovalResponse, str | None]]
|
[str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]]
|
||||||
]
|
]
|
||||||
|
|
||||||
type SyncApprovalCallback = Callable[
|
type SyncApprovalCallback = Callable[
|
||||||
[str, dict[str, Any], str], tuple[ApprovalResponse, str | None]
|
[str, BaseModel, str], tuple[ApprovalResponse, str | None]
|
||||||
]
|
]
|
||||||
|
|
||||||
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ import sys
|
||||||
|
|
||||||
from rich import print as rprint
|
from rich import print as rprint
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
from textual.theme import Theme
|
||||||
|
|
||||||
|
from vibe.cli.textual_ui.terminal_theme import (
|
||||||
|
TERMINAL_THEME_NAME,
|
||||||
|
capture_terminal_theme,
|
||||||
|
)
|
||||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
||||||
from vibe.setup.onboarding.screens import (
|
from vibe.setup.onboarding.screens import (
|
||||||
ApiKeyScreen,
|
ApiKeyScreen,
|
||||||
|
|
@ -16,7 +21,15 @@ from vibe.setup.onboarding.screens import (
|
||||||
class OnboardingApp(App[str | None]):
|
class OnboardingApp(App[str | None]):
|
||||||
CSS_PATH = "onboarding.tcss"
|
CSS_PATH = "onboarding.tcss"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._terminal_theme: Theme | None = capture_terminal_theme()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
if self._terminal_theme:
|
||||||
|
self.register_theme(self._terminal_theme)
|
||||||
|
self.theme = TERMINAL_THEME_NAME
|
||||||
|
|
||||||
self.install_screen(WelcomeScreen(), "welcome")
|
self.install_screen(WelcomeScreen(), "welcome")
|
||||||
self.install_screen(ThemeSelectionScreen(), "theme_selection")
|
self.install_screen(ThemeSelectionScreen(), "theme_selection")
|
||||||
self.install_screen(ApiKeyScreen(), "api_key")
|
self.install_screen(ApiKeyScreen(), "api_key")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import ClassVar
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.binding import Binding, BindingType
|
from textual.binding import Binding, BindingType
|
||||||
|
|
@ -9,10 +9,16 @@ from textual.events import Resize
|
||||||
from textual.theme import BUILTIN_THEMES
|
from textual.theme import BUILTIN_THEMES
|
||||||
from textual.widgets import Markdown, Static
|
from textual.widgets import Markdown, Static
|
||||||
|
|
||||||
|
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
|
||||||
from vibe.core.config import VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
from vibe.setup.onboarding.base import OnboardingScreen
|
from vibe.setup.onboarding.base import OnboardingScreen
|
||||||
|
|
||||||
THEMES = sorted(k for k in BUILTIN_THEMES if k != "textual-ansi")
|
if TYPE_CHECKING:
|
||||||
|
from vibe.setup.onboarding import OnboardingApp
|
||||||
|
|
||||||
|
THEMES = [TERMINAL_THEME_NAME] + sorted(
|
||||||
|
k for k in BUILTIN_THEMES if k != "textual-ansi"
|
||||||
|
)
|
||||||
|
|
||||||
VISIBLE_NEIGHBORS = 3
|
VISIBLE_NEIGHBORS = 3
|
||||||
FADE_CLASSES = ["fade-1", "fade-2", "fade-3"]
|
FADE_CLASSES = ["fade-1", "fade-2", "fade-3"]
|
||||||
|
|
@ -73,7 +79,7 @@ class ThemeSelectionScreen(OnboardingScreen):
|
||||||
Horizontal(
|
Horizontal(
|
||||||
Static("Navigate ↑ ↓", id="nav-hint"),
|
Static("Navigate ↑ ↓", id="nav-hint"),
|
||||||
Vertical(*self._compose_theme_list(), id="theme-list"),
|
Vertical(*self._compose_theme_list(), id="theme-list"),
|
||||||
Static("Press Enter ↵", id="enter-hint"),
|
Static("Press Enter \u21b5", id="enter-hint"),
|
||||||
id="theme-row",
|
id="theme-row",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -83,10 +89,24 @@ class ThemeSelectionScreen(OnboardingScreen):
|
||||||
with preview:
|
with preview:
|
||||||
yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner")
|
yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _has_terminal_theme(self) -> bool:
|
||||||
|
app: OnboardingApp = self.app # type: ignore[assignment]
|
||||||
|
return app._terminal_theme is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _available_themes(self) -> list[str]:
|
||||||
|
if self._has_terminal_theme:
|
||||||
|
return THEMES
|
||||||
|
return [t for t in THEMES if t != TERMINAL_THEME_NAME]
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
current_theme = self.app.theme
|
current_theme = self.app.theme
|
||||||
if current_theme in THEMES:
|
themes = self._available_themes
|
||||||
self._theme_index = THEMES.index(current_theme)
|
if current_theme == TERMINAL_THEME_NAME:
|
||||||
|
self._theme_index = 0
|
||||||
|
elif current_theme in themes:
|
||||||
|
self._theme_index = themes.index(current_theme)
|
||||||
self._update_display()
|
self._update_display()
|
||||||
self._update_preview_height()
|
self._update_preview_height()
|
||||||
self.focus()
|
self.focus()
|
||||||
|
|
@ -102,8 +122,9 @@ class ThemeSelectionScreen(OnboardingScreen):
|
||||||
preview.styles.max_height = max(10, available)
|
preview.styles.max_height = max(10, available)
|
||||||
|
|
||||||
def _get_theme_at_offset(self, offset: int) -> str:
|
def _get_theme_at_offset(self, offset: int) -> str:
|
||||||
index = (self._theme_index + offset) % len(THEMES)
|
themes = self._available_themes
|
||||||
return THEMES[index]
|
index = (self._theme_index + offset) % len(themes)
|
||||||
|
return themes[index]
|
||||||
|
|
||||||
def _update_display(self) -> None:
|
def _update_display(self) -> None:
|
||||||
for i, widget in enumerate(self._theme_widgets):
|
for i, widget in enumerate(self._theme_widgets):
|
||||||
|
|
@ -121,8 +142,10 @@ class ThemeSelectionScreen(OnboardingScreen):
|
||||||
widget.add_class(FADE_CLASSES[distance])
|
widget.add_class(FADE_CLASSES[distance])
|
||||||
|
|
||||||
def _navigate(self, direction: int) -> None:
|
def _navigate(self, direction: int) -> None:
|
||||||
self._theme_index = (self._theme_index + direction) % len(THEMES)
|
themes = self._available_themes
|
||||||
self.app.theme = THEMES[self._theme_index]
|
self._theme_index = (self._theme_index + direction) % len(themes)
|
||||||
|
theme = themes[self._theme_index]
|
||||||
|
self.app.theme = theme
|
||||||
self._update_display()
|
self._update_display()
|
||||||
|
|
||||||
def action_next_theme(self) -> None:
|
def action_next_theme(self) -> None:
|
||||||
|
|
@ -132,7 +155,7 @@ class ThemeSelectionScreen(OnboardingScreen):
|
||||||
self._navigate(-1)
|
self._navigate(-1)
|
||||||
|
|
||||||
def action_next(self) -> None:
|
def action_next(self) -> None:
|
||||||
theme = THEMES[self._theme_index]
|
theme = self._available_themes[self._theme_index]
|
||||||
try:
|
try:
|
||||||
VibeConfig.save_updates({"textual_theme": theme})
|
VibeConfig.save_updates({"textual_theme": theme})
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ from textual.containers import CenterMiddle, Horizontal
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from vibe.cli.textual_ui.terminal_theme import (
|
||||||
|
TERMINAL_THEME_NAME,
|
||||||
|
capture_terminal_theme,
|
||||||
|
)
|
||||||
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE
|
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -145,9 +149,13 @@ class TrustFolderApp(App):
|
||||||
self.folder_path = folder_path
|
self.folder_path = folder_path
|
||||||
self._result: bool | None = None
|
self._result: bool | None = None
|
||||||
self._quit_without_saving = False
|
self._quit_without_saving = False
|
||||||
|
self._terminal_theme = capture_terminal_theme()
|
||||||
self._load_theme()
|
self._load_theme()
|
||||||
|
|
||||||
def _load_theme(self) -> None:
|
def _load_theme(self) -> None:
|
||||||
|
if self._terminal_theme:
|
||||||
|
self.register_theme(self._terminal_theme)
|
||||||
|
|
||||||
config_file = GLOBAL_CONFIG_FILE.path
|
config_file = GLOBAL_CONFIG_FILE.path
|
||||||
if not config_file.is_file():
|
if not config_file.is_file():
|
||||||
return
|
return
|
||||||
|
|
@ -155,10 +163,18 @@ class TrustFolderApp(App):
|
||||||
try:
|
try:
|
||||||
with config_file.open("rb") as f:
|
with config_file.open("rb") as f:
|
||||||
config_data = tomllib.load(f)
|
config_data = tomllib.load(f)
|
||||||
if textual_theme := config_data.get("textual_theme"):
|
except (OSError, tomllib.TOMLDecodeError):
|
||||||
self.theme = textual_theme
|
return
|
||||||
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
|
||||||
pass
|
textual_theme = config_data.get("textual_theme")
|
||||||
|
if not textual_theme:
|
||||||
|
return
|
||||||
|
|
||||||
|
if textual_theme == TERMINAL_THEME_NAME:
|
||||||
|
if self._terminal_theme:
|
||||||
|
self.theme = TERMINAL_THEME_NAME
|
||||||
|
else:
|
||||||
|
self.theme = textual_theme
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield TrustFolderDialog(self.folder_path)
|
yield TrustFolderDialog(self.folder_path)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue