From 078693fc64b25bc99a400692df5f252f123edc68 Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Tue, 23 Dec 2025 19:00:46 +0100 Subject: [PATCH] v1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Quentin Torroba Co-Authored-By: Michel Thomazo Co-Authored-By: Clément Drouin Co-Authored-by: Thiago Padilha --- .github/ISSUE_TEMPLATE/bug-report.yml | 57 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature-request.yml | 47 ++ .github/workflows/release.yml | 19 +- .vscode/launch.json | 9 +- CHANGELOG.md | 22 + distribution/zed/extension.toml | 14 +- pyproject.toml | 3 +- tests/acp/test_initialize.py | 4 +- tests/cli/test_clipboard.py | 211 +++++-- tests/mock/utils.py | 2 + tests/onboarding/test_ui_onboarding.py | 13 +- tests/skills/conftest.py | 59 ++ tests/skills/test_manager.py | 275 +++++++++ tests/skills/test_models.py | 188 +++++++ tests/skills/test_parser.py | 115 ++++ ...ffered_reasoning_yields_before_content.svg | 207 +++++++ ...t_snapshot_shows_interleaved_reasoning.svg | 207 +++++++ .../test_snapshot_shows_reasoning_content.svg | 207 +++++++ ...pshot_shows_reasoning_content_expanded.svg | 207 +++++++ .../test_ui_snapshot_reasoning_content.py | 146 +++++ tests/test_agent_observer_streaming.py | 148 +++++ tests/test_agent_tool_call.py | 8 +- tests/test_reasoning_content.py | 360 ++++++++++++ uv.lock | 4 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent.py | 4 +- vibe/cli/cli.py | 2 + vibe/cli/clipboard.py | 50 +- vibe/cli/textual_ui/app.py | 91 ++- vibe/cli/textual_ui/app.tcss | 61 ++ vibe/cli/textual_ui/handlers/event_handler.py | 18 +- vibe/cli/textual_ui/renderers/__init__.py | 5 - .../textual_ui/renderers/tool_renderers.py | 216 ------- vibe/cli/textual_ui/terminal_theme.py | 266 +++++++++ vibe/cli/textual_ui/widgets/approval_app.py | 16 +- .../widgets/chat_input/container.py | 6 +- vibe/cli/textual_ui/widgets/config_app.py | 16 +- vibe/cli/textual_ui/widgets/loading.py | 2 +- vibe/cli/textual_ui/widgets/messages.py | 104 +++- vibe/cli/textual_ui/widgets/spinner.py | 60 +- vibe/cli/textual_ui/widgets/status_message.py | 16 +- vibe/cli/textual_ui/widgets/tool_widgets.py | 528 ++++++++++-------- vibe/cli/textual_ui/widgets/tools.py | 15 +- vibe/core/agent.py | 68 ++- vibe/core/config.py | 47 +- vibe/core/llm/backend/mistral.py | 82 ++- vibe/core/llm/format.py | 6 +- vibe/core/paths/config_paths.py | 10 + vibe/core/paths/global_paths.py | 1 + vibe/core/skills/__init__.py | 7 + vibe/core/skills/manager.py | 113 ++++ vibe/core/skills/models.py | 85 +++ vibe/core/skills/parser.py | 39 ++ vibe/core/system_prompt.py | 43 +- vibe/core/tools/builtins/grep.py | 22 +- vibe/core/tools/builtins/read_file.py | 18 +- vibe/core/tools/builtins/search_replace.py | 17 +- vibe/core/tools/builtins/todo.py | 25 +- vibe/core/tools/builtins/write_file.py | 15 +- vibe/core/tools/manager.py | 11 +- vibe/core/tools/mcp.py | 34 +- vibe/core/tools/ui.py | 16 +- vibe/core/types.py | 21 +- vibe/setup/onboarding/__init__.py | 13 + .../onboarding/screens/theme_selection.py | 43 +- .../trusted_folders/trust_folder_dialog.py | 24 +- 67 files changed, 3959 insertions(+), 819 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 tests/skills/conftest.py create mode 100644 tests/skills/test_manager.py create mode 100644 tests/skills/test_models.py create mode 100644 tests/skills/test_parser.py create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg create mode 100644 tests/snapshots/test_ui_snapshot_reasoning_content.py create mode 100644 tests/test_reasoning_content.py delete mode 100644 vibe/cli/textual_ui/renderers/__init__.py delete mode 100644 vibe/cli/textual_ui/renderers/tool_renderers.py create mode 100644 vibe/cli/textual_ui/terminal_theme.py create mode 100644 vibe/core/skills/__init__.py create mode 100644 vibe/core/skills/manager.py create mode 100644 vibe/core/skills/models.py create mode 100644 vibe/core/skills/parser.py 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithBufferedReasoningTransition + + + + + + + + + + +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Give me an answer + + +Thought + +● Here is my carefully considered answer. I hope this helps! + + + + + + + + + + + + + +⏵ default mode (shift+tab to cycle) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +>Ask anything... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithInterleavedReasoning + + + + + + + + + + +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Explain this to me + + +Thought + +● Here's the first part of the answer. + +Thought + +● And here's the conclusion! + + + + + + + + + +⏵ default mode (shift+tab to cycle) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +>Ask anything... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithReasoningContent + + + + + + + + + + +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +What is the answer? + + +Thought + +● The answer to your question is 42. This is the ultimate answer. + + + + + + + + + + + + + +⏵ default mode (shift+tab to cycle) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +>Ask anything... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithReasoningContent + + + + + + + + + + +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +What is the answer? + + +Thought +Let me think about this step by step... First, I need to understand the question. Then I can formulate a response. + +● The answer to your question is 42. This is the ultimate answer. + + + + + + + + + + + + +⏵ default mode (shift+tab to cycle) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +>Ask anything... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + + 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)