diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
new file mode 100644
index 0000000..be8dfc7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -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
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..072bd95
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -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
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 0000000..f5702ab
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,47 @@
+name: Feature Request
+description: Pitch an improvement for Mistral Vibe
+title: "feat: "
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: >
+ Feature requests work best when they focus on the problem first. Tell us
+ what you're trying to achieve and why existing APIs aren't enough.
+
+ - type: dropdown
+ id: component
+ attributes:
+ label: Component
+ description: Which part of Mistral Vibe would this feature apply to?
+ options:
+ - CLI
+ - ACP
+ - Both
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem statement
+ description: >
+ What use case is blocked? Provide a clear and concise description of the problem.
+ placeholder: "I want to be able to use the agent to write a blog post about the benefits of using Mistral Vibe"
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ description: >
+ Sketch the feature you’d like to see. Code snippets welcome.
+ validations:
+ required: true
+
+ - type: textarea
+ id: extra
+ attributes:
+ label: Additional context
+ description: Links, screenshots, related issues, etc.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e4dc7c2..19b0617 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/.vscode/launch.json b/.vscode/launch.json
index f8a7978..0e3bc85 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -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": [
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd014b5..dcec783 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.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
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index 870a0b3..8c05c55 100644
--- a/distribution/zed/extension.toml
+++ b/distribution/zed/extension.toml
@@ -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"
diff --git a/pyproject.toml b/pyproject.toml
index 27b1a6d..5cd3769 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index 08a8b10..fef3156 100644
--- a/tests/acp/test_initialize.py
+++ b/tests/acp/test_initialize.py
@@ -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
diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py
index 8d505db..fd915fd 100644
--- a/tests/cli/test_clipboard.py
+++ b/tests/cli/test_clipboard.py
@@ -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
diff --git a/tests/mock/utils.py b/tests/mock/utils.py
index b484a9b..b486202 100644
--- a/tests/mock/utils.py
+++ b/tests/mock/utils.py
@@ -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,
diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py
index ad95598..2c454dd 100644
--- a/tests/onboarding/test_ui_onboarding.py
+++ b/tests/onboarding/test_ui_onboarding.py
@@ -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")
diff --git a/tests/skills/conftest.py b/tests/skills/conftest.py
new file mode 100644
index 0000000..c5af3e8
--- /dev/null
+++ b/tests/skills/conftest.py
@@ -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
diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py
new file mode 100644
index 0000000..90d9da6
--- /dev/null
+++ b/tests/skills/test_manager.py
@@ -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
diff --git a/tests/skills/test_models.py b/tests/skills/test_models.py
new file mode 100644
index 0000000..c114fcc
--- /dev/null
+++ b/tests/skills/test_models.py
@@ -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
diff --git a/tests/skills/test_parser.py b/tests/skills/test_parser.py
new file mode 100644
index 0000000..5138771
--- /dev/null
+++ b/tests/skills/test_parser.py
@@ -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() == ""
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg
new file mode 100644
index 0000000..ec49a36
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg
@@ -0,0 +1,207 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg
new file mode 100644
index 0000000..db42343
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg
@@ -0,0 +1,207 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg
new file mode 100644
index 0000000..e73514f
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg
@@ -0,0 +1,207 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg
new file mode 100644
index 0000000..c56e915
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg
@@ -0,0 +1,207 @@
+
diff --git a/tests/snapshots/test_ui_snapshot_reasoning_content.py b/tests/snapshots/test_ui_snapshot_reasoning_content.py
new file mode 100644
index 0000000..533dac0
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_reasoning_content.py
@@ -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,
+ )
diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py
index ffd1e6d..55bae29 100644
--- a/tests/test_agent_observer_streaming.py
+++ b/tests/test_agent_observer_streaming.py
@@ -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"),
+ ]
diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py
index 5cda432..45794b9 100644
--- a/tests/test_agent_tool_call.py
+++ b/tests/test_agent_tool_call.py
@@ -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)
diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py
new file mode 100644
index 0000000..518d13c
--- /dev/null
+++ b/tests/test_reasoning_content.py
@@ -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
diff --git a/uv.lock b/uv.lock
index b6b26b5..21f263b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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" },
diff --git a/vibe/__init__.py b/vibe/__init__.py
index 9c7139b..a7048c9 100644
--- a/vibe/__init__.py
+++ b/vibe/__init__.py
@@ -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"
diff --git a/vibe/acp/acp_agent.py b/vibe/acp/acp_agent.py
index 1d3cf25..213c1f7 100644
--- a/vibe/acp/acp_agent.py
+++ b/vibe/acp/acp_agent.py
@@ -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)
diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py
index 412e399..5c439e4 100644
--- a/vibe/cli/cli.py
+++ b/vibe/cli/cli.py
@@ -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
diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py
index 1b60b7b..81061fc 100644
--- a/vibe/cli/clipboard.py
+++ b/vibe/cli/clipboard.py
@@ -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",
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index 58b040b..a5f0b73 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -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(
diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss
index 1fe6c48..6e75d41 100644
--- a/vibe/cli/textual_ui/app.tcss
+++ b/vibe/cli/textual_ui/app.tcss
@@ -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;
diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py
index 1646da9..aa7ec57 100644
--- a/vibe/cli/textual_ui/handlers/event_handler.py
+++ b/vibe/cli/textual_ui/handlers/event_handler.py
@@ -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
diff --git a/vibe/cli/textual_ui/renderers/__init__.py b/vibe/cli/textual_ui/renderers/__init__.py
deleted file mode 100644
index c0c384a..0000000
--- a/vibe/cli/textual_ui/renderers/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from __future__ import annotations
-
-from vibe.cli.textual_ui.renderers.tool_renderers import get_renderer
-
-__all__ = ["get_renderer"]
diff --git a/vibe/cli/textual_ui/renderers/tool_renderers.py b/vibe/cli/textual_ui/renderers/tool_renderers.py
deleted file mode 100644
index 01acc4b..0000000
--- a/vibe/cli/textual_ui/renderers/tool_renderers.py
+++ /dev/null
@@ -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()
diff --git a/vibe/cli/textual_ui/terminal_theme.py b/vibe/cli/textual_ui/terminal_theme.py
new file mode 100644
index 0000000..639b7dd
--- /dev/null
+++ b/vibe/cli/textual_ui/terminal_theme.py
@@ -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 ] ; ? 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,
+ )
diff --git a/vibe/cli/textual_ui/widgets/approval_app.py b/vibe/cli/textual_ui/widgets/approval_app.py
index 65a0150..4839c59 100644
--- a/vibe/cli/textual_ui/widgets/approval_app.py
+++ b/vibe/cli/textual_ui/widgets/approval_app.py
@@ -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:
diff --git a/vibe/cli/textual_ui/widgets/chat_input/container.py b/vibe/cli/textual_ui/widgets/chat_input/container.py
index f3cd256..2e35d34 100644
--- a/vibe/cli/textual_ui/widgets/chat_input/container.py
+++ b/vibe/cli/textual_ui/widgets/chat_input/container.py
@@ -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)
diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py
index 897ea9e..d6965e3 100644
--- a/vibe/cli/textual_ui/widgets/config_app.py
+++ b/vibe/cli/textual_ui/widgets/config_app.py
@@ -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,
},
]
diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py
index d9c89dc..e842eea 100644
--- a/vibe/cli/textual_ui/widgets/loading.py
+++ b/vibe/cli/textual_ui/widgets/loading.py
@@ -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
diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py
index 69f1fcd..332ae5d 100644
--- a/vibe/cli/textual_ui/widgets/messages.py
+++ b/vibe/cli/textual_ui/widgets/messages.py
@@ -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:
diff --git a/vibe/cli/textual_ui/widgets/spinner.py b/vibe/cli/textual_ui/widgets/spinner.py
index d84278e..a11f8b4 100644
--- a/vibe/cli/textual_ui/widgets/spinner.py
+++ b/vibe/cli/textual_ui/widgets/spinner.py
@@ -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)
diff --git a/vibe/cli/textual_ui/widgets/status_message.py b/vibe/cli/textual_ui/widgets/status_message.py
index 31e06b3..fdf47f5 100644
--- a/vibe/cli/textual_ui/widgets/status_message.py
+++ b/vibe/cli/textual_ui/widgets/status_message.py
@@ -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()
diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py
index 21390e8..a86ee9a 100644
--- a/vibe/cli/textual_ui/widgets/tool_widgets.py
+++ b/vibe/cli/textual_ui/widgets/tool_widgets.py
@@ -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)
diff --git a/vibe/cli/textual_ui/widgets/tools.py b/vibe/cli/textual_ui/widgets/tools.py
index f8a26c4..6c8c165 100644
--- a/vibe/cli/textual_ui/widgets/tools.py
+++ b/vibe/cli/textual_ui/widgets/tools.py
@@ -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:
diff --git a/vibe/core/agent.py b/vibe/core/agent.py
index a7939d1..581b886 100644
--- a/vibe/core/agent.py
+++ b/vibe/core/agent.py
@@ -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:
diff --git a/vibe/core/config.py b/vibe/core/config.py
index fb11278..a5546df 100644
--- a/vibe/core/config.py
+++ b/vibe/core/config.py
@@ -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:
diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py
index 97996fb..f9901cc 100644
--- a/vibe/core/llm/backend/mistral.py
+++ b/vibe/core/llm/backend/mistral.py
@@ -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
)
diff --git a/vibe/core/llm/format.py b/vibe/core/llm/format.py
index 20c546f..e28be63 100644
--- a/vibe/core/llm/format.py
+++ b/vibe/core/llm/format.py
@@ -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"] = [
diff --git a/vibe/core/paths/config_paths.py b/vibe/core/paths/config_paths.py
index 2b3764f..69f5f7c 100644
--- a/vibe/core/paths/config_paths.py
+++ b/vibe/core/paths/config_paths.py
@@ -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
diff --git a/vibe/core/paths/global_paths.py b/vibe/core/paths/global_paths.py
index 0a6faad..05c56e5 100644
--- a/vibe/core/paths/global_paths.py
+++ b/vibe/core/paths/global_paths.py
@@ -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")
diff --git a/vibe/core/skills/__init__.py b/vibe/core/skills/__init__.py
new file mode 100644
index 0000000..4a06713
--- /dev/null
+++ b/vibe/core/skills/__init__.py
@@ -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"]
diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py
new file mode 100644
index 0000000..a837622
--- /dev/null
+++ b/vibe/core/skills/manager.py
@@ -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)
diff --git a/vibe/core/skills/models.py b/vibe/core/skills/models.py
new file mode 100644
index 0000000..d5372b1
--- /dev/null
+++ b/vibe/core/skills/models.py
@@ -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(),
+ )
diff --git a/vibe/core/skills/parser.py b/vibe/core/skills/parser.py
new file mode 100644
index 0000000..ed3a442
--- /dev/null
+++ b/vibe/core/skills/parser.py
@@ -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
diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py
index 725e435..0e4cdd5 100644
--- a/vibe/core/system_prompt.py
+++ b/vibe/core/system_prompt.py
@@ -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.",
+ "",
+ "",
+ ]
+
+ for name, info in sorted(skills.items()):
+ lines.append(" ")
+ lines.append(f" {html.escape(str(name))}")
+ lines.append(
+ f" {html.escape(str(info.description))}"
+ )
+ lines.append(f" {html.escape(str(info.skill_path))}")
+ lines.append(" ")
+
+ lines.append("")
+
+ 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:
diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py
index 3ab01e4..c99f478 100644
--- a/vibe/core/tools/builtins/grep.py
+++ b/vibe/core/tools/builtins/grep.py
@@ -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"
diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py
index b60b7d7..078bbd7 100644
--- a/vibe/core/tools/builtins/read_file.py
+++ b/vibe/core/tools/builtins/read_file.py
@@ -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
diff --git a/vibe/core/tools/builtins/search_replace.py b/vibe/core/tools/builtins/search_replace.py
index ede2b78..423b690 100644
--- a/vibe/core/tools/builtins/search_replace.py
+++ b/vibe/core/tools/builtins/search_replace.py
@@ -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(
diff --git a/vibe/core/tools/builtins/todo.py b/vibe/core/tools/builtins/todo.py
index 6ce1a6f..d8482bc 100644
--- a/vibe/core/tools/builtins/todo.py
+++ b/vibe/core/tools/builtins/todo.py
@@ -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:
diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py
index ac08fee..7c1e0fd 100644
--- a/vibe/core/tools/builtins/write_file.py
+++ b/vibe/core/tools/builtins/write_file.py
@@ -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")
diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py
index 365ea4c..5c81547 100644
--- a/vibe/core/tools/manager.py
+++ b/vibe/core/tools/manager.py
@@ -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():
diff --git a/vibe/core/tools/mcp.py b/vibe/core/tools/mcp.py
index 16ac996..29b1c7d 100644
--- a/vibe/core/tools/mcp.py
+++ b/vibe/core/tools/mcp.py
@@ -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:
diff --git a/vibe/core/tools/ui.py b/vibe/core/tools/ui.py
index db1e8f7..aa9c549 100644
--- a/vibe/core/tools/ui.py
+++ b/vibe/core/tools/ui.py
@@ -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:
diff --git a/vibe/core/types.py b/vibe/core/types.py
index fa82417..7d2db19 100644
--- a/vibe/core/types.py
+++ b/vibe/core/types.py
@@ -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
diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py
index b380da1..a7a3580 100644
--- a/vibe/setup/onboarding/__init__.py
+++ b/vibe/setup/onboarding/__init__.py
@@ -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")
diff --git a/vibe/setup/onboarding/screens/theme_selection.py b/vibe/setup/onboarding/screens/theme_selection.py
index e9379e5..cf18e7b 100644
--- a/vibe/setup/onboarding/screens/theme_selection.py
+++ b/vibe/setup/onboarding/screens/theme_selection.py
@@ -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:
diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py
index e0d2f14..2b10bee 100644
--- a/vibe/setup/trusted_folders/trust_folder_dialog.py
+++ b/vibe/setup/trusted_folders/trust_folder_dialog.py
@@ -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)