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:
Mathias Gesbert 2025-12-23 19:00:46 +01:00 committed by Mathias Gesbert
parent 2e1e15120d
commit 078693fc64
67 changed files with 3959 additions and 819 deletions

57
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View 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
View 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

View 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 youd like to see. Code snippets welcome.
validations:
required: true
- type: textarea
id: extra
attributes:
label: Additional context
description: Links, screenshots, related issues, etc.

View file

@ -1,4 +1,4 @@
name: Release to Pipy
name: Release to Pipy and Zed's store
on:
release:
@ -6,7 +6,8 @@ on:
workflow_dispatch:
jobs:
release:
release-pypy:
name: Release to Pypi
runs-on: ubuntu-latest
environment:
name: pypi
@ -42,3 +43,17 @@ jobs:
- name: Publish distribution to PyPI
if: github.repository == 'mistralai/mistral-vibe'
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
View file

@ -1,5 +1,5 @@
{
"version": "1.2.2",
"version": "1.3.0",
"configurations": [
{
"name": "ACP Server",
@ -46,6 +46,13 @@
"args": [],
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "Attach using PID",
"type": "debugpy",
"request": "attach",
"processId": "${command:pickProcess}",
"justMyCode": true
}
],
"inputs": [

View file

@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
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
### Fixed

View file

@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "1.2.2"
version = "1.3.0"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.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"
[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"
[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"
[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"
[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"
[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"

View file

@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "1.2.2"
version = "1.3.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@ -36,6 +36,7 @@ dependencies = [
"packaging>=24.1",
"pydantic>=2.12.4",
"pydantic-settings>=2.12.0",
"pyyaml>=6.0.0",
"python-dotenv>=1.0.0",
"rich>=14.0.0",
"textual>=1.0.0",

View file

@ -41,7 +41,7 @@ class TestACPInitialize:
),
)
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 == []
@ -63,7 +63,7 @@ class TestACPInitialize:
),
)
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

View file

@ -5,10 +5,17 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, mock_open, patch
import pyperclip
import pytest
from textual.app import App
from vibe.cli.clipboard import _copy_osc52, copy_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:
@ -79,88 +86,69 @@ def test_copy_selection_to_clipboard_no_notification(
mock_app.notify.assert_not_called()
@patch("vibe.cli.clipboard._copy_osc52")
@patch("vibe.cli.clipboard.pyperclip.copy")
def test_copy_selection_to_clipboard_success_with_osc52(
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
@patch("vibe.cli.clipboard._get_copy_fns")
def test_copy_selection_to_clipboard_success(
mock_get_copy_fns: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
mock_copy_fn = MagicMock()
mock_get_copy_fns.return_value = [mock_copy_fn]
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with("selected text")
mock_pyperclip_copy.assert_not_called()
mock_app.copy_to_clipboard.assert_not_called()
mock_copy_fn.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
)
@patch("vibe.cli.clipboard._copy_osc52")
@patch("vibe.cli.clipboard.pyperclip.copy")
def test_copy_selection_to_clipboard_osc52_fails_success_with_pyperclip(
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
@patch("vibe.cli.clipboard._get_copy_fns")
def test_copy_selection_to_clipboard_tries_all(
mock_get_copy_fns: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
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)
mock_osc52_copy.assert_called_once_with("selected text")
mock_pyperclip_copy.assert_called_once_with("selected text")
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
fn_1.assert_called_once_with("selected text")
fn_2.assert_called_once_with("selected text")
fn_3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
)
@patch("vibe.cli.clipboard._copy_osc52")
@patch("vibe.cli.clipboard.pyperclip.copy")
@patch("vibe.cli.clipboard._get_copy_fns")
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:
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")
mock_pyperclip_copy.side_effect = Exception("pyperclip failed")
mock_app.copy_to_clipboard.side_effect = Exception("app copy failed")
failing_fn1 = MagicMock(side_effect=Exception("failed 1"))
failing_fn2 = MagicMock(side_effect=Exception("failed 2"))
failing_fn3 = MagicMock(side_effect=Exception("failed 3"))
mock_get_copy_fns.return_value = [failing_fn1, failing_fn2, failing_fn3]
copy_selection_to_clipboard(mock_app)
mock_osc52_copy.assert_called_once_with("selected text")
mock_pyperclip_copy.assert_called_once_with("selected text")
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
failing_fn1.assert_called_once_with("selected text")
failing_fn2.assert_called_once_with("selected text")
failing_fn3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
"Failed to copy - no clipboard method available", severity="warning", timeout=3
)
@ -177,10 +165,12 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
widget3 = MockWidget(text_selection=None)
mock_app.query.return_value = [widget1, widget2, widget3]
with patch("vibe.cli.clipboard._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)
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(
'"first selection⏎second selection" copied to clipboard',
severity="information",
@ -195,15 +185,12 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) ->
)
mock_app.query.return_value = [widget]
with (
patch("vibe.cli.clipboard._copy_osc52") as mock_osc52_copy,
patch("vibe.cli.clipboard.pyperclip.copy") as mock_pyperclip_copy,
):
mock_osc52_copy.side_effect = Exception("osc52 failed")
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)
mock_osc52_copy.assert_called_once_with(long_text)
mock_pyperclip_copy.assert_called_once_with(long_text)
mock_copy_fn.assert_called_once_with(long_text)
notification_call = mock_app.notify.call_args
assert notification_call is not None
assert '"' in notification_call[0][0]
@ -226,3 +213,113 @@ def test_copy_osc52_writes_correct_sequence(
handle = mock_file()
handle.write.assert_called_once_with(expected_seq)
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

View file

@ -9,6 +9,7 @@ MOCK_DATA_ENV_VAR = "VIBE_MOCK_LLM_DATA"
def mock_llm_chunk(
content: str = "Hello!",
reasoning_content: str | None = None,
role: Role = Role.assistant,
tool_calls: list[ToolCall] | None = None,
name: str | None = None,
@ -19,6 +20,7 @@ def mock_llm_chunk(
message = LLMMessage(
role=role,
content=content,
reasoning_content=reasoning_content,
tool_calls=tool_calls,
name=name,
tool_call_id=tool_call_id,

View file

@ -13,7 +13,7 @@ from textual.widgets import Input
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE
from vibe.setup.onboarding import OnboardingApp
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
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)
theme_screen = app.screen
assert isinstance(theme_screen, ThemeSelectionScreen)
app.post_message(
Resize(Size(40, 10), Size(40, 10))
) # trigger the resize event handler
preview = theme_screen.query_one("#preview")
assert preview.styles.max_height is not None
target_theme = "gruvbox"
assert target_theme in THEMES
start_index = THEMES.index(app.theme)
target_index = THEMES.index(target_theme)
steps_down = (target_index - start_index) % len(THEMES)
# Use the screen's available themes which accounts for terminal theme availability
available_themes = theme_screen._available_themes
assert target_theme in available_themes
start_index = theme_screen._theme_index
target_index = available_themes.index(target_theme)
steps_down = (target_index - start_index) % len(available_themes)
await pilot.press(*["down"] * steps_down)
assert app.theme == target_theme
await pilot.press("enter")

59
tests/skills/conftest.py Normal file
View 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

View 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
View 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
View 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

View 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,
)

View file

@ -25,6 +25,7 @@ from vibe.core.types import (
AssistantEvent,
FunctionCall,
LLMMessage,
ReasoningEvent,
Role,
ToolCall,
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 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"),
]

View file

@ -2,8 +2,8 @@ from __future__ import annotations
import asyncio
import json
from typing import Any
from pydantic import BaseModel
import pytest
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
async def test_tool_call_approved_by_callback() -> None:
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]:
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"
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]:
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
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]:
callback_invocations.append(tool_name)
# Set permission to ALWAYS for this tool (simulating the new behavior)

View 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
View file

@ -661,7 +661,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "1.2.2"
version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@ -675,6 +675,7 @@ dependencies = [
{ name = "pydantic-settings" },
{ name = "pyperclip" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "textual" },
{ name = "textual-speedups" },
@ -714,6 +715,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "pyperclip", specifier = ">=1.11.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "rich", specifier = ">=14.0.0" },
{ name = "textual", specifier = ">=1.0.0" },
{ name = "textual-speedups", specifier = ">=0.2.1" },

View file

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

View file

@ -4,7 +4,7 @@ import asyncio
from collections.abc import AsyncGenerator
from pathlib import Path
import sys
from typing import Any, cast, override
from typing import cast, override
from acp import (
PROTOCOL_VERSION,
@ -244,7 +244,7 @@ class VibeAcpAgent(AcpAgent):
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
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]:
# Create the tool call update
tool_call = ToolCall(toolCallId=tool_call_id)

View file

@ -26,6 +26,8 @@ def get_initial_mode(args: argparse.Namespace) -> AgentMode:
return AgentMode.PLAN
if args.auto_approve:
return AgentMode.AUTO_APPROVE
if args.prompt is not None:
return AgentMode.AUTO_APPROVE
return AgentMode.DEFAULT

View file

@ -1,7 +1,11 @@
from __future__ import annotations
import base64
from collections.abc import Callable
import os
import platform
import shutil
import subprocess
import pyperclip
from textual.app import App
@ -20,6 +24,33 @@ def _copy_osc52(text: str) -> None:
tty.flush()
def _copy_x11_clipboard(text: str) -> None:
subprocess.run(
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
)
def _copy_wayland_clipboard(text: str) -> None:
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
def _has_cmd(cmd: str) -> bool:
return shutil.which(cmd) is not None
def _get_copy_fns(app: App) -> list[Callable[[str], None]]:
copy_fns: list[Callable[[str], None]] = [
_copy_osc52,
pyperclip.copy,
app.copy_to_clipboard,
]
if platform.system() == "Linux" and _has_cmd("wl-copy"):
copy_fns = [_copy_wayland_clipboard, *copy_fns]
if platform.system() == "Linux" and _has_cmd("xclip"):
copy_fns = [_copy_x11_clipboard, *copy_fns]
return copy_fns
def _shorten_preview(texts: list[str]) -> str:
dense_text = "".join(texts).replace("\n", "")
if len(dense_text) > _PREVIEW_MAX_LENGTH:
@ -53,18 +84,23 @@ def copy_selection_to_clipboard(app: App) -> None:
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:
copy_fn(combined_text)
except:
pass
else:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
)
break
success = True
if success:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
)
else:
app.notify(
"Failed to copy - no clipboard method available",

View file

@ -6,6 +6,7 @@ import subprocess
import time
from typing import Any, ClassVar, assert_never
from pydantic import BaseModel
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
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.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.compact import CompactMessage
@ -29,6 +34,8 @@ from vibe.cli.textual_ui.widgets.messages import (
BashOutputMessage,
ErrorMessage,
InterruptMessage,
ReasoningMessage,
StreamingMessageBase,
UserCommandMessage,
UserMessage,
WarningMessage,
@ -116,13 +123,13 @@ class VibeApp(App):
self._mode_indicator: ModeIndicator | None = None
self._context_progress: ContextProgress | None = None
self._current_bottom_app: BottomApp = BottomApp.Input
self.theme = config.textual_theme
self.history_file = HISTORY_FILE.path
self._tools_collapsed = True
self._todos_collapsed = False
self._current_streaming_message: AssistantMessage | None = None
self._current_streaming_reasoning: ReasoningMessage | None = None
self._version_update_notifier = version_update_notifier
self._update_cache_repository = update_cache_repository
self._is_update_check_enabled = config.enable_update_checks
@ -138,6 +145,7 @@ class VibeApp(App):
self._agent_init_interrupted = False
self._auto_scroll = True
self._last_escape_time: float | None = None
self._terminal_theme = capture_terminal_theme()
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
@ -166,6 +174,15 @@ class VibeApp(App):
yield ContextProgress()
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(
mount_callback=self._mount_and_scroll,
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:
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(
self, message: ConfigApp.ConfigClosed
@ -539,7 +560,7 @@ class VibeApp(App):
return self._agent_init_task
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]:
self._pending_approval = asyncio.Future()
await self._switch_to_approval_app(tool, args)
@ -894,13 +915,17 @@ class VibeApp(App):
if self._mode_indicator:
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)
self._current_bottom_app = BottomApp.Config
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")
try:
@ -1133,12 +1158,36 @@ class VibeApp(App):
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
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:
return
await self._current_streaming_message.stop_stream()
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:
messages_area = self.query_one("#messages")
chat = self.query_one("#chat", VerticalScroll)
@ -1147,15 +1196,28 @@ class VibeApp(App):
if was_at_bottom:
self._auto_scroll = True
if isinstance(widget, AssistantMessage):
if self._current_streaming_message is not None:
content = widget._content or ""
if content:
await self._current_streaming_message.append_content(content)
else:
self._current_streaming_message = widget
await messages_area.mount(widget)
await widget.write_initial_content()
if isinstance(widget, ReasoningMessage):
result = await self._handle_streaming_widget(
widget,
self._current_streaming_reasoning,
self._current_streaming_message,
messages_area,
)
if result is not None:
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:
await self._finalize_current_streaming_message()
await messages_area.mount(widget)
@ -1279,7 +1341,6 @@ def run_textual_ui(
initial_prompt: str | None = None,
loaded_messages: list[LLMMessage] | None = None,
) -> None:
"""Run the Textual UI."""
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
app = VibeApp(

View file

@ -227,6 +227,67 @@ Markdown MarkdownFence {
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 {
margin-top: 0;
margin-bottom: 0;

View file

@ -6,13 +6,14 @@ from typing import TYPE_CHECKING
from textual.widgets import Static
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.core.types import (
AssistantEvent,
BaseEvent,
CompactEndEvent,
CompactStartEvent,
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
)
@ -50,21 +51,18 @@ class EventHandler:
return await self._handle_tool_call(event, loading_widget)
case ToolResultEvent():
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
return None
case ReasoningEvent():
await self._handle_reasoning_message(event)
case AssistantEvent():
await self._handle_assistant_message(event)
return None
case CompactStartEvent():
await self._handle_compact_start()
return None
case CompactEndEvent():
await self._handle_compact_end(event)
return None
case _:
await self._handle_unknown_event(event)
return None
return None
def _sanitize_event(self, event: ToolResultEvent) -> ToolResultEvent:
if isinstance(event, ToolResultEvent):
@ -125,6 +123,12 @@ class EventHandler:
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
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:
compact_msg = CompactMessage()
self.current_compact = compact_msg

View file

@ -1,5 +0,0 @@
from __future__ import annotations
from vibe.cli.textual_ui.renderers.tool_renderers import get_renderer
__all__ = ["get_renderer"]

View file

@ -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()

View 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,
)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from typing import ClassVar
from pydantic import BaseModel
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
@ -9,7 +10,7 @@ from textual.containers import Container, Vertical, VerticalScroll
from textual.message import Message
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
@ -29,14 +30,14 @@ class ApprovalApp(Container):
]
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__()
self.tool_name = tool_name
self.tool_args = tool_args
class ApprovalGrantedAlwaysTool(Message):
def __init__(
self, tool_name: str, tool_args: dict, save_permanently: bool
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
) -> None:
super().__init__()
self.tool_name = tool_name
@ -44,13 +45,13 @@ class ApprovalApp(Container):
self.save_permanently = save_permanently
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__()
self.tool_name = tool_name
self.tool_args = tool_args
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:
super().__init__(id="approval-app")
self.tool_name = tool_name
@ -100,11 +101,8 @@ class ApprovalApp(Container):
if not self.tool_info_container:
return
renderer = get_renderer(self.tool_name)
widget_class, data = renderer.get_approval_widget(self.tool_args)
approval_widget = get_approval_widget(self.tool_name, self.tool_args)
await self.tool_info_container.remove_children()
approval_widget = widget_class(data)
await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None:

View file

@ -158,7 +158,11 @@ class ChatInputContainer(Vertical):
def set_safety(self, safety: ModeSafety) -> None:
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():
input_box.remove_class(border_class)

View file

@ -10,10 +10,14 @@ from textual.message import Message
from textual.theme import BUILTIN_THEMES
from textual.widgets import Static
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
if TYPE_CHECKING:
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):
@ -46,12 +50,18 @@ class ConfigApp(Container):
super().__init__()
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")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
themes = (
_ALL_THEMES
if has_terminal_theme
else [t for t in _ALL_THEMES if t != TERMINAL_THEME_NAME]
)
self.settings: list[SettingDefinition] = [
{
"key": "active_model",
@ -64,7 +74,7 @@ class ConfigApp(Container):
"key": "textual_theme",
"label": "Theme",
"type": "cycle",
"options": THEMES,
"options": themes,
"value": self.config.textual_theme,
},
]

View file

@ -78,7 +78,7 @@ class LoadingWidget(Static):
return None
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:
return self._get_easter_egg() or status

View file

@ -7,6 +7,8 @@ from textual.containers import Horizontal, Vertical
from textual.widgets import Markdown, Static
from textual.widgets._markdown import MarkdownStream
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class NonSelectableStatic(Static):
@property
@ -57,25 +59,18 @@ class UserMessage(Static):
self.remove_class("pending")
class AssistantMessage(Static):
class StreamingMessageBase(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("assistant-message")
self._content = content
self._markdown: Markdown | 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:
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
def _ensure_stream(self) -> MarkdownStream:
@ -88,11 +83,12 @@ class AssistantMessage(Static):
return
self._content += content
stream = self._ensure_stream()
await stream.write(content)
if self._should_write_content():
stream = self._ensure_stream()
await stream.write(content)
async def write_initial_content(self) -> None:
if self._content:
if self._content and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._content)
@ -103,6 +99,86 @@ class AssistantMessage(Static):
await self._stream.stop()
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):
def __init__(self, content: str) -> None:

View file

@ -1,8 +1,21 @@
from __future__ import annotations
from abc import ABC
from collections.abc import Callable
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):
@ -85,3 +98,48 @@ _SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
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)

View file

@ -7,20 +7,17 @@ from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
from vibe.cli.textual_ui.widgets.spinner import Spinner, SpinnerType, create_spinner
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class StatusMessage(Static):
class StatusMessage(SpinnerMixin, Static):
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
self._spinner: Spinner = create_spinner(self.SPINNER_TYPE)
self._spinner_timer = None
self._is_spinning = True
self.success = True
self._initial_text = initial_text
self._indicator_widget: Static | None = None
self._text_widget: Static | None = None
self.init_spinner()
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
@ -38,9 +35,9 @@ class StatusMessage(Static):
def on_mount(self) -> None:
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:
return
self.update_display()
@ -72,4 +69,7 @@ class StatusMessage(Static):
def stop_spinning(self, success: bool = True) -> None:
self._is_spinning = False
self.success = success
if self._spinner_timer:
self._spinner_timer.stop()
self._spinner_timer = None
self.update_display()

View file

@ -1,221 +1,287 @@
from __future__ import annotations
import difflib
from pathlib import Path
from pydantic import BaseModel
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Markdown, Static
from 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 __init__(self, data: dict) -> None:
def _truncate_lines(content: str, max_lines: int) -> str:
"""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__()
self.data = data
self.args = args
self.add_class("tool-approval-widget")
def compose(self) -> ComposeResult:
MAX_APPROVAL_MSG_SIZE = 150
for key, value in self.data.items():
MAX_MSG_SIZE = 150
for field_name in type(self.args).model_fields:
value = getattr(self.args, field_name)
if value is None or value in ("", []):
continue
value_str = str(value)
if len(value_str) > MAX_APPROVAL_MSG_SIZE:
hidden = len(value_str) - MAX_APPROVAL_MSG_SIZE
value_str = (
value_str[:MAX_APPROVAL_MSG_SIZE] + f"… ({hidden} more characters)"
)
if len(value_str) > MAX_MSG_SIZE:
hidden = len(value_str) - MAX_MSG_SIZE
value_str = value_str[:MAX_MSG_SIZE] + f"… ({hidden} more characters)"
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
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__()
self.data = data
self.result = result
self.success = success
self.message = message
self.collapsed = collapsed
self.warnings = warnings or []
self.add_class("tool-result-widget")
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self.SHORTCUT} to {action})"
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
def _header(self) -> ComposeResult:
"""Yield the standard header. Subclasses can call this then add content."""
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
yield Static(f"{self.message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
yield Static(self.message, markup=False)
if not self.collapsed and (details := self.data.get("details")):
for key, value in details.items():
if value:
def compose(self) -> ComposeResult:
"""Default: show message and optionally result fields."""
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(
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:
command = self.data.get("command", "")
description = self.data.get("description", "")
yield Markdown(f"```bash\n{self.args.command}\n```")
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 Markdown(f"```bash\n{command}\n```")
ext = Path(self.result.path).suffix.lstrip(".") or "text"
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
class BashResultWidget(ToolResultWidget):
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
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 (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(
f"File: {self.args.file_path}", markup=False, classes="approval-description"
)
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:
MAX_LINES = 10
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:
if path := self.data.get("path"):
yield Static(
f"Path: {path}", markup=False, classes="tool-result-detail"
)
if bytes_written := self.data.get("bytes_written"):
yield Static(
f"Bytes: {bytes_written}",
markup=False,
classes="tool-result-detail",
)
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 from self._header()
if self.collapsed or not self.result:
return
yield Static(
f"File: {self.result.file}", markup=False, classes="tool-result-detail"
)
yield Static(
f"Blocks applied: {self.result.blocks_applied}",
markup=False,
classes="tool-result-detail",
)
yield Static(
f"Lines changed: {self.result.lines_changed}",
markup=False,
classes="tool-result-detail",
)
for warning in self.result.warnings:
yield Static(f"{warning}", markup=False, classes="tool-result-warning")
if self.result.content:
yield Static("")
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")
for line in parse_search_replace_to_diff(self.result.content):
yield render_diff_line(line)
class TodoApprovalWidget(ToolApprovalWidget):
class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
def compose(self) -> ComposeResult:
description = self.data.get("description", "")
if description:
yield Static(description, markup=False, classes="approval-description")
yield Static(
f"Action: {self.args.action}", 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"]
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
yield Static(f"{self.message} {self._hint()}", markup=False)
else:
yield Static(f"{message} {self._hint()}", markup=False)
yield Static(f"{self.message} {self._hint()}", markup=False)
yield Static("")
by_status = self.data.get("todos_by_status", {})
if not any(by_status.values()):
if not self.result or not self.result.todos:
yield Static("No todos", markup=False, classes="todo-empty")
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"]:
todos = by_status.get(status, [])
for todo in todos:
content = todo.get("content", "")
for todo in by_status[status]:
icon = self._get_status_icon(status)
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:
@ -223,95 +289,103 @@ class TodoResultWidget(ToolResultWidget):
return icons.get(status, "")
class ReadFileApprovalWidget(ToolApprovalWidget):
class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
def compose(self) -> ComposeResult:
for key, value in self.data.items():
if value:
yield Static(
f"{key}: {value}", markup=False, classes="approval-description"
)
yield Static(
f"path: {self.args.path}", 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:
MAX_LINES = 10
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
yield from self._header()
if self.collapsed:
return
if path := self.data.get("path"):
yield Static(f"Path: {path}", markup=False, classes="tool-result-detail")
if warnings := self.data.get("warnings"):
for warning in warnings:
yield Static(
f"{warning}", markup=False, classes="tool-result-warning"
)
if content := self.data.get("content"):
if self.result:
yield Static(
f"Path: {self.result.path}", markup=False, classes="tool-result-detail"
)
for warning in self.warnings:
yield Static(f"{warning}", markup=False, classes="tool-result-warning")
if self.result and self.result.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```")
ext = Path(self.result.path).suffix.lstrip(".") or "text"
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
class GrepApprovalWidget(ToolApprovalWidget):
class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
def compose(self) -> ComposeResult:
for key, value in self.data.items():
if value:
yield Static(
f"{key}: {value!s}", classes="approval-description", markup=False
)
yield Static(
f"pattern: {self.args.pattern}",
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:
MAX_LINES = 30
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
yield from self._header()
if self.collapsed:
return
if warnings := self.data.get("warnings"):
for warning in warnings:
yield Static(
f"{warning}", classes="tool-result-warning", markup=False
)
if matches := self.data.get("matches"):
for warning in self.warnings:
yield Static(f"{warning}", markup=False, classes="tool-result-warning")
if self.result and self.result.matches:
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:
shown_lines = lines[:MAX_LINES]
remaining = total_lines - MAX_LINES
truncated_content = "\n".join(
shown_lines + [f"… ({remaining} more lines)"]
)
yield Markdown(f"```\n{truncated_content}\n```")
else:
yield Markdown(f"```\n{matches}\n```")
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
"bash": BashApprovalWidget,
"read_file": ReadFileApprovalWidget,
"write_file": WriteFileApprovalWidget,
"search_replace": SearchReplaceApprovalWidget,
"grep": GrepApprovalWidget,
"todo": TodoApprovalWidget,
}
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)

View file

@ -4,9 +4,9 @@ from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static
from vibe.cli.textual_ui.renderers import get_renderer
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -129,11 +129,16 @@ class ToolResultMessage(Static):
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_result_display(self._event)
renderer = get_renderer(self._event.tool_name)
widget_class, data = renderer.get_result_widget(display, self.collapsed)
await self._content_container.mount(
widget_class(data, collapsed=self.collapsed)
widget = get_result_widget(
self._event.tool_name,
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:
if self._content_container is None:

View file

@ -4,7 +4,7 @@ import asyncio
from collections.abc import AsyncGenerator, Callable
from enum import StrEnum, auto
import time
from typing import Any, cast
from typing import cast
from uuid import uuid4
from pydantic import BaseModel
@ -28,6 +28,7 @@ from vibe.core.middleware import (
)
from vibe.core.modes import AgentMode
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.tools.base import (
BaseTool,
@ -48,6 +49,7 @@ from vibe.core.types import (
LLMChunk,
LLMMessage,
LLMUsage,
ReasoningEvent,
Role,
SyncApprovalCallback,
ToolCallEvent,
@ -103,6 +105,7 @@ class Agent:
self._max_price = max_price
self.tool_manager = ToolManager(config)
self.skill_manager = SkillManager(config)
self.format_handler = APIToolFormatHandler()
self.backend_factory = lambda: backend or self._select_backend()
@ -114,7 +117,9 @@ class Agent:
self.enable_streaming = enable_streaming
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)]
if self.message_observer:
@ -308,24 +313,49 @@ class Agent:
async for event in self._handle_tool_calls(resolved):
yield event
async def _stream_assistant_events(self) -> AsyncGenerator[AssistantEvent]:
batched_chunk = LLMChunk(message=LLMMessage(role=Role.assistant))
async def _stream_assistant_events(
self,
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
content_buffer = ""
reasoning_buffer = ""
chunks_with_content = 0
chunks_with_reasoning = 0
BATCH_SIZE = 5
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 reasoning_buffer:
yield ReasoningEvent(content=reasoning_buffer)
reasoning_buffer = ""
chunks_with_reasoning = 0
content_buffer += chunk.message.content
chunks_with_content += 1
if chunks_with_content >= BATCH_SIZE:
yield AssistantEvent(content=cast(str, batched_chunk.message.content))
batched_chunk = LLMChunk(message=LLMMessage(role=Role.assistant))
chunks_with_content = 0
if chunks_with_content >= BATCH_SIZE:
yield AssistantEvent(content=content_buffer)
content_buffer = ""
chunks_with_content = 0
if batched_chunk.message.content:
yield AssistantEvent(content=batched_chunk.message.content)
if reasoning_buffer:
yield ReasoningEvent(content=reasoning_buffer)
if content_buffer:
yield AssistantEvent(content=content_buffer)
async def _get_assistant_event(self) -> AssistantEvent:
llm_result = await self._chat()
@ -381,7 +411,7 @@ class Agent:
continue
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:
@ -605,15 +635,12 @@ class Agent:
self.stats.tokens_per_second = usage.completion_tokens / time_seconds
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:
if self.auto_approve:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
args_model, _ = tool._get_tool_args_results()
validated_args = args_model.model_validate(args)
allowlist_denylist_result = tool.check_allowlist_denylist(validated_args)
allowlist_denylist_result = tool.check_allowlist_denylist(args)
if allowlist_denylist_result == ToolPermission.ALWAYS:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
elif allowlist_denylist_result == ToolPermission.NEVER:
@ -638,7 +665,7 @@ class Agent:
return await self._ask_approval(tool_name, args, tool_call_id)
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:
if not self.approval_callback:
return ToolDecision(
@ -844,8 +871,11 @@ class Agent:
self._max_price = max_price
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)]
if preserved_messages:

View file

@ -273,12 +273,12 @@ DEFAULT_MODELS = [
class VibeConfig(BaseSettings):
active_model: str = "devstral-2"
textual_theme: str = "terminal"
vim_keybindings: bool = False
disable_welcome_banner_animation: bool = False
displayed_workdir: str = ""
auto_compact_threshold: int = 200_000
context_warnings: bool = False
textual_theme: str = "textual-dark"
instructions: str = ""
workdir: Path | None = Field(default=None, exclude=True)
system_prompt_id: str = "cli"
@ -296,7 +296,7 @@ class VibeConfig(BaseSettings):
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
tools: dict[str, BaseToolConfig] = Field(default_factory=dict)
tool_paths: list[str] = Field(
tool_paths: list[Path] = Field(
default_factory=list,
description=(
"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(
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
@ -418,6 +426,20 @@ class VibeConfig(BaseSettings):
pass
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")
@classmethod
def _expand_workdir(cls, v: Any) -> Path | None:
@ -517,26 +539,7 @@ class VibeConfig(BaseSettings):
@classmethod
def _migrate(cls) -> None:
if not CONFIG_FILE.path.is_file():
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)
pass
@classmethod
def load(cls, agent: str | None = None, **overrides: Any) -> VibeConfig:

View file

@ -5,7 +5,7 @@ import json
import os
import re
import types
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, NamedTuple, cast
import httpx
import mistralai
@ -27,6 +27,11 @@ if TYPE_CHECKING:
from vibe.core.config import ModelConfig, ProviderConfig
class ParsedContent(NamedTuple):
content: Content
reasoning_content: Content | None
class MistralMapper:
def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
match msg.role:
@ -35,9 +40,25 @@ class MistralMapper:
case Role.user:
return mistralai.UserMessage(role="user", content=msg.content)
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(
role="assistant",
content=msg.content,
content=content,
tool_calls=[
mistralai.ToolCall(
function=mistralai.FunctionCall(
@ -80,20 +101,37 @@ class MistralMapper:
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):
return content
return ParsedContent(content=content, reasoning_content=None)
concat_content = ""
concat_reasoning = ""
for chunk in content:
if isinstance(chunk, mistralai.FileChunk):
continue
match chunk.type:
case "text":
concat_content += chunk.text
case _:
pass
return concat_content
if isinstance(chunk, mistralai.TextChunk):
concat_content += chunk.text
elif isinstance(chunk, mistralai.ThinkChunk):
concat_reasoning += self._extract_thinking_text(chunk)
return ParsedContent(
content=concat_content,
reasoning_content=concat_reasoning if concat_reasoning else None,
)
def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
return [
@ -187,14 +225,16 @@ class MistralBackend:
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(
message=LLMMessage(
role=Role.assistant,
content=self._mapper.parse_content(
response.choices[0].message.content
)
if response.choices[0].message.content
else "",
content=parsed.content,
reasoning_content=parsed.reasoning_content,
tool_calls=self._mapper.parse_tool_calls(
response.choices[0].message.tool_calls
)
@ -256,14 +296,16 @@ class MistralBackend:
else None,
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(
message=LLMMessage(
role=Role.assistant,
content=self._mapper.parse_content(
chunk.data.choices[0].delta.content
)
if chunk.data.choices[0].delta.content
else "",
content=parsed.content,
reasoning_content=parsed.reasoning_content,
tool_calls=self._mapper.parse_tool_calls(
chunk.data.choices[0].delta.tool_calls
)

View file

@ -164,7 +164,11 @@ class APIToolFormatHandler:
return "auto"
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:
clean_message["tool_calls"] = [

View file

@ -32,11 +32,21 @@ def _resolve_config_path(basename: str, type: Literal["file", "dir"]) -> Path:
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():
return candidate
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:
global _config_paths_locked
_config_paths_locked = False

View file

@ -29,6 +29,7 @@ VIBE_HOME = GlobalPath(_get_vibe_home)
GLOBAL_CONFIG_FILE = GlobalPath(lambda: VIBE_HOME.path / "config.toml")
GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")

View 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
View 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)

View 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(),
)

View 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

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Generator
import fnmatch
import html
import os
from pathlib import Path
import subprocess
@ -17,6 +18,7 @@ from vibe.core.utils import is_dangerous_directory, is_windows
if TYPE_CHECKING:
from vibe.core.config import ProjectContextConfig, VibeConfig
from vibe.core.skills.manager import SkillManager
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]
if config.include_commit_signature:
@ -398,6 +435,10 @@ def get_universal_system_prompt(tool_manager: ToolManager, config: VibeConfig) -
if user_instructions.strip():
sections.append(user_instructions)
skills_section = _get_available_skills_section(skill_manager)
if skills_section:
sections.append(skills_section)
if config.include_project_context:
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:

View file

@ -284,15 +284,7 @@ class Grep(
if not event.args.use_default_ignore:
summary += " [no-ignore]"
return ToolCallDisplay(
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,
},
)
return ToolCallDisplay(summary=summary)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
@ -309,18 +301,8 @@ class Grep(
if event.result.was_truncated:
warnings.append("Output was truncated due to size/match limits")
return ToolResultDisplay(
success=True,
message=message,
warnings=warnings,
details={
"match_count": event.result.match_count,
"was_truncated": event.result.was_truncated,
"matches": event.result.matches,
},
)
return ToolResultDisplay(success=True, message=message, warnings=warnings)
@classmethod
def get_status_text(cls) -> str:
"""Return status message for spinner."""
return "Searching files"

View file

@ -188,14 +188,7 @@ class ReadFile(
parts.append(f"limit {event.args.limit} lines")
summary += f" ({', '.join(parts)})"
return ToolCallDisplay(
summary=summary,
details={
"path": event.args.path,
"offset": event.args.offset,
"limit": event.args.limit,
},
)
return ToolCallDisplay(summary=summary)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
@ -215,15 +208,6 @@ class ReadFile(
warnings=["File was truncated due to size limit"]
if event.result.was_truncated
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

View file

@ -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.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
)
_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```",
flags=re.DOTALL,
)
@ -88,11 +88,6 @@ class SearchReplace(
return ToolCallDisplay(
summary=f"Patching {args.file_path} ({len(blocks)} blocks)",
content=args.content,
details={
"path": args.file_path,
"blocks_count": len(blocks),
"original_path": args.file_path,
},
)
@classmethod
@ -102,10 +97,6 @@ class SearchReplace(
success=True,
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'}",
warnings=event.result.warnings,
details={
"lines_changed": event.result.lines_changed,
"content": event.result.content,
},
)
return ToolResultDisplay(success=True, message="Patch applied")
@ -406,10 +397,10 @@ class SearchReplace(
1. With 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:
matches = _BLOCK_RE.findall(content)
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
return [
SearchReplaceBlock(

View file

@ -75,20 +75,12 @@ class Todo(
match args.action:
case "read":
return ToolCallDisplay(
summary="Reading todos", details={"action": "read"}
)
return ToolCallDisplay(summary="Reading todos")
case "write":
count = len(args.todos) if args.todos else 0
return ToolCallDisplay(
summary=f"Writing {count} todos",
details={"action": "write", "count": count},
)
return ToolCallDisplay(summary=f"Writing {count} todos")
case _:
return ToolCallDisplay(
summary=f"Unknown action: {args.action}",
details={"action": args.action},
)
return ToolCallDisplay(summary=f"Unknown action: {args.action}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
@ -97,16 +89,7 @@ class Todo(
result = event.result
by_status = {"in_progress": [], "pending": [], "completed": [], "cancelled": []}
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},
)
return ToolResultDisplay(success=True, message=result.message)
@classmethod
def get_status_text(cls) -> str:

View file

@ -56,17 +56,10 @@ class WriteFile(
return ToolCallDisplay(summary="Invalid arguments")
args = event.args
file_ext = Path(args.path).suffix.lstrip(".")
return ToolCallDisplay(
summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
content=args.content,
details={
"path": args.path,
"overwrite": args.overwrite,
"file_extension": file_ext,
"content": args.content,
},
)
@classmethod
@ -74,13 +67,7 @@ class WriteFile(
if isinstance(event.result, WriteFileResult):
action = "Overwritten" if event.result.file_existed else "Created"
return ToolResultDisplay(
success=True,
message=f"{action} {Path(event.result.path).name}",
details={
"bytes_written": event.result.bytes_written,
"path": event.result.path,
"content": event.result.content,
},
success=True, message=f"{action} {Path(event.result.path).name}"
)
return ToolResultDisplay(success=True, message="File written")

View file

@ -19,7 +19,6 @@ from vibe.core.tools.mcp import (
list_tools_http,
list_tools_stdio,
)
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.utils import run_sync
logger = getLogger("vibe")
@ -53,17 +52,11 @@ class ToolManager:
def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
for p in config.tool_paths:
path = Path(p).expanduser().resolve()
for path in config.tool_paths:
if path.is_dir():
paths.append(path)
is_folder_trusted = trusted_folders_manager.is_trusted(config.effective_workdir)
if (
is_folder_trusted is True
and (tools_dir := resolve_local_tools_dir(config.effective_workdir))
is not None
):
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
paths.append(tools_dir)
if GLOBAL_TOOLS_DIR.path.is_dir():

View file

@ -173,12 +173,7 @@ def create_mcp_http_proxy_tool_class(
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
return ToolCallDisplay(
summary=f"{published_name}",
details=event.args.model_dump()
if hasattr(event.args, "model_dump")
else {},
)
return ToolCallDisplay(summary=f"{published_name}")
@classmethod
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"
details = {}
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
)
return ToolResultDisplay(success=event.result.ok, message=message)
@classmethod
def get_status_text(cls) -> str:
@ -279,12 +266,7 @@ def create_mcp_stdio_proxy_tool_class(
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
return ToolCallDisplay(
summary=f"{published_name}",
details=event.args.model_dump()
if hasattr(event.args, "model_dump")
else {},
)
return ToolCallDisplay(summary=f"{published_name}")
@classmethod
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"
details = {}
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
)
return ToolResultDisplay(success=event.result.ok, message=message)
@classmethod
def get_status_text(cls) -> str:

View file

@ -11,14 +11,12 @@ if TYPE_CHECKING:
class ToolCallDisplay(BaseModel):
summary: str # Brief description: "Writing file.txt", "Patching code.py"
content: str | None = None # Optional content preview
details: dict[str, Any] = Field(default_factory=dict) # Tool-specific data
class ToolResultDisplay(BaseModel):
success: bool
message: str
warnings: list[str] = Field(default_factory=list)
details: dict[str, Any] = Field(default_factory=dict) # Tool-specific data
@runtime_checkable
@ -46,9 +44,7 @@ class ToolUIDataAdapter:
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])
return ToolCallDisplay(
summary=f"{event.tool_name}({args_str})", details=args_dict
)
return ToolCallDisplay(summary=f"{event.tool_name}({args_str})")
def get_result_display(self, event: ToolResultEvent) -> ToolResultDisplay:
if event.error:
@ -62,15 +58,7 @@ class ToolUIDataAdapter:
if self.ui_data_class:
return self.ui_data_class.get_result_display(event)
return ToolResultDisplay(
success=True,
message="Success",
details=(
event.result.model_dump()
if event.result and hasattr(event.result, "model_dump")
else {}
),
)
return ToolResultDisplay(success=True, message="Success")
def get_status_text(self) -> str:
if self.ui_data_class:

View file

@ -168,6 +168,7 @@ class LLMMessage(BaseModel):
role: Role
content: Content | None = None
reasoning_content: Content | None = None
tool_calls: list[ToolCall] | None = None
name: str | None = None
tool_call_id: str | None = None
@ -178,10 +179,15 @@ class LLMMessage(BaseModel):
if isinstance(v, dict):
v.setdefault("content", "")
v.setdefault("role", "assistant")
v.setdefault(
"reasoning_content", v.get("reasoning_content") or v.get("reasoning")
)
return v
return {
"role": str(getattr(v, "role", "assistant")),
"content": getattr(v, "content", ""),
"reasoning_content": getattr(v, "reasoning_content", None)
or getattr(v, "reasoning", None),
"tool_calls": getattr(v, "tool_calls", None),
"name": getattr(v, "name", None),
"tool_call_id": getattr(v, "tool_call_id", None),
@ -202,6 +208,12 @@ class LLMMessage(BaseModel):
if not content:
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]()
for tool_calls in [self.tool_calls or [], other.tool_calls or []]:
for tc in tool_calls:
@ -226,6 +238,7 @@ class LLMMessage(BaseModel):
return LLMMessage(
role=self.role,
content=content,
reasoning_content=reasoning_content,
tool_calls=list(tool_calls_map.values()) or None,
name=self.name,
tool_call_id=self.tool_call_id,
@ -275,6 +288,10 @@ class AssistantEvent(BaseEvent):
)
class ReasoningEvent(BaseEvent):
content: str
class ToolCallEvent(BaseEvent):
tool_name: str
tool_class: type[BaseTool]
@ -311,11 +328,11 @@ class OutputFormat(StrEnum):
type AsyncApprovalCallback = Callable[
[str, dict[str, Any], str], Awaitable[tuple[ApprovalResponse, str | None]]
[str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]]
]
type SyncApprovalCallback = Callable[
[str, dict[str, Any], str], tuple[ApprovalResponse, str | None]
[str, BaseModel, str], tuple[ApprovalResponse, str | None]
]
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback

View file

@ -4,7 +4,12 @@ import sys
from rich import print as rprint
from textual.app import App
from textual.theme import Theme
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding.screens import (
ApiKeyScreen,
@ -16,7 +21,15 @@ from vibe.setup.onboarding.screens import (
class OnboardingApp(App[str | None]):
CSS_PATH = "onboarding.tcss"
def __init__(self) -> None:
super().__init__()
self._terminal_theme: Theme | None = capture_terminal_theme()
def on_mount(self) -> None:
if self._terminal_theme:
self.register_theme(self._terminal_theme)
self.theme = TERMINAL_THEME_NAME
self.install_screen(WelcomeScreen(), "welcome")
self.install_screen(ThemeSelectionScreen(), "theme_selection")
self.install_screen(ApiKeyScreen(), "api_key")

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import ClassVar
from typing import TYPE_CHECKING, ClassVar
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
@ -9,10 +9,16 @@ from textual.events import Resize
from textual.theme import BUILTIN_THEMES
from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
from vibe.core.config import VibeConfig
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
FADE_CLASSES = ["fade-1", "fade-2", "fade-3"]
@ -73,7 +79,7 @@ class ThemeSelectionScreen(OnboardingScreen):
Horizontal(
Static("Navigate ↑ ↓", id="nav-hint"),
Vertical(*self._compose_theme_list(), id="theme-list"),
Static("Press Enter ", id="enter-hint"),
Static("Press Enter \u21b5", id="enter-hint"),
id="theme-row",
)
)
@ -83,10 +89,24 @@ class ThemeSelectionScreen(OnboardingScreen):
with preview:
yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner")
@property
def _has_terminal_theme(self) -> bool:
app: OnboardingApp = self.app # type: ignore[assignment]
return app._terminal_theme is not None
@property
def _available_themes(self) -> list[str]:
if self._has_terminal_theme:
return THEMES
return [t for t in THEMES if t != TERMINAL_THEME_NAME]
def on_mount(self) -> None:
current_theme = self.app.theme
if current_theme in THEMES:
self._theme_index = THEMES.index(current_theme)
themes = self._available_themes
if current_theme == TERMINAL_THEME_NAME:
self._theme_index = 0
elif current_theme in themes:
self._theme_index = themes.index(current_theme)
self._update_display()
self._update_preview_height()
self.focus()
@ -102,8 +122,9 @@ class ThemeSelectionScreen(OnboardingScreen):
preview.styles.max_height = max(10, available)
def _get_theme_at_offset(self, offset: int) -> str:
index = (self._theme_index + offset) % len(THEMES)
return THEMES[index]
themes = self._available_themes
index = (self._theme_index + offset) % len(themes)
return themes[index]
def _update_display(self) -> None:
for i, widget in enumerate(self._theme_widgets):
@ -121,8 +142,10 @@ class ThemeSelectionScreen(OnboardingScreen):
widget.add_class(FADE_CLASSES[distance])
def _navigate(self, direction: int) -> None:
self._theme_index = (self._theme_index + direction) % len(THEMES)
self.app.theme = THEMES[self._theme_index]
themes = self._available_themes
self._theme_index = (self._theme_index + direction) % len(themes)
theme = themes[self._theme_index]
self.app.theme = theme
self._update_display()
def action_next_theme(self) -> None:
@ -132,7 +155,7 @@ class ThemeSelectionScreen(OnboardingScreen):
self._navigate(-1)
def action_next(self) -> None:
theme = THEMES[self._theme_index]
theme = self._available_themes[self._theme_index]
try:
VibeConfig.save_updates({"textual_theme": theme})
except OSError:

View file

@ -11,6 +11,10 @@ from textual.containers import CenterMiddle, Horizontal
from textual.message import Message
from textual.widgets import Static
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE
@ -145,9 +149,13 @@ class TrustFolderApp(App):
self.folder_path = folder_path
self._result: bool | None = None
self._quit_without_saving = False
self._terminal_theme = capture_terminal_theme()
self._load_theme()
def _load_theme(self) -> None:
if self._terminal_theme:
self.register_theme(self._terminal_theme)
config_file = GLOBAL_CONFIG_FILE.path
if not config_file.is_file():
return
@ -155,10 +163,18 @@ class TrustFolderApp(App):
try:
with config_file.open("rb") as f:
config_data = tomllib.load(f)
if textual_theme := config_data.get("textual_theme"):
self.theme = textual_theme
except (OSError, tomllib.TOMLDecodeError, KeyError):
pass
except (OSError, tomllib.TOMLDecodeError):
return
textual_theme = config_data.get("textual_theme")
if not textual_theme:
return
if textual_theme == TERMINAL_THEME_NAME:
if self._terminal_theme:
self.theme = TERMINAL_THEME_NAME
else:
self.theme = textual_theme
def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path)