From d50704e694faad43df3ac0b1a10fb7f0b197ced0 Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Mon, 29 Jun 2026 17:35:54 +0200 Subject: [PATCH] v2.18.2 (#857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cyprien Co-authored-by: Guillaume LE GOFF Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Peter Evers Co-authored-by: Pierre Rossinès Co-authored-by: Yousria Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe --- .github/workflows/build-and-upload.yml | 33 +- .github/workflows/ci.yml | 22 +- .github/workflows/issue-labeler.yml | 8 +- .github/workflows/release.yml | 17 +- AGENTS.md | 4 + CHANGELOG.md | 16 + README.md | 2 +- action.yml | 6 +- distribution/zed/extension.toml | 12 +- pyproject.toml | 5 +- scripts/ci/inject-sentry-dsn.sh | 25 + tests/acp/test_initialize.py | 4 +- tests/cli/test_approval_app_grace_period.py | 34 ++ tests/cli/test_question_app.py | 63 +++ tests/cli/test_rewind_app_vim.py | 29 ++ tests/cli/test_ui_theme_picker.py | 25 + tests/cli/test_voice_app_vim.py | 30 ++ .../test_event_handler_error_muting.py | 183 +++++++ .../test_tool_error_muting_widgets.py | 106 ++++ tests/cli/textual_ui/test_tool_result_link.py | 102 ++-- tests/core/test_config_otel.py | 25 +- tests/core/test_environment_layer.py | 4 +- tests/core/test_vibe_config_schema.py | 4 +- .../test_snapshot_config_initial.svg | 2 +- .../test_snapshot_config_navigate_down.svg | 2 +- .../test_snapshot_config_toggle_autocopy.svg | 2 +- ...ite_approval_long_content_after_resize.svg | 2 +- ...roval_long_content_bottom_lines_hidden.svg | 2 +- ..._snapshot_write_approval_short_content.svg | 2 +- .../test_snapshot_edit_approval_diff.svg | 4 +- .../test_snapshot_edit_approval_diff_ansi.svg | 2 +- ...napshot_edit_approval_diff_replace_all.svg | 4 +- ...st_snapshot_connector_auth_back_to_mcp.svg | 2 +- ...shot_mcp_backspace_returns_to_overview.svg | 2 +- .../test_snapshot_mcp_broken_server.svg | 2 +- ...napshot_mcp_connector_back_to_overview.svg | 2 +- .../test_snapshot_mcp_connectors_only.svg | 2 +- ...apshot_mcp_connectors_sorted_by_status.svg | 2 +- ...test_snapshot_mcp_drill_into_connector.svg | 2 +- ..._snapshot_mcp_enter_drills_into_server.svg | 2 +- ...st_snapshot_mcp_help_bar_shows_connect.svg | 2 +- .../test_snapshot_mcp_overview.svg | 2 +- ...st_snapshot_mcp_overview_navigate_down.svg | 2 +- .../test_snapshot_mcp_refresh_shortcut.svg | 2 +- .../test_snapshot_mcp_server_arg.svg | 2 +- ..._snapshot_mcp_with_connectors_overview.svg | 2 +- .../test_snapshot_model_picker_initial.svg | 2 +- ...st_snapshot_model_picker_navigate_down.svg | 2 +- ...ot_multi_question_answer_first_advance.svg | 4 +- ...ulti_question_first_answered_checkmark.svg | 4 +- .../test_snapshot_multi_question_initial.svg | 4 +- ...hot_multi_question_navigate_left_wraps.svg | 4 +- ...snapshot_multi_question_navigate_right.svg | 4 +- ..._snapshot_multi_question_tab_to_second.svg | 4 +- .../test_snapshot_multi_select_initial.svg | 4 +- ..._snapshot_multi_select_mixed_selection.svg | 4 +- ...apshot_multi_select_navigate_to_submit.svg | 4 +- ..._snapshot_multi_select_other_with_text.svg | 4 +- ...est_snapshot_multi_select_toggle_first.svg | 4 +- ..._snapshot_multi_select_toggle_multiple.svg | 4 +- .../test_snapshot_multi_select_untoggle.svg | 4 +- .../test_snapshot_question_app_initial.svg | 4 +- ...st_snapshot_question_app_navigate_down.svg | 4 +- ...napshot_question_app_navigate_to_other.svg | 4 +- ..._question_app_navigate_to_third_option.svg | 4 +- ...napshot_question_app_navigate_up_wraps.svg | 4 +- ...est_snapshot_question_app_other_typing.svg | 4 +- ...test_snapshot_rewind_error_shows_toast.svg | 2 +- .../test_snapshot_rewind_navigate_down.svg | 2 +- .../test_snapshot_rewind_navigate_up.svg | 2 +- .../test_snapshot_rewind_panel_shown.svg | 2 +- .../test_snapshot_session_picker_header.svg | 2 +- ...port_push_confirmation_cancel_selected.svg | 4 +- ...ort_push_confirmation_multiple_commits.svg | 4 +- ...leport_push_confirmation_single_commit.svg | 4 +- tests/test_tracing.py | 41 +- uv.lock | 25 +- vibe/__init__.py | 2 +- vibe/cli/cli.py | 14 + vibe/cli/textual_ui/app.py | 32 +- vibe/cli/textual_ui/app.tcss | 8 + vibe/cli/textual_ui/handlers/event_handler.py | 31 +- vibe/cli/textual_ui/widgets/approval_app.py | 8 +- vibe/cli/textual_ui/widgets/config_app.py | 5 +- vibe/cli/textual_ui/widgets/links.py | 50 +- vibe/cli/textual_ui/widgets/mcp_app.py | 11 +- vibe/cli/textual_ui/widgets/model_picker.py | 5 +- .../widgets/navigable_option_list.py | 13 + vibe/cli/textual_ui/widgets/question_app.py | 13 +- vibe/cli/textual_ui/widgets/rewind_app.py | 8 +- vibe/cli/textual_ui/widgets/session_picker.py | 5 +- vibe/cli/textual_ui/widgets/status_message.py | 9 + vibe/cli/textual_ui/widgets/theme_picker.py | 5 +- .../cli/textual_ui/widgets/thinking_picker.py | 6 +- vibe/cli/textual_ui/widgets/tool_widgets.py | 13 +- vibe/cli/textual_ui/widgets/tools.py | 60 ++- vibe/cli/textual_ui/widgets/vim_navigation.py | 31 ++ vibe/cli/textual_ui/widgets/voice_app.py | 8 +- vibe/core/agent_loop.py | 15 +- vibe/core/config/__init__.py | 56 +- vibe/core/config/_defaults.py | 14 + vibe/core/config/_settings.py | 480 ++---------------- vibe/core/config/models.py | 388 ++++++++++++++ vibe/core/config/vibe_schema.py | 32 +- vibe/core/llm/backend/factory.py | 2 + vibe/core/llm/backend/mistral.py | 8 +- vibe/core/sentry.py | 65 +++ vibe/core/skills/builtins/vibe.py | 1 - vibe/core/tracing.py | 51 +- vibe/whats_new.md | 1 - 110 files changed, 1663 insertions(+), 764 deletions(-) create mode 100755 scripts/ci/inject-sentry-dsn.sh create mode 100644 tests/cli/test_rewind_app_vim.py create mode 100644 tests/cli/test_voice_app_vim.py create mode 100644 tests/cli/textual_ui/test_event_handler_error_muting.py create mode 100644 tests/cli/textual_ui/test_tool_error_muting_widgets.py create mode 100644 vibe/cli/textual_ui/widgets/navigable_option_list.py create mode 100644 vibe/cli/textual_ui/widgets/vim_navigation.py create mode 100644 vibe/core/config/_defaults.py create mode 100644 vibe/core/config/models.py create mode 100644 vibe/core/sentry.py diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 5890346..dc3b8bb 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -57,10 +57,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv with caching - uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: version: "latest" enable-cache: true @@ -68,7 +68,7 @@ jobs: - name: Set up Python if: ${{ matrix.os != 'linux' }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" @@ -77,6 +77,13 @@ jobs: shell: bash run: bash ./scripts/ci/setup-linux-pyinstaller-build.sh + - name: Inject Sentry DSN + shell: bash + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + REQUIRE_SENTRY_DSN: ${{ github.event_name == 'release' && 'true' || 'false' }} + run: bash ./scripts/ci/inject-sentry-dsn.sh + - name: Build PyInstaller binaries shell: bash run: bash ./scripts/ci/build-pyinstaller-binaries.sh vibe-acp.spec vibe.spec @@ -92,13 +99,13 @@ jobs: run: echo "version=$(uv version | cut -d' ' -f2)" >> $GITHUB_OUTPUT - name: Upload ACP binary as artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version.outputs.version }} path: dist/vibe-acp-dir/ - name: Upload CLI binary as artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: vibe-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version.outputs.version }} path: dist/vibe-dir/ @@ -114,11 +121,11 @@ jobs: steps: - name: Checkout repository if: matrix.os != 'windows' - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install Nix if: matrix.os != 'windows' - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31 + uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 - name: Build with Nix if: matrix.os != 'windows' @@ -144,11 +151,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python if: ${{ !matrix.container }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" @@ -160,14 +167,14 @@ jobs: run: ${{ matrix.container && 'python3.11' || 'python' }} -m pip install agent-client-protocol==0.10.1 - name: Download ACP artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-* merge-multiple: true path: dist/vibe-acp-dir - name: Download CLI artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: vibe-${{ matrix.os }}-${{ matrix.arch }}-* merge-multiple: true @@ -190,7 +197,7 @@ jobs: contents: write steps: - name: Download all artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts @@ -209,7 +216,7 @@ jobs: done - name: Attach binaries to release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@2bb465e97f322d3cb2a965294d483e0d26a67aa9 # v3.0.1 with: files: release-assets/*.zip env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd25e6c..5b9a48b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,17 +18,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv with caching - uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: version: "latest" enable-cache: true cache-dependency-glob: "uv.lock" - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.PYTHON_VERSION }} @@ -42,7 +42,7 @@ jobs: run: echo "$(pwd)/.venv/bin" >> $GITHUB_PATH - name: Cache pre-commit - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.cache/pre-commit key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} @@ -58,17 +58,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv with caching - uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: version: "latest" enable-cache: true cache-dependency-glob: "uv.lock" - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.PYTHON_VERSION }} @@ -94,17 +94,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv with caching - uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: version: "latest" enable-cache: true cache-dependency-glob: "uv.lock" - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.PYTHON_VERSION }} @@ -118,7 +118,7 @@ jobs: - name: Upload snapshot report if: steps.snapshot-tests.outcome == 'failure' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: snapshot-report path: snapshot_report.html diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index f05cd61..2c08763 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -11,24 +11,24 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Parse bug report form id: parse-bug - uses: stefanbuck/github-issue-parser@25f1485edffc1fee3ea68eb9f59a72e58720ffc4 # v3 + uses: stefanbuck/github-issue-parser@cb6e97157cbf851e3a393ff8d57c93a484cc323f # v3.2.5 with: template-path: .github/ISSUE_TEMPLATE/bug-report.yml continue-on-error: true - name: Parse feature request form id: parse-feature - uses: stefanbuck/github-issue-parser@25f1485edffc1fee3ea68eb9f59a72e58720ffc4 # v3 + uses: stefanbuck/github-issue-parser@cb6e97157cbf851e3a393ff8d57c93a484cc323f # v3.2.5 with: template-path: .github/ISSUE_TEMPLATE/feature-request.yml continue-on-error: true - name: Apply component label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9.0.0 env: BUG_COMPONENT: ${{ steps.parse-bug.outputs.issueparser_component }} FEATURE_COMPONENT: ${{ steps.parse-feature.outputs.issueparser_component }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 913ac65..54ff1ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,28 +18,35 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Install dependencies run: uv sync --locked --dev + - name: Inject Sentry DSN + shell: bash + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + REQUIRE_SENTRY_DSN: "true" + run: bash ./scripts/ci/inject-sentry-dsn.sh + - name: Build package run: uv build - name: Upload artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist/ - name: Publish distribution to PyPI if: github.repository == 'mistralai/mistral-vibe' - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0 diff --git a/AGENTS.md b/AGENTS.md index 7236ab7..138f028 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,10 @@ Always go through `uv` — never invoke bare `python` or `pip`. - They return `ReadSafeResult(text, encoding)` and try UTF-8, then BOM detection, then locale, then `charset_normalizer` lazily. - Pass `raise_on_error=True` only when callers must distinguish corrupt files from valid ones; the default replaces undecodable bytes with U+FFFD. +## Widgets + +- For selectable lists, use `NavigableOptionList` from `vibe/cli/textual_ui/widgets/navigable_option_list.py` instead of Textual's `OptionList`. It adds `j`/`k` cursor navigation on top of the arrow keys; the bare `OptionList` only handles arrows. + ## TCSS - When a rule sets `color: $text-muted;`, pair it with a nested `&:ansi { text-style: dim; }` so the muted intent survives under ANSI themes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce489e..71e4d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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). +## [2.18.2] - 2026-06-29 + +### Added + +- Sentry crash reporting for unhandled exceptions in the TUI (gated by `enable_telemetry`; off when telemetry is disabled) + +### Changed + +- Recoverable tool-call errors are now muted in the UI; only terminal errors render as a hard failure + +### Fixed + +- MarkupError crash when tool output contained square brackets +- OpenTelemetry chat/LLM spans missing after Mistral Python SDK 2.4.10+ telemetry opt-in gate + + ## [2.18.1] - 2026-06-26 ### Added diff --git a/README.md b/README.md index 6b227d2..956dafd 100644 --- a/README.md +++ b/README.md @@ -805,7 +805,7 @@ Mistral Vibe can be used in text editors and IDEs that support [Agent Client Pro ## Data collection & usage -Use of Vibe is subject to our [Privacy Policy](https://legal.mistral.ai/terms/privacy-policy) and may include the collection and processing of data related to your use of the service, such as usage data, to operate, maintain, and improve Vibe. You can disable telemetry in your `config.toml` by setting `enable_telemetry = false`. +Use of Vibe is subject to our [Privacy Policy](https://legal.mistral.ai/terms/privacy-policy) and may include the collection and processing of data related to your use of the service, such as usage data, to operate, maintain, and improve Vibe. You can disable telemetry and crash reporting in your `config.toml` by setting `enable_telemetry = false`. ## License diff --git a/action.yml b/action.yml index 0ba333f..23fb752 100644 --- a/action.yml +++ b/action.yml @@ -27,18 +27,18 @@ runs: steps: - name: Install Required Python Version if: inputs.install_python == true - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version-file: ${{ github.action_path }}/.python-version - name: Install Requested Python Version if: inputs.install_python && inputs.python_version - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ inputs.python_version }} - name: Install uv - uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Install Mistral Vibe shell: bash diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 3f3f620..abc64cd 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 = "2.18.1" +version = "2.18.2" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-darwin-aarch64-2.18.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.2/vibe-acp-darwin-aarch64-2.18.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-darwin-x86_64-2.18.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.2/vibe-acp-darwin-x86_64-2.18.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-linux-aarch64-2.18.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.2/vibe-acp-linux-aarch64-2.18.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-linux-x86_64-2.18.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.2/vibe-acp-linux-x86_64-2.18.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-windows-x86_64-2.18.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.2/vibe-acp-windows-x86_64-2.18.2.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index ac6d46d..de404d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.18.1" +version = "2.18.2" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -65,7 +65,7 @@ dependencies = [ "linkify-it-py==2.1.0", "markdown-it-py==4.0.0", "markdownify==1.2.2", - "mcp==1.28.0", + "mcp==1.28.1", "mdit-py-plugins==0.5.0", "mdurl==0.1.2", "mistralai==2.4.11", @@ -101,6 +101,7 @@ dependencies = [ "rich==15.0.0", "rpds-py==0.30.0", "secretstorage==3.5.0 ; sys_platform == 'linux'", + "sentry-sdk==2.63.0", "six==1.17.0", "smmap==5.0.3", "sounddevice==0.5.5", diff --git a/scripts/ci/inject-sentry-dsn.sh b/scripts/ci/inject-sentry-dsn.sh new file mode 100755 index 0000000..56bcad7 --- /dev/null +++ b/scripts/ci/inject-sentry-dsn.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +target_file="${1:-vibe/core/sentry.py}" +require_sentry_dsn="${REQUIRE_SENTRY_DSN:-false}" + +if [ -z "${SENTRY_DSN:-}" ]; then + if [ "${require_sentry_dsn}" = "true" ]; then + echo "SENTRY_DSN is required but not set" >&2 + exit 1 + fi + echo "SENTRY_DSN is not set; leaving ${target_file} unchanged" + exit 0 +fi + +if ! grep -q '^_SENTRY_DSN = None$' "${target_file}"; then + echo "Expected Sentry placeholder not found in ${target_file}" >&2 + exit 1 +fi + +escaped_dsn="$(printf '%s' "${SENTRY_DSN}" | sed 's/[&|]/\\&/g')" +sed -i.bak "s|^_SENTRY_DSN = None$|_SENTRY_DSN = \"${escaped_dsn}\"|" "${target_file}" +rm -f "${target_file}.bak" + +grep -q '^_SENTRY_DSN = ".*"$' "${target_file}" diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 2c2362f..e1db94a 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -72,7 +72,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.2" ) assert response.auth_methods is not None @@ -172,7 +172,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.2" ) assert response.auth_methods is not None diff --git a/tests/cli/test_approval_app_grace_period.py b/tests/cli/test_approval_app_grace_period.py index 0b0b127..09e3745 100644 --- a/tests/cli/test_approval_app_grace_period.py +++ b/tests/cli/test_approval_app_grace_period.py @@ -4,6 +4,7 @@ from unittest.mock import patch from pydantic import BaseModel import pytest +from textual import events from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp from vibe.core.config import VibeConfig @@ -76,3 +77,36 @@ class TestGracePeriod: assert approval_app.selected_option == 1 approval_app.action_move_up() assert approval_app.selected_option == 0 + + +class TestVimKeybindings: + def test_j_moves_down(self, approval_app: ApprovalApp): + with patch.object(approval_app, "_update_options"): + assert approval_app.selected_option == 0 + + approval_app.on_key(events.Key("j", "j")) + + assert approval_app.selected_option == 1 + + def test_k_moves_up(self, approval_app: ApprovalApp): + with patch.object(approval_app, "_update_options"): + assert approval_app.selected_option == 0 + + approval_app.on_key(events.Key("k", "k")) + + # Wraps to last option (index 3) + assert approval_app.selected_option == 3 + + def test_vim_navigation_works_during_grace_period(self, approval_app: ApprovalApp): + with ( + patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time, + patch.object(approval_app, "_update_options"), + ): + mock_time.monotonic.return_value = 100.0 + 0.01 + assert approval_app.is_within_grace_period() + + assert approval_app.selected_option == 0 + approval_app.on_key(events.Key("j", "j")) + assert approval_app.selected_option == 1 + approval_app.on_key(events.Key("k", "k")) + assert approval_app.selected_option == 0 diff --git a/tests/cli/test_question_app.py b/tests/cli/test_question_app.py index cd4fe71..de0bf74 100644 --- a/tests/cli/test_question_app.py +++ b/tests/cli/test_question_app.py @@ -716,3 +716,66 @@ class TestMultiSelectSubmit: app.action_move_down() assert app.selected_option == 0 + + +class TestVimKeybindings: + def test_j_moves_down(self, single_question_args): + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + assert app.selected_option == 0 + + app.on_key(events.Key("j", "j")) + + assert app.selected_option == 1 + + def test_k_moves_up(self, single_question_args): + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + assert app.selected_option == 0 + + app.on_key(events.Key("k", "k")) + + # Wraps to last option (Other = index 2) + assert app.selected_option == 2 + + def test_j_k_ignored_when_other_input_focused(self, single_question_args): + from unittest.mock import MagicMock + + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + app = QuestionApp(single_question_args) + app.other_input = MagicMock() + app.other_input.has_focus = True + + app.on_key(events.Key("j", "j")) + assert app.selected_option == 0 + + def test_j_moves_down_for_single_question(self): + # Vim navigation must fire before the len <= 1 early return in on_key. + from textual import events + + from vibe.cli.textual_ui.widgets.question_app import QuestionApp + + args = AskUserQuestionArgs( + questions=[ + Question( + question="Only question?", + header="Q", + options=[Choice(label="A"), Choice(label="B")], + ) + ] + ) + app = QuestionApp(args) + assert app.selected_option == 0 + + app.on_key(events.Key("j", "j")) + + assert app.selected_option == 1 diff --git a/tests/cli/test_rewind_app_vim.py b/tests/cli/test_rewind_app_vim.py new file mode 100644 index 0000000..c6d0e15 --- /dev/null +++ b/tests/cli/test_rewind_app_vim.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import pytest +from textual import events + +from vibe.cli.textual_ui.widgets.rewind_app import RewindApp + + +@pytest.fixture +def app(monkeypatch: pytest.MonkeyPatch) -> RewindApp: + widget = RewindApp("preview", has_file_changes=True) + monkeypatch.setattr(widget, "_update_options", lambda: None) + return widget + + +class TestRewindAppVimKeybindings: + def test_j_moves_down(self, app: RewindApp): + assert app.selected_option == 0 + + app.on_key(events.Key("j", "j")) + + assert app.selected_option == 1 + + def test_k_wraps_to_last(self, app: RewindApp): + assert app.selected_option == 0 + + app.on_key(events.Key("k", "k")) + + assert app.selected_option == len(app._options) - 1 diff --git a/tests/cli/test_ui_theme_picker.py b/tests/cli/test_ui_theme_picker.py index 975a134..57daadb 100644 --- a/tests/cli/test_ui_theme_picker.py +++ b/tests/cli/test_ui_theme_picker.py @@ -91,3 +91,28 @@ async def test_theme_picker_select_persists_and_applies() -> None: assert len(app.query(ThemePickerApp)) == 0 assert app.config.theme == target assert app.theme == target + + +@pytest.mark.asyncio +async def test_theme_picker_jk_moves_cursor() -> None: + from textual.widgets import OptionList + + config = build_test_vibe_config() + config.theme = "ansi-dark" + app = build_test_vibe_app(config=config) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_theme() + await pilot.pause(0.2) + + option_list = app.query_one(ThemePickerApp).query_one(OptionList) + start = option_list.highlighted + assert start is not None + + await pilot.press("j") + await pilot.pause(0.1) + assert option_list.highlighted == (start + 1) % option_list.option_count + + await pilot.press("k") + await pilot.pause(0.1) + assert option_list.highlighted == start diff --git a/tests/cli/test_voice_app_vim.py b/tests/cli/test_voice_app_vim.py new file mode 100644 index 0000000..6e16f2c --- /dev/null +++ b/tests/cli/test_voice_app_vim.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import pytest +from textual import events + +from vibe.cli.textual_ui.widgets.voice_app import VoiceApp +from vibe.core.config import VibeConfig + + +@pytest.fixture +def app(monkeypatch: pytest.MonkeyPatch) -> VoiceApp: + widget = VoiceApp(VibeConfig()) + monkeypatch.setattr(widget, "_update_display", lambda: None) + return widget + + +class TestVoiceAppVimKeybindings: + def test_j_moves_down(self, app: VoiceApp): + assert app.selected_index == 0 + + app.on_key(events.Key("j", "j")) + + assert app.selected_index == 1 + + def test_k_wraps_to_last(self, app: VoiceApp): + assert app.selected_index == 0 + + app.on_key(events.Key("k", "k")) + + assert app.selected_index == len(app.settings) - 1 diff --git a/tests/cli/textual_ui/test_event_handler_error_muting.py b/tests/cli/textual_ui/test_event_handler_error_muting.py new file mode 100644 index 0000000..a0b6439 --- /dev/null +++ b/tests/cli/textual_ui/test_event_handler_error_muting.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest + +from tests.stubs.fake_tool import FakeTool, FakeToolArgs +from vibe.cli.textual_ui.handlers.event_handler import EventHandler +from vibe.cli.textual_ui.widgets.tools import ToolResultMessage +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +def _call_event(call_id: str) -> ToolCallEvent: + return ToolCallEvent( + tool_name="stub_tool", + tool_class=FakeTool, + args=FakeToolArgs(), + tool_call_id=call_id, + ) + + +def _error_result(call_id: str) -> ToolResultEvent: + return ToolResultEvent( + tool_name="stub_tool", + tool_class=FakeTool, + result=None, + error="boom", + tool_call_id=call_id, + ) + + +def _ok_result(call_id: str) -> ToolResultEvent: + return ToolResultEvent( + tool_name="stub_tool", tool_class=FakeTool, result=None, tool_call_id=call_id + ) + + +def _make_handler() -> tuple[EventHandler, AsyncMock]: + mount_callback = AsyncMock() + handler = EventHandler( + mount_callback=mount_callback, get_tools_collapsed=lambda: False + ) + return handler, mount_callback + + +def _last_result_widget(mount_callback: AsyncMock) -> ToolResultMessage: + for call in reversed(mount_callback.call_args_list): + widget = call.args[0] + if isinstance(widget, ToolResultMessage): + return widget + raise AssertionError("no ToolResultMessage was mounted") + + +@pytest.mark.asyncio +async def test_error_result_is_registered_as_pending() -> None: + handler, mount_callback = _make_handler() + + await handler.handle_event(_call_event("a")) + await handler.handle_event(_error_result("a")) + + assert len(handler._pending_error_results) == 1 + + +@pytest.mark.asyncio +async def test_followup_tool_call_keeps_error_muted() -> None: + handler, mount_callback = _make_handler() + + await handler.handle_event(_call_event("a")) + await handler.handle_event(_error_result("a")) + result_widget = _last_result_widget(mount_callback) + result_widget.escalate_error = Mock() + + await handler.handle_event(_call_event("b")) + + result_widget.escalate_error.assert_not_called() + assert handler._pending_error_results == [] + + +@pytest.mark.asyncio +async def test_turn_end_escalates_error() -> None: + handler, mount_callback = _make_handler() + + await handler.handle_event(_call_event("a")) + await handler.handle_event(_error_result("a")) + result_widget = _last_result_widget(mount_callback) + result_widget.escalate_error = Mock() + + handler.escalate_unresolved_errors() + + result_widget.escalate_error.assert_called_once() + assert handler._pending_error_results == [] + + +@pytest.mark.asyncio +async def test_successful_result_is_not_pending() -> None: + handler, mount_callback = _make_handler() + + await handler.handle_event(_call_event("a")) + await handler.handle_event(_ok_result("a")) + + assert handler._pending_error_results == [] + + +@pytest.mark.asyncio +async def test_streaming_arg_update_before_result_does_not_register_error() -> None: + handler, _ = _make_handler() + + # Same tool_call_id re-emitted as a streaming arg update, before any result. + await handler.handle_event(_call_event("a")) + await handler.handle_event(_call_event("a")) + + assert handler._pending_error_results == [] + + +@pytest.mark.asyncio +async def test_parallel_errors_escalated_together_at_turn_end() -> None: + handler, mount_callback = _make_handler() + + await handler.handle_event(_call_event("a")) + await handler.handle_event(_call_event("b")) + await handler.handle_event(_error_result("a")) + await handler.handle_event(_error_result("b")) + mocks: list[Mock] = [] + for widget in handler._pending_error_results: + mock = Mock() + widget.escalate_error = mock + mocks.append(mock) + assert len(mocks) == 2 + + handler.escalate_unresolved_errors() + + for mock in mocks: + mock.assert_called_once() + assert handler._pending_error_results == [] + + +@pytest.mark.asyncio +async def test_cancel_holds_muted_square_for_in_flight_call() -> None: + handler, _ = _make_handler() + + await handler.handle_event(_call_event("a")) + tool_call = handler.tool_calls["a"] + show_muted = Mock() + stop_spinning = Mock() + tool_call.show_muted = show_muted + tool_call.stop_spinning = stop_spinning + + handler.stop_current_tool_call(cancelled=True) + + show_muted.assert_called_once() + stop_spinning.assert_not_called() + + +@pytest.mark.asyncio +async def test_turn_error_shows_red_cross_for_in_flight_call() -> None: + handler, _ = _make_handler() + + await handler.handle_event(_call_event("a")) + tool_call = handler.tool_calls["a"] + show_muted = Mock() + stop_spinning = Mock() + tool_call.show_muted = show_muted + tool_call.stop_spinning = stop_spinning + + handler.stop_current_tool_call(success=False) + + stop_spinning.assert_called_once() + show_muted.assert_not_called() + + +@pytest.mark.asyncio +async def test_cancel_does_not_escalate_pending_errors() -> None: + handler, mount_callback = _make_handler() + + await handler.handle_event(_call_event("a")) + await handler.handle_event(_error_result("a")) + result_widget = _last_result_widget(mount_callback) + result_widget.escalate_error = Mock() + + handler.stop_current_tool_call(cancelled=True) + + result_widget.escalate_error.assert_not_called() + assert handler._pending_error_results == [] diff --git a/tests/cli/textual_ui/test_tool_error_muting_widgets.py b/tests/cli/textual_ui/test_tool_error_muting_widgets.py new file mode 100644 index 0000000..1166bb8 --- /dev/null +++ b/tests/cli/textual_ui/test_tool_error_muting_widgets.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import pytest +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.content import Content +from textual.widgets import Static + +from tests.stubs.fake_tool import FakeTool, FakeToolArgs +from vibe.cli.textual_ui.widgets.collapsible import CollapsibleSection +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage +from vibe.core.types import ToolCallEvent, ToolResultEvent + + +class _ToolApp(App[None]): + def __init__( + self, call_event: ToolCallEvent, result_event: ToolResultEvent + ) -> None: + super().__init__() + self._call_event = call_event + self._result_event = result_event + self.call_widget: ToolCallMessage | None = None + self.result_widget: ToolResultMessage | None = None + + def compose(self) -> ComposeResult: + yield Vertical(id="root") + + async def on_mount(self) -> None: + root = self.query_one("#root", Vertical) + self.call_widget = ToolCallMessage(self._call_event) + await root.mount(self.call_widget) + self.result_widget = ToolResultMessage(self._result_event, self.call_widget) + await root.mount(self.result_widget) + + +def _rendered(widget: Static) -> Content: + content = widget.render() + assert isinstance(content, Content) + return content + + +def _call_event() -> ToolCallEvent: + return ToolCallEvent( + tool_name="stub_tool", + tool_class=FakeTool, + args=FakeToolArgs(), + tool_call_id="a", + ) + + +def _error_result() -> ToolResultEvent: + return ToolResultEvent( + tool_name="stub_tool", + tool_class=FakeTool, + result=None, + error="boom", + tool_call_id="a", + ) + + +@pytest.mark.asyncio +async def test_error_renders_muted_then_escalates_icon_and_styling() -> None: + app = _ToolApp(_call_event(), _error_result()) + async with app.run_test() as pilot: + await pilot.pause() + call_widget = app.call_widget + result_widget = app.result_widget + assert call_widget is not None and result_widget is not None + + icon = call_widget._indicator_widget + assert icon is not None + # Default: held as a neutral grey square while the verdict is unknown. + assert _rendered(icon).plain == "□" + assert icon.has_class("muted") + assert not icon.has_class("error") + assert not result_widget.has_class("error-text") + + result_widget.escalate_error() + await pilot.pause() + + # Escalated: red-cross icon, but the folded body keeps its muted style + # (only the "Error" word colored, no whole-body error-text). + assert _rendered(icon).plain == "✕" + assert icon.has_class("error") + assert not icon.has_class("muted") + assert not result_widget.has_class("error-text") + + +@pytest.mark.asyncio +async def test_folded_error_detail_colors_only_the_error_word() -> None: + app = _ToolApp(_call_event(), _error_result()) + async with app.run_test() as pilot: + await pilot.pause() + result_widget = app.result_widget + assert result_widget is not None + + section = result_widget.query_one(CollapsibleSection) + detail = section.query_one(Static) + # A markup-enabled Static is required so only "Error" can be colored. + assert not isinstance(detail, NoMarkupStatic) + content = _rendered(detail) + assert content.plain == "Error: boom" + assert any( + span.start == 0 and span.end == len("Error") for span in content.spans + ) diff --git a/tests/cli/textual_ui/test_tool_result_link.py b/tests/cli/textual_ui/test_tool_result_link.py index 5c3443e..e979dff 100644 --- a/tests/cli/textual_ui/test_tool_result_link.py +++ b/tests/cli/textual_ui/test_tool_result_link.py @@ -5,7 +5,7 @@ from textual.app import App, ComposeResult from vibe.cli.textual_ui.widgets.links import ( LinkStatic, - link_markup, + link_content, linkify_urls_in_text, ) from vibe.cli.textual_ui.widgets.tool_widgets import WebSearchResultWidget @@ -13,32 +13,41 @@ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource -def test_link_markup_encodes_url_in_action_and_renders_label() -> None: +def _click_actions(content: object) -> list[str]: + spans = getattr(content, "spans", []) + return [ + span.style.meta["@click"] + for span in spans + if span.style.meta and "@click" in span.style.meta + ] + + +def test_link_content_encodes_url_in_action_and_keeps_label() -> None: # The action arg is percent-encoded; the visible label is the page name. - assert ( - link_markup("Example", "https://example.com") - == "[@click=open_url('https%3A%2F%2Fexample.com')]Example[/]" - ) + content = link_content("Example", "https://example.com") + assert content.plain == "Example" + assert _click_actions(content) == ["open_url('https%3A%2F%2Fexample.com')"] -def test_link_markup_only_links_http_schemes() -> None: - # Non-http(s) schemes render as the plain label, not a clickable @click span. +def test_link_content_only_links_http_schemes() -> None: + # Non-http(s) schemes render as the plain label, with no clickable @click span. for url in ("file:///etc/passwd", "javascript:alert(1)", "vscode://x"): - assert link_markup(url, url) == url - assert "@click" not in link_markup(url, url) + content = link_content(url, url) + assert content.plain == url + assert _click_actions(content) == [] -def test_link_markup_handles_previously_unsafe_urls() -> None: - # Brackets, quotes, parens — all encoded into the action, never a fallback. +def test_link_content_handles_previously_unsafe_urls() -> None: + # Brackets, quotes, parens live in the encoded action, never in the label text. for url in ( "https://e.org/x[1]", "https://e.org/it's", "https://e.org/x)", "https://en.wikipedia.org/wiki/Python_(programming_language)", ): - markup = link_markup(url, url) - assert markup.startswith("[@click=open_url('") - assert "[1]" not in markup.split("]", 1)[0] # raw bracket not in the tag + content = link_content(url, url) + assert content.plain == url # literal text, never re-parsed as markup + assert len(_click_actions(content)) == 1 class _Harness(App): @@ -48,7 +57,7 @@ class _Harness(App): self.opened: list[str] = [] def compose(self) -> ComposeResult: - yield LinkStatic(link_markup(self.url, self.url)) + yield LinkStatic(link_content(self.url, self.url)) def open_url(self, url: str, *, new_tab: bool = True) -> None: self.opened.append(url) @@ -112,33 +121,36 @@ async def test_unsafe_schemes_are_rejected(scheme: str) -> None: def test_linkify_urls_in_text_auto_detects_url() -> None: # Once a tool is opted into linkification, URLs are found in the message # itself — the call site doesn't have to point at the URL span. - markup = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)") - assert markup.startswith("Fetched ") - assert ( - "[@click=open_url('https%3A%2F%2Fexample.com')]https://example.com[/]" in markup - ) + content = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)") + assert content.plain == "Fetched https://example.com (10 chars, text/html)" + assert _click_actions(content) == ["open_url('https%3A%2F%2Fexample.com')"] def test_linkify_urls_in_text_handles_multiple_urls() -> None: - markup = linkify_urls_in_text("see https://a.com and https://b.com here") - assert "[@click=open_url('https%3A%2F%2Fa.com')]https://a.com[/]" in markup - assert "[@click=open_url('https%3A%2F%2Fb.com')]https://b.com[/]" in markup + content = linkify_urls_in_text("see https://a.com and https://b.com here") + assert _click_actions(content) == [ + "open_url('https%3A%2F%2Fa.com')", + "open_url('https%3A%2F%2Fb.com')", + ] def test_linkify_urls_in_text_keeps_balanced_parens_in_url() -> None: # Wikipedia-style URLs with `(…)` were the reason the @click action is # percent-encoded; Rich's URL detector already keeps them in the span. url = "https://en.wikipedia.org/wiki/Python_(programming_language)" - markup = linkify_urls_in_text(f"see {url} for details") - assert link_markup(url, url) in markup + content = linkify_urls_in_text(f"see {url} for details") + assert url in content.plain + assert _click_actions(content) == [ + "open_url('https%3A%2F%2Fen.wikipedia.org%2Fwiki%2F" + "Python_%28programming_language%29')" + ] -def test_linkify_urls_in_text_escapes_and_keeps_plain_when_no_url() -> None: - # Brackets must be escaped so raw tool text can't break the markup. - assert ( - linkify_urls_in_text("Searched '[a]' (2 sources)") - == "Searched '\\[a]' (2 sources)" - ) +def test_linkify_urls_in_text_keeps_brackets_literal_when_no_url() -> None: + # Raw tool text with brackets stays literal (Content is never markup-parsed). + content = linkify_urls_in_text("Searched '[a]' (2 sources)") + assert content.plain == "Searched '[a]' (2 sources)" + assert _click_actions(content) == [] async def _rendered_lines(widget: WebSearchResultWidget) -> list[str]: @@ -216,7 +228,7 @@ async def test_tool_call_message_set_result_text_renders_clickable_url() -> None @pytest.mark.asyncio -async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> None: +async def test_tool_call_message_set_result_text_keeps_brackets_literal_off() -> None: call = ToolCallMessage(tool_name="bash") class _H(App): @@ -234,3 +246,27 @@ async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> N assert "@click=open_url" not in rendered assert "https://example.com" in rendered assert "[exit 0]" in rendered + + +@pytest.mark.asyncio +@pytest.mark.parametrize("linkify", [False, True]) +async def test_tool_call_message_renders_malformed_markup_without_crashing( + linkify: bool, +) -> None: + # A bash summary like `git tag [/foo bar]` reads as a broken Rich closing + # tag and used to raise MarkupError when rendered into a markup=True widget. + call = ToolCallMessage(tool_name="bash") + + class _H(App): + def compose(self) -> ComposeResult: + yield call + + text = "ran: git tag [/foo bar] && echo [done] https://example.com" + async with _H().run_test() as pilot: + await pilot.pause(0.1) + call.set_result_text(text, linkify=linkify) + await pilot.pause(0.1) + rendered = str(call._text_widget.render()) if call._text_widget else "" + + assert "[/foo bar]" in rendered + assert "[done]" in rendered diff --git a/tests/core/test_config_otel.py b/tests/core/test_config_otel.py index f224766..93a721d 100644 --- a/tests/core/test_config_otel.py +++ b/tests/core/test_config_otel.py @@ -4,9 +4,16 @@ import pytest from tests.constants import ANTHROPIC_BASE_URL from vibe.core.config import OtelSpanExporterConfig, ProviderConfig, VibeConfig +from vibe.core.tracing import build_otel_span_exporter_config from vibe.core.types import Backend +def _exporter_config(config: VibeConfig) -> OtelSpanExporterConfig | None: + return build_otel_span_exporter_config( + config.otel_endpoint, config.get_mistral_provider() + ) + + class TestOtelSpanExporterConfig: def test_derives_endpoint_from_mistral_provider( self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch @@ -23,7 +30,7 @@ class TestOtelSpanExporterConfig: ] } ) - result = config.otel_span_exporter_config + result = _exporter_config(config) assert result is not None assert result.endpoint == "https://customer.mistral.ai/telemetry/v1/traces" assert result.headers == {"Authorization": "Bearer sk-test"} @@ -50,7 +57,7 @@ class TestOtelSpanExporterConfig: ] } ) - result = config.otel_span_exporter_config + result = _exporter_config(config) assert result is not None assert result.endpoint == "https://eu.mistral.ai/telemetry/v1/traces" assert result.headers == {"Authorization": "Bearer sk-eu"} @@ -68,7 +75,7 @@ class TestOtelSpanExporterConfig: ] } ) - result = config.otel_span_exporter_config + result = _exporter_config(config) assert result is not None assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces" assert result.headers == {"Authorization": "Bearer sk-fallback"} @@ -77,7 +84,7 @@ class TestOtelSpanExporterConfig: self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("MISTRAL_API_KEY", "sk-default") - result = vibe_config.otel_span_exporter_config + result = _exporter_config(vibe_config) assert result is not None assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces" @@ -89,7 +96,7 @@ class TestOtelSpanExporterConfig: monkeypatch.setattr( "keyring.get_password", lambda service, username: "sk-keyring" ) - result = vibe_config.otel_span_exporter_config + result = _exporter_config(vibe_config) assert result is not None assert result.headers == {"Authorization": "Bearer sk-keyring"} @@ -101,7 +108,7 @@ class TestOtelSpanExporterConfig: ) -> None: monkeypatch.delenv("MISTRAL_API_KEY", raising=False) with caplog.at_level("WARNING"): - assert vibe_config.otel_span_exporter_config is None + assert _exporter_config(vibe_config) is None assert "OTEL tracing enabled but MISTRAL_API_KEY is not set" in caplog.text def test_custom_api_key_env_var( @@ -121,7 +128,7 @@ class TestOtelSpanExporterConfig: ] } ) - result = config.otel_span_exporter_config + result = _exporter_config(config) assert result is not None assert result.endpoint == "https://onprem.corp.com/telemetry/v1/traces" assert result.headers == {"Authorization": "Bearer sk-custom"} @@ -132,7 +139,7 @@ class TestOtelSpanExporterConfig: config = vibe_config.model_copy( update={"otel_endpoint": "https://my-collector:4318"} ) - result = config.otel_span_exporter_config + result = _exporter_config(config) assert result is not None assert result == OtelSpanExporterConfig( endpoint="https://my-collector:4318/v1/traces" @@ -145,7 +152,7 @@ class TestOtelSpanExporterConfig: config = vibe_config.model_copy( update={"otel_endpoint": "https://my-collector:4318/api/public/otel"} ) - result = config.otel_span_exporter_config + result = _exporter_config(config) assert result is not None assert result == OtelSpanExporterConfig( endpoint="https://my-collector:4318/api/public/otel/v1/traces" diff --git a/tests/core/test_environment_layer.py b/tests/core/test_environment_layer.py index 070e7d2..61e06fc 100644 --- a/tests/core/test_environment_layer.py +++ b/tests/core/test_environment_layer.py @@ -14,7 +14,7 @@ async def test_reads_env_vars() -> None: env = { "MISTRAL_API_KEY": "test-key", "VIBE_ACTIVE_MODEL": "mistral-large", - "VIBE_VIM_KEYBINDINGS": "true", + "VIBE_DISABLE_WELCOME_BANNER_ANIMATION": "true", "VIBE_ENABLE_TELEMETRY": "0", "VIBE_UNKNOWN_VAR": "ignored", "VIBE_SESSION_LOGGING__ENABLED": "false", @@ -27,7 +27,7 @@ async def test_reads_env_vars() -> None: assert data.model_dump() == { "active_model": "mistral-large", - "vim_keybindings": True, + "disable_welcome_banner_animation": True, "enable_telemetry": False, "session_logging": {"enabled": False, "session_prefix": "mysession"}, "api_timeout": 0.12, diff --git a/tests/core/test_vibe_config_schema.py b/tests/core/test_vibe_config_schema.py index da3ff2d..89625bc 100644 --- a/tests/core/test_vibe_config_schema.py +++ b/tests/core/test_vibe_config_schema.py @@ -32,7 +32,7 @@ async def test_full_toml_to_vibe_config_schema(tmp_path: Path) -> None: toml_path = tmp_path / "config.toml" toml_path.write_text( """\ -vim_keybindings = true +disable_welcome_banner_animation = true api_timeout = 300.0 api_retry_max_elapsed_time = 120.0 active_model = "codestral" @@ -60,7 +60,7 @@ provider = "mistral" ) config = orchestrator.config - assert config.vim_keybindings is True + assert config.disable_welcome_banner_animation is True assert config.api_timeout == 300.0 assert config.api_retry_max_elapsed_time == 120.0 assert config.active_model == "codestral" diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg index 2f8f4d3..76e0abe 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg @@ -196,7 +196,7 @@ Auto-copy: On Autocomplete watcher (may delay first autocompletion): Off -↑↓ Navigate  Enter Select/Toggle  Esc Exit +↑↓/jk Navigate  Enter Select/Toggle  Esc Exit └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg index b0b33c0..29f67d5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg @@ -196,7 +196,7 @@ Auto-copy: On Autocomplete watcher (may delay first autocompletion): Off -↑↓ Navigate  Enter Select/Toggle  Esc Exit +↑↓/jk Navigate  Enter Select/Toggle  Esc Exit └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg index 19479d6..ba331b4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg @@ -196,7 +196,7 @@ Auto-copy: Off Autocomplete watcher (may delay first autocompletion): Off -↑↓ Navigate  Enter Select/Toggle  Esc Exit +↑↓/jk Navigate  Enter Select/Toggle  Esc Exit └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg index 18dc5c7..c90cb02 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg @@ -214,7 +214,7 @@   4. Deny -↑↓ navigate  Enter select  ESC reject +↑↓/jk navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg index 7ce0d5a..0d3b086 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg @@ -173,7 +173,7 @@   4. Deny -↑↓ navigate  Enter select  ESC reject +↑↓/jk navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg index b810d57..8c1fb9f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg @@ -172,7 +172,7 @@   4. Deny -↑↓ navigate  Enter select  ESC reject +↑↓/jk navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg index cf802fa..57df59b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff.svg @@ -154,7 +154,7 @@ - +   ⡠⣒⠄  ⡔⢄⠔⡄ @@ -183,7 +183,7 @@   4. Deny -↑↓ navigate  Enter select  ESC reject +↑↓/jk navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg index 219a86b..86f30ad 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_ansi.svg @@ -175,7 +175,7 @@   4. Deny -↑↓ navigate  Enter select  ESC reject +↑↓/jk navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg index ebf9761..8af4c19 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg @@ -153,7 +153,7 @@ - +   ⠉⠒⠣⠤⠵⠤⠬⠮⠆ @@ -182,7 +182,7 @@   4. Deny -↑↓ navigate  Enter select  ESC reject +↑↓/jk navigate  Enter select  ESC reject └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg index 1fd9bde..71e6b15 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg @@ -197,7 +197,7 @@   alpha  [connector]  1 tool   connected   beta   [connector]  no tools needs auth   zeta   [connector]  no tools needs setup -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg index 92cf740..c0d1f13 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg @@ -198,7 +198,7 @@ Local MCP Servers   filesystem  [stdio]  1 tool enabled   search      [http]   1 tool enabled -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg index 897bc36..45c337d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg @@ -198,7 +198,7 @@   filesystem     [stdio]  1 tool   enabled   broken-server  [stdio]  no tools enabled   search         [http]   1 tool   enabled -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg index 10bd823..60bfd3b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg @@ -198,7 +198,7 @@ Workspace Connectors   gmail  [connector]  3 tools connected   slack  [connector]  2 tools connected -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg index 1f94edb..39ad81a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg @@ -198,7 +198,7 @@ Workspace Connectors   gmail  [connector]  3 tools connected   slack  [connector]  2 tools connected -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg index 0f04354..a77c050 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg @@ -197,7 +197,7 @@   alpha  [connector]  1 tool   connected   beta   [connector]  no tools needs auth   zeta   [connector]  no tools needs setup -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg index 3aae522..541ed68 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg @@ -195,7 +195,7 @@ search_messages  -  Search Slack messages send_message  -  Send a Slack message -↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close +↑↓/jk Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg index 3deae52..9cb0eb1 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg @@ -194,7 +194,7 @@ MCP Server: filesystem fake_tool  -  A fake tool for filesystem -↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close +↑↓/jk Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg index 8e9dd3c..88439f4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg @@ -197,7 +197,7 @@   alpha  [connector]  1 tool   connected   beta   [connector]  no tools needs auth   zeta   [connector]  no tools needs setup -↑↓ Navigate  Enter Connect  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Connect  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg index 7d6327d..74b3d20 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg @@ -198,7 +198,7 @@ Local MCP Servers   filesystem  [stdio]  1 tool enabled   search      [http]   1 tool enabled -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg index e08ac1a..f34f3cb 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg @@ -198,7 +198,7 @@ Local MCP Servers   filesystem  [stdio]  1 tool enabled   search      [http]   1 tool enabled -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg index 205d4a2..554c6b5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg @@ -198,7 +198,7 @@ Local MCP Servers   filesystem  [stdio]  1 tool enabled   search      [http]   1 tool enabled -Refreshed.  ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +Refreshed.  ↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg index 4102d9a..504ebb1 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg @@ -194,7 +194,7 @@ MCP Server: filesystem fake_tool  -  A fake tool for filesystem -↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close +↑↓/jk Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg index 10bd823..60bfd3b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg @@ -198,7 +198,7 @@ Workspace Connectors   gmail  [connector]  3 tools connected   slack  [connector]  2 tools connected -↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +↑↓/jk Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg index c9a9fb7..ca0ea8b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg @@ -195,7 +195,7 @@   mistral-small   local -↑↓ Navigate  Enter Select  Esc Cancel +↑↓/jk Navigate  Enter Select  Esc Cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg index 98711d5..1efcfe7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg @@ -196,7 +196,7 @@   mistral-small   local -↑↓ Navigate  Enter Select  Esc Cancel +↑↓/jk Navigate  Enter Select  Esc Cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg index fe0f5fc..ba6bd55 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_answer_first_advance.svg @@ -111,7 +111,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐  DB ✓   [Framework] @@ -122,7 +122,7 @@   2. Django   3. Type your answer... -←→ questions  ↑↓ navigate  Enter select  Esc cancel +←→ questions  ↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg index fe0f5fc..ba6bd55 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_first_answered_checkmark.svg @@ -111,7 +111,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐  DB ✓   [Framework] @@ -122,7 +122,7 @@   2. Django   3. Type your answer... -←→ questions  ↑↓ navigate  Enter select  Esc cancel +←→ questions  ↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg index 3f522d1..59723ec 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_initial.svg @@ -111,7 +111,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ [DB]   Framework  @@ -122,7 +122,7 @@   2. MongoDB   3. Type your answer... -←→ questions  ↑↓ navigate  Enter select  Esc cancel +←→ questions  ↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg index b9a832f..ae75c4b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_left_wraps.svg @@ -111,7 +111,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐  DB   [Framework] @@ -122,7 +122,7 @@   2. Django   3. Type your answer... -←→ questions  ↑↓ navigate  Enter select  Esc cancel +←→ questions  ↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg index b9a832f..ae75c4b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_navigate_right.svg @@ -111,7 +111,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐  DB   [Framework] @@ -122,7 +122,7 @@   2. Django   3. Type your answer... -←→ questions  ↑↓ navigate  Enter select  Esc cancel +←→ questions  ↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg index 3f522d1..59723ec 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_question_tab_to_second.svg @@ -111,7 +111,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ [DB]   Framework  @@ -122,7 +122,7 @@   2. MongoDB   3. Type your answer... -←→ questions  ↑↓ navigate  Enter select  Esc cancel +←→ questions  ↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg index 6000640..a9db7cc 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_initial.svg @@ -110,7 +110,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? @@ -122,7 +122,7 @@      Submit → -↑↓ navigate  Enter toggle  Esc cancel +↑↓/jk navigate  Enter toggle  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg index 4f59125..7747fe6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_mixed_selection.svg @@ -112,7 +112,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? @@ -124,7 +124,7 @@      Submit → -↑↓ navigate  Enter toggle  Esc cancel +↑↓/jk navigate  Enter toggle  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg index 12c1c53..6e37cdf 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_navigate_to_submit.svg @@ -111,7 +111,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? @@ -123,7 +123,7 @@ ›    Submit → -↑↓ navigate  Enter toggle  Esc cancel +↑↓/jk navigate  Enter toggle  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg index bcbdacb..aa15886 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_other_with_text.svg @@ -112,7 +112,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? @@ -124,7 +124,7 @@      Submit → -↑↓ navigate  Enter toggle  Esc cancel +↑↓/jk navigate  Enter toggle  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg index 5f66515..5f7abc0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_first.svg @@ -110,7 +110,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? @@ -122,7 +122,7 @@      Submit → -↑↓ navigate  Enter toggle  Esc cancel +↑↓/jk navigate  Enter toggle  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg index 473f784..4f9ac9f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_toggle_multiple.svg @@ -110,7 +110,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? @@ -122,7 +122,7 @@      Submit → -↑↓ navigate  Enter toggle  Esc cancel +↑↓/jk navigate  Enter toggle  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg index 6000640..a9db7cc 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_multi_select_untoggle.svg @@ -110,7 +110,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which features do you want to enable? @@ -122,7 +122,7 @@      Submit → -↑↓ navigate  Enter toggle  Esc cancel +↑↓/jk navigate  Enter toggle  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg index 585680e..4e54af2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_initial.svg @@ -110,7 +110,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project? @@ -120,7 +120,7 @@   3. Redis - In-memory store   4. Type your answer... -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg index 8cd0d3b..af29bdd 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_down.svg @@ -110,7 +110,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project? @@ -120,7 +120,7 @@   3. Redis - In-memory store   4. Type your answer... -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg index 6993ff6..f32582e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_other.svg @@ -113,7 +113,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project? @@ -123,7 +123,7 @@   3. Redis - In-memory store › 4. Type your answer... -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg index c0b3e71..a18c477 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_to_third_option.svg @@ -110,7 +110,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project? @@ -120,7 +120,7 @@ › 3. Redis - In-memory store   4. Type your answer... -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg index 6993ff6..f32582e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_navigate_up_wraps.svg @@ -113,7 +113,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project? @@ -123,7 +123,7 @@   3. Redis - In-memory store › 4. Type your answer... -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg index d1236fa..4103d83 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_question_app/test_snapshot_question_app_other_typing.svg @@ -112,7 +112,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ Which database should we use for this project? @@ -122,7 +122,7 @@   3. Redis - In-memory store › 4. SQLite -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg index 6309ca8..bcebe68 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg @@ -196,7 +196,7 @@ › 1. Edit & restore files to this point   2. Edit without restoring files -Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel +Alt+↑↓ or Ctrl+P/N browse messages  ↑↓/jk pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg index fcdbaf2..178d95b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg @@ -195,7 +195,7 @@ › 1. Edit & restore files to this point   2. Edit without restoring files -Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel +Alt+↑↓ or Ctrl+P/N browse messages  ↑↓/jk pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg index 17b388b..f5d97f3 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg @@ -195,7 +195,7 @@ › 1. Edit & restore files to this point   2. Edit without restoring files -Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel +Alt+↑↓ or Ctrl+P/N browse messages  ↑↓/jk pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg index fcdbaf2..178d95b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg @@ -195,7 +195,7 @@ › 1. Edit & restore files to this point   2. Edit without restoring files -Alt+↑↓ or Ctrl+P/N browse messages  ↑↓ pick option  Enter confirm  ESC cancel +Alt+↑↓ or Ctrl+P/N browse messages  ↑↓/jk pick option  Enter confirm  ESC cancel └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir15/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg index eb718a9..8943e2e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg @@ -194,7 +194,7 @@ unknown   local-se  Refactor the auth module unknown   local-se  Add unit tests for the API -↑↓ Navigate  Enter Select  D Delete  Esc Cancel +↑↓/jk Navigate  Enter Select  D Delete  Esc Cancel └──────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0/200k tokens (0%) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_cancel_selected.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_cancel_selected.svg index bbe32b3..093ca97 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_cancel_selected.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_cancel_selected.svg @@ -86,7 +86,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ You have 5 unpushed commits. Push to continue? @@ -94,7 +94,7 @@   1. Push and continue › 2. Cancel -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_multiple_commits.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_multiple_commits.svg index 26c184d..0fccbcd 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_multiple_commits.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_multiple_commits.svg @@ -86,7 +86,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ You have 5 unpushed commits. Push to continue? @@ -94,7 +94,7 @@ › 1. Push and continue   2. Cancel -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_single_commit.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_single_commit.svg index ddcadbc..b95c65e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_single_commit.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_push_confirmation_single_commit.svg @@ -86,7 +86,7 @@ - + ┌──────────────────────────────────────────────────────────────────────────────┐ You have 1 unpushed commit. Push to continue? @@ -94,7 +94,7 @@ › 1. Push and continue   2. Cancel -↑↓ navigate  Enter select  Esc cancel +↑↓/jk navigate  Enter select  Esc cancel └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 9dd5480..2ca156c 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -60,24 +60,27 @@ class TestSetupTracing: mock_set.assert_not_called() def test_noop_when_exporter_config_is_none(self) -> None: - config = MagicMock( - enable_telemetry=True, enable_otel=True, otel_span_exporter_config=None - ) - with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: + config = MagicMock(enable_telemetry=True, enable_otel=True) + with ( + patch( + "vibe.core.tracing.build_otel_span_exporter_config", return_value=None + ), + patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set, + ): setup_tracing(config) mock_set.assert_not_called() def test_configures_provider_from_exporter_config(self) -> None: - config = MagicMock( - enable_telemetry=True, - enable_otel=True, - otel_span_exporter_config=OtelSpanExporterConfig( - endpoint="https://customer.mistral.ai/telemetry/v1/traces", - headers={"Authorization": "Bearer sk-test"}, - ), - ) + config = MagicMock(enable_telemetry=True, enable_otel=True) with ( + patch( + "vibe.core.tracing.build_otel_span_exporter_config", + return_value=OtelSpanExporterConfig( + endpoint="https://customer.mistral.ai/telemetry/v1/traces", + headers={"Authorization": "Bearer sk-test"}, + ), + ), patch( "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter" ) as mock_exporter, @@ -93,15 +96,15 @@ class TestSetupTracing: assert isinstance(mock_set.call_args[0][0], TracerProvider) def test_custom_endpoint_has_no_auth_headers(self) -> None: - config = MagicMock( - enable_telemetry=True, - enable_otel=True, - otel_span_exporter_config=OtelSpanExporterConfig( - endpoint="https://my-collector:4318/v1/traces" - ), - ) + config = MagicMock(enable_telemetry=True, enable_otel=True) with ( + patch( + "vibe.core.tracing.build_otel_span_exporter_config", + return_value=OtelSpanExporterConfig( + endpoint="https://my-collector:4318/v1/traces" + ), + ), patch( "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter" ) as mock_exporter, diff --git a/uv.lock b/uv.lock index af1a766..d14d0b1 100644 --- a/uv.lock +++ b/uv.lock @@ -785,7 +785,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -803,9 +803,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] @@ -831,7 +831,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.18.1" +version = "2.18.2" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -908,6 +908,7 @@ dependencies = [ { name = "rich" }, { name = "rpds-py" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, + { name = "sentry-sdk" }, { name = "six" }, { name = "smmap" }, { name = "sounddevice" }, @@ -1001,7 +1002,7 @@ requires-dist = [ { name = "linkify-it-py", specifier = "==2.1.0" }, { name = "markdown-it-py", specifier = "==4.0.0" }, { name = "markdownify", specifier = "==1.2.2" }, - { name = "mcp", specifier = "==1.28.0" }, + { name = "mcp", specifier = "==1.28.1" }, { name = "mdit-py-plugins", specifier = "==0.5.0" }, { name = "mdurl", specifier = "==0.1.2" }, { name = "mistralai", specifier = "==2.4.11" }, @@ -1037,6 +1038,7 @@ requires-dist = [ { name = "rich", specifier = "==15.0.0" }, { name = "rpds-py", specifier = "==0.30.0" }, { name = "secretstorage", marker = "sys_platform == 'linux'", specifier = "==3.5.0" }, + { name = "sentry-sdk", specifier = "==2.63.0" }, { name = "six", specifier = "==1.17.0" }, { name = "smmap", specifier = "==5.0.3" }, { name = "sounddevice", specifier = "==0.5.5" }, @@ -1983,6 +1985,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "sentry-sdk" +version = "2.63.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/c8/b3c970a5b186722d276cd40a05b3254e03bccc0208560aff20f612e018e8/sentry_sdk-2.63.0.tar.gz", hash = "sha256:2a1502bf864769275dbc8c2c9fc7a0f7f5e18358180b615d262d13a31ffba216", size = 912449, upload-time = "2026-06-16T12:45:57.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/57/cb205f7d93373120f666b9c5736dc0815524d96a9b278e7a728f018dc22a/sentry_sdk-2.63.0-py3-none-any.whl", hash = "sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a", size = 495950, upload-time = "2026-06-16T12:45:55.819Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" diff --git a/vibe/__init__.py b/vibe/__init__.py index 1cda36b..2c66307 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__ = "2.18.1" +__version__ = "2.18.2" diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index f0fa634..df66b72 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -33,6 +33,7 @@ from vibe.core.hooks.config import HookConfigResult, load_hooks_from_fs from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE from vibe.core.programmatic import run_programmatic +from vibe.core.sentry import init_sentry from vibe.core.session import last_session_pointer from vibe.core.session.session_loader import SessionLoader from vibe.core.telemetry.build_metadata import build_entrypoint_metadata @@ -374,6 +375,8 @@ def run_cli( *, resolve_trusted_folder: Callable[[], None] | None = None, ) -> None: + sentry_enabled = False + load_dotenv_values() bootstrap_config_files() @@ -398,6 +401,11 @@ def run_cli( resolve_trusted_folder() config = load_config_or_exit(interactive=True) + sentry_enabled = init_sentry( + config, + headless=not is_interactive, + entrypoint_metadata=_build_cli_entrypoint_metadata(), + ) initial_agent_name = get_initial_agent_name(args, config) hook_config_result = load_hooks_from_fs(config) setup_tracing(config) @@ -450,3 +458,9 @@ def run_cli( except (KeyboardInterrupt, EOFError): rprint("\n[dim]Bye![/]") sys.exit(0) + finally: + if sentry_enabled: + import sentry_sdk + + if sentry_sdk.is_initialized(): + sentry_sdk.flush(timeout=5) diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 9bbc531..466f52a 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -28,6 +28,7 @@ from textual.theme import BUILTIN_THEMES from textual.timer import Timer from textual.widget import Widget from textual.widgets import Static +from textual.worker import Worker, WorkerFailed, WorkerState from vibe import __version__ as CORE_VERSION from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard @@ -177,6 +178,7 @@ from vibe.core.log_reader import LogReader from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE from vibe.core.rewind import RewindError +from vibe.core.sentry import capture_sentry_exception from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image from vibe.core.session.resume_sessions import ( ResumeSessionInfo, @@ -1821,11 +1823,13 @@ class VibeApp(App): # noqa: PLR0904 self._pending_question = None await self._switch_to_input_app() - async def _handle_turn_error(self) -> None: + async def _handle_turn_error(self, *, cancelled: bool = False) -> None: if self._loading_widget and self._loading_widget.parent: await self._loading_widget.remove() if self.event_handler: - self.event_handler.stop_current_tool_call(success=False) + self.event_handler.stop_current_tool_call( + success=False, cancelled=cancelled + ) async def _handle_agent_loop_init(self) -> None: show_init_spinner = not self.agent_loop.is_initialized @@ -1900,7 +1904,7 @@ class VibeApp(App): # noqa: PLR0904 ) as events: await self._handle_agent_loop_events(events) except asyncio.CancelledError: - await self._handle_turn_error() + await self._handle_turn_error(cancelled=True) self._narrator_manager.on_turn_cancel() raise except Exception as e: @@ -1927,6 +1931,7 @@ class VibeApp(App): # noqa: PLR0904 self._loading_widget = None if self.event_handler: await self.event_handler.finalize_streaming() + self.event_handler.escalate_unresolved_errors() self._queue.notify_busy_changed() self._queue.start_drain_if_needed() await self._refresh_windowing_from_history() @@ -2119,7 +2124,7 @@ class VibeApp(App): # noqa: PLR0904 pass if self.event_handler: - self.event_handler.stop_current_tool_call(success=False) + self.event_handler.stop_current_tool_call(cancelled=True) self.event_handler.stop_current_compact() await self.event_handler.finalize_streaming() @@ -3776,6 +3781,25 @@ class VibeApp(App): # noqa: PLR0904 telemetry_client=self.agent_loop.telemetry_client, ) + def _handle_exception(self, error: Exception) -> None: + if not isinstance(error, WorkerFailed): + capture_sentry_exception( + error, fatal=True, tags={"vibe_boundary": "textual_app"} + ) + return super()._handle_exception(error) + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + error = event.worker.error + if event.state == WorkerState.ERROR and error: + capture_sentry_exception( + error, + fatal=False, + tags={ + "vibe_boundary": "textual_worker", + "worker_name": event.worker.name or "", + }, + ) + async def _run_app_with_cleanup(app: VibeApp) -> str | None: from vibe.cli.stderr_guard import stderr_guard diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 6784da7..cd39d7f 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -722,6 +722,14 @@ StatusMessage { &.error { color: $error; } + + &.muted { + color: $text-muted; + + &:ansi { + text-style: dim; + } + } } .status-indicator-text { diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index 3763e54..7f2b706 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -67,6 +67,10 @@ class EventHandler: self._hook_containers: dict[str, HookRunContainer] = {} # Per-tool-call anchor for correct widget ordering during concurrent calls. self._tool_call_anchors: dict[str, Widget] = {} + # Errored results shown muted while their verdict is unknown: a follow-up + # tool call confirms them recoverable (stay muted), turn end escalates + # them to hard errors. + self._pending_error_results: list[ToolResultMessage] = [] async def _handle_hook_event( self, event: HookEvent, loading_widget: LoadingWidget | None = None @@ -206,6 +210,8 @@ class EventHandler: existing_tool_call.update_event(event) tool_call = existing_tool_call else: + # A follow-up tool call is the recovery signal: leave prior errors muted. + self._resolve_pending_errors(escalate=False) tool_call = ToolCallMessage(event) if tool_call_id: self.tool_calls[tool_call_id] = tool_call @@ -228,11 +234,24 @@ class EventHandler: tool_result = ToolResultMessage(event, call_widget) await self.mount_callback(tool_result, after=anchor) + if event.error and not event.skipped and not event.cancelled: + self._pending_error_results.append(tool_result) + if tool_call_id: self._tool_call_anchors[tool_call_id] = tool_result if tool_call_id in self.tool_calls: del self.tool_calls[tool_call_id] + def _resolve_pending_errors(self, *, escalate: bool) -> None: + if escalate: + for result in self._pending_error_results: + result.escalate_error() + self._pending_error_results.clear() + + def escalate_unresolved_errors(self) -> None: + # Called at turn end: errors not followed by a tool call were terminal. + self._resolve_pending_errors(escalate=True) + async def _handle_tool_stream(self, event: ToolStreamEvent) -> None: tool_call = self.tool_calls.get(event.tool_call_id) if tool_call: @@ -290,12 +309,20 @@ class EventHandler: await self.current_streaming_message.stop_stream() self.current_streaming_message = None - def stop_current_tool_call(self, success: bool = True) -> None: + def stop_current_tool_call( + self, success: bool = True, *, cancelled: bool = False + ) -> None: for tool_call in self.tool_calls.values(): - tool_call.stop_spinning(success=success) + if cancelled: + # A user interrupt is neutral, not a failure: hold a grey square. + tool_call.show_muted() + else: + tool_call.stop_spinning(success=success) self.tool_calls.clear() self._tool_call_anchors.clear() self._hook_containers.clear() + # On cancel nothing is terminal -- leave prior errors muted too. + self._resolve_pending_errors(escalate=not cancelled) def stop_current_compact(self) -> None: if self.current_compact: diff --git a/vibe/cli/textual_ui/widgets/approval_app.py b/vibe/cli/textual_ui/widgets/approval_app.py index 770ae14..e63ebed 100644 --- a/vibe/cli/textual_ui/widgets/approval_app.py +++ b/vibe/cli/textual_ui/widgets/approval_app.py @@ -14,13 +14,14 @@ from textual.widgets import Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget +from vibe.cli.textual_ui.widgets.vim_navigation import VimNavigationMixin from vibe.core.config import VibeConfig from vibe.core.tools.permissions import RequiredPermission _INPUT_GRACE_PERIOD_S = 0.5 -class ApprovalApp(Container): +class ApprovalApp(VimNavigationMixin, Container): can_focus = True can_focus_children = False @@ -113,7 +114,7 @@ class ApprovalApp(Container): self.option_widgets.append(widget) yield widget self.help_widget = NoMarkupStatic( - "↑↓ navigate Enter select ESC reject", classes="approval-help" + "↑↓/jk navigate Enter select ESC reject", classes="approval-help" ) yield self.help_widget @@ -266,5 +267,8 @@ class ApprovalApp(Container): ) ) + def on_key(self, event: events.Key) -> None: + self._handle_vim_navigation_key(event) + def on_blur(self, event: events.Blur) -> None: self.call_after_refresh(self.focus) diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py index a608591..1ac29d1 100644 --- a/vibe/cli/textual_ui/widgets/config_app.py +++ b/vibe/cli/textual_ui/widgets/config_app.py @@ -12,6 +12,7 @@ from textual.message import Message from textual.widgets import OptionList from textual.widgets.option_list import Option +from vibe.cli.textual_ui.widgets.navigable_option_list import NavigableOptionList from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic if TYPE_CHECKING: @@ -125,10 +126,10 @@ class ConfigApp(Container): with Vertical(id="config-content"): yield NoMarkupStatic("Settings", classes="settings-title") yield NoMarkupStatic("") - yield OptionList(*options, id="config-options") + yield NavigableOptionList(*options, id="config-options") yield NoMarkupStatic("") yield NoMarkupStatic( - "↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help" + "↑↓/jk Navigate Enter Select/Toggle Esc Exit", classes="settings-help" ) def on_mount(self) -> None: diff --git a/vibe/cli/textual_ui/widgets/links.py b/vibe/cli/textual_ui/widgets/links.py index ba6a438..ac93fb2 100644 --- a/vibe/cli/textual_ui/widgets/links.py +++ b/vibe/cli/textual_ui/widgets/links.py @@ -4,8 +4,9 @@ from typing import Any from urllib.parse import quote, unquote, urlsplit from rich.highlighter import ReprHighlighter -from rich.markup import escape from rich.text import Text +from textual.content import Content, ContentType +from textual.style import Style from textual.widgets import Static from vibe.core.logger import logger @@ -23,38 +24,37 @@ def _is_safe_url(url: str) -> bool: return urlsplit(url).scheme.lower() in _SAFE_SCHEMES -def link_markup(label: str, url: str) -> str: - # Only http(s) URLs become clickable; other schemes (file:, custom protocol - # handlers, ...) render as plain text so an untrusted source URL can't steer - # a click to a local handler. Percent-encode so brackets/quotes/parens can't - # break the markup or the @click action literal; action_open_url decodes it. - if not _is_safe_url(url): - return escape(label) - return f"[@click=open_url('{quote(url, safe='')}')]{escape(label)}[/]" +def _click_style(url: str) -> Style: + # Percent-encode so brackets/quotes/parens in the URL can't break the + # @click action literal; action_open_url decodes it. + return Style.from_meta({"@click": f"open_url('{quote(url, safe='')}')"}) -def linkify_urls_in_text(text: str) -> str: +def link_content(label: str, url: str) -> Content: + content = Content(label) + if _is_safe_url(url): + return content.stylize(_click_style(url)) + return content + + +def linkify_urls_in_text(text: str) -> Content: rich = Text(text) _URL_HIGHLIGHTER.highlight(rich) - spans = sorted( - (s for s in rich.spans if s.style == _URL_STYLE), key=lambda s: s.start - ) - if not spans: - return escape(text) - parts: list[str] = [] - cursor = 0 - for span in spans: - parts.append(escape(text[cursor : span.start])) + content = Content(text) + for span in rich.spans: + if span.style != _URL_STYLE: + continue url = text[span.start : span.end] - parts.append(link_markup(url, url)) - cursor = span.end - parts.append(escape(text[cursor:])) - return "".join(parts) + if _is_safe_url(url): + content = content.stylize(_click_style(url), span.start, span.end) + return content class LinkStatic(Static): - def __init__(self, content: str = "", **kwargs: Any) -> None: - super().__init__(content, markup=True, **kwargs) + def __init__(self, content: ContentType = "", **kwargs: Any) -> None: + # markup=False: plain strings render literally and Content objects carry + # their own styling/meta, so user text containing brackets cannot crash. + super().__init__(content, markup=False, **kwargs) def action_open_url(self, url: str) -> None: target = unquote(url) diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index c8c466e..0154db7 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -14,6 +14,7 @@ from textual.widgets import OptionList from textual.widgets.option_list import Option, OptionDoesNotExist from textual.worker import Worker +from vibe.cli.textual_ui.widgets.navigable_option_list import NavigableOptionList from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import ConnectorConfig, VibeConfig from vibe.core.tools.connectors import ConnectorAuthAction, ConnectorRegistry @@ -64,15 +65,15 @@ def collect_mcp_tool_index( _LIST_VIEW_HELP_TOOLS = ( - "↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close" + "↑↓/jk Navigate Enter Show tools D Disable E Enable R Refresh Esc Close" ) _LIST_VIEW_HELP_AUTH = ( - "↑↓ Navigate Enter Connect D Disable E Enable R Refresh Esc Close" + "↑↓/jk Navigate Enter Connect D Disable E Enable R Refresh Esc Close" ) _DETAIL_VIEW_HELP = ( - "↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close" + "↑↓/jk Navigate D Disable E Enable Backspace Back R Refresh Esc Close" ) -_DETAIL_VIEW_HELP_NO_TOOLS = "↑↓ Navigate Backspace Back R Refresh Esc Close" +_DETAIL_VIEW_HELP_NO_TOOLS = "↑↓/jk Navigate Backspace Back R Refresh Esc Close" class MCPApp(Container): @@ -163,7 +164,7 @@ class MCPApp(Container): with Vertical(id="mcp-content"): yield NoMarkupStatic("", id="mcp-title", classes="settings-title") yield NoMarkupStatic("") - yield OptionList(id="mcp-options") + yield NavigableOptionList(id="mcp-options") yield NoMarkupStatic("", id="mcp-help", classes="settings-help") def on_mount(self) -> None: diff --git a/vibe/cli/textual_ui/widgets/model_picker.py b/vibe/cli/textual_ui/widgets/model_picker.py index 89cb7b2..0e6d71d 100644 --- a/vibe/cli/textual_ui/widgets/model_picker.py +++ b/vibe/cli/textual_ui/widgets/model_picker.py @@ -10,6 +10,7 @@ from textual.message import Message from textual.widgets import OptionList from textual.widgets.option_list import Option +from vibe.cli.textual_ui.widgets.navigable_option_list import NavigableOptionList from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic @@ -53,9 +54,9 @@ class ModelPickerApp(Container): ] with Vertical(id="modelpicker-content"): yield NoMarkupStatic("Select Model", classes="modelpicker-title") - yield OptionList(*options, id="modelpicker-options") + yield NavigableOptionList(*options, id="modelpicker-options") yield NoMarkupStatic( - "↑↓ Navigate Enter Select Esc Cancel", classes="modelpicker-help" + "↑↓/jk Navigate Enter Select Esc Cancel", classes="modelpicker-help" ) def on_mount(self) -> None: diff --git a/vibe/cli/textual_ui/widgets/navigable_option_list.py b/vibe/cli/textual_ui/widgets/navigable_option_list.py new file mode 100644 index 0000000..dc66094 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/navigable_option_list.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import ClassVar + +from textual.binding import Binding, BindingType +from textual.widgets import OptionList + + +class NavigableOptionList(OptionList): + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("j", "cursor_down", "Down", show=False), + Binding("k", "cursor_up", "Up", show=False), + ] diff --git a/vibe/cli/textual_ui/widgets/question_app.py b/vibe/cli/textual_ui/widgets/question_app.py index 78714cf..6cfe2f8 100644 --- a/vibe/cli/textual_ui/widgets/question_app.py +++ b/vibe/cli/textual_ui/widgets/question_app.py @@ -13,6 +13,7 @@ from textual.reactive import reactive from textual.widgets import Input from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.textual_ui.widgets.vim_navigation import VimNavigationMixin from vibe.cli.textual_ui.widgets.vscode_compat import VscodeCompatInput _INPUT_GRACE_PERIOD_S = 0.5 @@ -27,7 +28,7 @@ if TYPE_CHECKING: from vibe.core.tools.builtins.ask_user_question import Answer -class QuestionApp(Container): +class QuestionApp(VimNavigationMixin, Container): MAX_OPTIONS: ClassVar[int] = 4 can_focus = True @@ -307,9 +308,9 @@ class QuestionApp(Container): if not self.help_widget: return if self._current_question.multi_select: - help_text = "↑↓ navigate Enter toggle Esc cancel" + help_text = "↑↓/jk navigate Enter toggle Esc cancel" else: - help_text = "↑↓ navigate Enter select Esc cancel" + help_text = "↑↓/jk navigate Enter select Esc cancel" if len(self.questions) > 1: help_text = "←→ questions " + help_text self.help_widget.update(help_text) @@ -483,9 +484,13 @@ class QuestionApp(Container): if self._handle_number_key(event): return + other_input_focused = bool(self.other_input and self.other_input.has_focus) + if not other_input_focused and self._handle_vim_navigation_key(event): + return + if len(self.questions) <= 1: return - if self.other_input and self.other_input.has_focus: + if other_input_focused: return if event.key == "left": self.action_prev_question() diff --git a/vibe/cli/textual_ui/widgets/rewind_app.py b/vibe/cli/textual_ui/widgets/rewind_app.py index e1dd090..1524a45 100644 --- a/vibe/cli/textual_ui/widgets/rewind_app.py +++ b/vibe/cli/textual_ui/widgets/rewind_app.py @@ -12,6 +12,7 @@ from textual.widgets import Static from vibe.cli.commands import ALT_KEY from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.textual_ui.widgets.vim_navigation import VimNavigationMixin class _RewindAction(StrEnum): @@ -19,7 +20,7 @@ class _RewindAction(StrEnum): EDIT_ONLY = auto() -class RewindApp(Container): +class RewindApp(VimNavigationMixin, Container): """Bottom panel widget for rewind mode actions.""" can_focus = True @@ -85,7 +86,7 @@ class RewindApp(Container): yield widget yield NoMarkupStatic("") yield NoMarkupStatic( - f"{ALT_KEY}+↑↓ or Ctrl+P/N browse messages ↑↓ pick option Enter confirm ESC cancel", + f"{ALT_KEY}+↑↓ or Ctrl+P/N browse messages ↑↓/jk pick option Enter confirm ESC cancel", classes="rewind-help", ) @@ -143,5 +144,8 @@ class RewindApp(Container): case _RewindAction.EDIT_ONLY: self.post_message(self.RewindWithoutRestore()) + def on_key(self, event: events.Key) -> None: + self._handle_vim_navigation_key(event) + def on_blur(self, event: events.Blur) -> None: self.call_after_refresh(self.focus) diff --git a/vibe/cli/textual_ui/widgets/session_picker.py b/vibe/cli/textual_ui/widgets/session_picker.py index c74cc08..83c3b9f 100644 --- a/vibe/cli/textual_ui/widgets/session_picker.py +++ b/vibe/cli/textual_ui/widgets/session_picker.py @@ -12,6 +12,7 @@ from textual.message import Message from textual.widgets import OptionList from textual.widgets.option_list import Option +from vibe.cli.textual_ui.widgets.navigable_option_list import NavigableOptionList from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.session.resume_sessions import ResumeSessionInfo, short_session_id @@ -279,9 +280,9 @@ class SessionPickerApp(Container): yield NoMarkupStatic( _build_header_text(self._cwd), classes="sessionpicker-header" ) - yield OptionList(*options, id="sessionpicker-options") + yield NavigableOptionList(*options, id="sessionpicker-options") yield NoMarkupStatic( - "↑↓ Navigate Enter Select D Delete Esc Cancel", + "↑↓/jk Navigate Enter Select D Delete Esc Cancel", classes="sessionpicker-help", ) diff --git a/vibe/cli/textual_ui/widgets/status_message.py b/vibe/cli/textual_ui/widgets/status_message.py index d4001cf..7bc3d5e 100644 --- a/vibe/cli/textual_ui/widgets/status_message.py +++ b/vibe/cli/textual_ui/widgets/status_message.py @@ -21,6 +21,7 @@ class StatusMessage(SpinnerMixin, NoMarkupStatic): self._indicator_widget: Static | None = None self._text_widget: Static | None = None self.success = True + self.muted = False self.init_spinner() super().__init__(**kwargs) @@ -55,14 +56,22 @@ class StatusMessage(SpinnerMixin, NoMarkupStatic): self._indicator_widget.update(self._spinner.next_frame()) self._indicator_widget.remove_class("success") self._indicator_widget.remove_class("error") + self._indicator_widget.remove_class("muted") + elif self.muted: + self._indicator_widget.update("□") + self._indicator_widget.add_class("muted") + self._indicator_widget.remove_class("success") + self._indicator_widget.remove_class("error") elif self.success: self._indicator_widget.update("✓") self._indicator_widget.add_class("success") self._indicator_widget.remove_class("error") + self._indicator_widget.remove_class("muted") else: self._indicator_widget.update("✕") self._indicator_widget.add_class("error") self._indicator_widget.remove_class("success") + self._indicator_widget.remove_class("muted") self._text_widget.update(self._format_text(content)) diff --git a/vibe/cli/textual_ui/widgets/theme_picker.py b/vibe/cli/textual_ui/widgets/theme_picker.py index b550238..73ce2ce 100644 --- a/vibe/cli/textual_ui/widgets/theme_picker.py +++ b/vibe/cli/textual_ui/widgets/theme_picker.py @@ -12,6 +12,7 @@ from textual.timer import Timer from textual.widgets import OptionList from textual.widgets.option_list import Option +from vibe.cli.textual_ui.widgets.navigable_option_list import NavigableOptionList from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic PREVIEW_DEBOUNCE_SECONDS = 0.1 @@ -69,9 +70,9 @@ class ThemePickerApp(Container): ] with Vertical(id="themepicker-content"): yield NoMarkupStatic("Select Theme", classes="themepicker-title") - yield OptionList(*options, id="themepicker-options") + yield NavigableOptionList(*options, id="themepicker-options") yield NoMarkupStatic( - "↑↓ Preview Enter Select Esc Cancel", classes="themepicker-help" + "↑↓/jk Preview Enter Select Esc Cancel", classes="themepicker-help" ) def on_mount(self) -> None: diff --git a/vibe/cli/textual_ui/widgets/thinking_picker.py b/vibe/cli/textual_ui/widgets/thinking_picker.py index 83d883b..2033d31 100644 --- a/vibe/cli/textual_ui/widgets/thinking_picker.py +++ b/vibe/cli/textual_ui/widgets/thinking_picker.py @@ -10,6 +10,7 @@ from textual.message import Message from textual.widgets import OptionList from textual.widgets.option_list import Option +from vibe.cli.textual_ui.widgets.navigable_option_list import NavigableOptionList from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config._settings import ThinkingLevel @@ -56,9 +57,10 @@ class ThinkingPickerApp(Container): yield NoMarkupStatic( "Select Thinking Level", classes="thinkingpicker-title" ) - yield OptionList(*options, id="thinkingpicker-options") + yield NavigableOptionList(*options, id="thinkingpicker-options") yield NoMarkupStatic( - "↑↓ Navigate Enter Select Esc Cancel", classes="thinkingpicker-help" + "↑↓/jk Navigate Enter Select Esc Cancel", + classes="thinkingpicker-help", ) def on_mount(self) -> None: diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py index 156d552..9f2233d 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -8,6 +8,7 @@ from typing import ClassVar from pydantic import BaseModel from textual.app import ComposeResult from textual.containers import Vertical, VerticalGroup +from textual.content import Content from textual.widget import Widget from textual.widgets import Markdown, Static @@ -18,7 +19,7 @@ from vibe.cli.textual_ui.widgets.diff_rendering import ( locate_snippets_in_file, render_edit_diff, ) -from vibe.cli.textual_ui.widgets.links import LinkStatic, link_markup +from vibe.cli.textual_ui.widgets.links import LinkStatic, link_content from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult from vibe.core.tools.builtins.bash import BashArgs, BashResult @@ -385,9 +386,9 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]): class WebSearchResultWidget(ToolResultWidget[WebSearchResult]): @staticmethod - def _source_markup(source: WebSearchSource) -> str: + def _source_content(source: WebSearchSource) -> Content: label = source.title or source.url - return link_markup(label, source.url) + return Content(" • ") + link_content(label, source.url) def compose(self) -> ComposeResult: if not self.result: @@ -401,8 +402,8 @@ class WebSearchResultWidget(ToolResultWidget[WebSearchResult]): yield NoMarkupStatic("") if len(result.sources) > 1: yield NoMarkupStatic("Sources:", classes="tool-result-detail") - lines = [f" • {self._source_markup(s)}" for s in result.sources] - yield LinkStatic("\n".join(lines), classes="tool-result-detail") + lines = [self._source_content(s) for s in result.sources] + yield LinkStatic(Content("\n").join(lines), classes="tool-result-detail") yield from self._footer() @@ -413,7 +414,7 @@ class WebFetchResultWidget(ToolResultWidget[WebFetchResult]): return yield from self._yield_truncated_text(self.result.content) yield NoMarkupStatic("") - link = link_markup(self.result.url, self.result.url) + link = link_content(self.result.url, self.result.url) yield LinkStatic(link, classes="tool-result-detail") yield from self._footer() diff --git a/vibe/cli/textual_ui/widgets/tools.py b/vibe/cli/textual_ui/widgets/tools.py index 2c17df6..8a658d6 100644 --- a/vibe/cli/textual_ui/widgets/tools.py +++ b/vibe/cli/textual_ui/widgets/tools.py @@ -89,9 +89,6 @@ class ToolCallMessage(StatusMessage): return adapter.get_call_display(self._event) return ToolCallDisplay(summary=self._tool_name) - def _format_text(self, content: str) -> str: - return escape(content) - def update_event(self, event: ToolCallEvent) -> None: self._event = event self._tool_name = event.tool_name @@ -114,7 +111,7 @@ class ToolCallMessage(StatusMessage): def _set_text(self, text: str, suffix: str, *, linkify: bool = False) -> None: if self._text_widget: - content = linkify_urls_in_text(text) if linkify else escape(text) + content = linkify_urls_in_text(text) if linkify else text self._text_widget.update(content) self._update_suffix(suffix) @@ -127,6 +124,19 @@ class ToolCallMessage(StatusMessage): super().update_display() self._update_suffix(self.get_content_suffix()) + def show_muted(self) -> None: + # Neutral grey square with the call summary -- used for an error whose + # verdict is still unknown and for a user-cancelled call. + self.muted = True + self.success = True + self.stop_spinning(success=True) + + def escalate_error(self) -> None: + # No recovery followed: promote the held square to a hard red cross. + self.muted = False + self.success = False + self.update_display() + class ToolResultMessage(ClickWithoutDragMixin, Static): def __init__( @@ -146,6 +156,7 @@ class ToolResultMessage(ClickWithoutDragMixin, Static): self._content = content self._content_container: Vertical | None = None self._result_widget: ToolResultWidget | None = None + self._is_error = False super().__init__() self.add_class("tool-result") @@ -163,15 +174,30 @@ class ToolResultMessage(ClickWithoutDragMixin, Static): async def on_mount(self) -> None: if self._call_widget: - success = self._determine_success() - self._call_widget.stop_spinning(success=success) - result_text, result_suffix = self._get_result_text() - linkify = self._tool_name in LINKIFY_RESULT_TOOLS - self._call_widget.set_result_text( - result_text, result_suffix, linkify=linkify - ) + if self._event is not None and self._event.error: + # Start muted; the verdict (recoverable vs terminal) lands later. + self._call_widget.show_muted() + else: + success = self._determine_success() + self._call_widget.stop_spinning(success=success) + result_text, result_suffix = self._get_result_text() + linkify = self._tool_name in LINKIFY_RESULT_TOOLS + self._call_widget.set_result_text( + result_text, result_suffix, linkify=linkify + ) await self._render_result() + def escalate_error(self) -> None: + # Turn ended without a follow-up tool call: switch to the red-cross icon + # and error header. The folded body keeps the same style as a recoverable + # error -- only the leading "Error" word stays colored. + if not self._is_error: + return + if self._call_widget is not None: + self._call_widget.escalate_error() + result_text, result_suffix = self._get_result_text() + self._call_widget.set_result_text(result_text, result_suffix) + def _determine_success(self) -> bool: if self._event is None: return True @@ -221,13 +247,13 @@ class ToolResultMessage(ClickWithoutDragMixin, Static): return if self._event.error: - self.add_class("error-text") - error_text = f"Error: {self._event.error}" - line_count = len(error_text.strip("\n").split("\n")) + self._is_error = True + # Only the inline "Error" span is ever colored; escalation changes the + # call icon to a red cross but leaves this folded body untouched. + line_count = len(self._event.error.strip("\n").split("\n")) + detail = Static(f"[$error]Error[/]: {escape(self._event.error)}") await self._content_container.mount( - CollapsibleSection( - NoMarkupStatic(error_text), collapsed_label=lines_label(line_count) - ) + CollapsibleSection(detail, collapsed_label=lines_label(line_count)) ) self.display = True return diff --git a/vibe/cli/textual_ui/widgets/vim_navigation.py b/vibe/cli/textual_ui/widgets/vim_navigation.py new file mode 100644 index 0000000..14b8e84 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/vim_navigation.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Protocol, cast + +from textual import events + + +class _VimNavigationHost(Protocol): + def action_move_up(self) -> None: ... + + def action_move_down(self) -> None: ... + + +class VimNavigationMixin: + # For custom container widgets that render their own option list and expose + # action_move_up/action_move_down. OptionList-based pickers should use + # NavigableOptionList instead. + def _handle_vim_navigation_key(self, event: events.Key) -> bool: + host = cast(_VimNavigationHost, self) + + match event.key: + case "j": + host.action_move_down() + case "k": + host.action_move_up() + case _: + return False + + event.stop() + event.prevent_default() + return True diff --git a/vibe/cli/textual_ui/widgets/voice_app.py b/vibe/cli/textual_ui/widgets/voice_app.py index 18090f7..590d41c 100644 --- a/vibe/cli/textual_ui/widgets/voice_app.py +++ b/vibe/cli/textual_ui/widgets/voice_app.py @@ -10,6 +10,7 @@ from textual.message import Message from textual.widgets import Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.textual_ui.widgets.vim_navigation import VimNavigationMixin if TYPE_CHECKING: from vibe.core.config import VibeConfig @@ -22,7 +23,7 @@ class SettingDefinition(TypedDict): options: list[str] -class VoiceApp(Container): +class VoiceApp(VimNavigationMixin, Container): can_focus = True can_focus_children = False @@ -86,7 +87,7 @@ class VoiceApp(Container): yield NoMarkupStatic("") self.help_widget = NoMarkupStatic( - "↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help" + "↑↓/jk navigate Space/Enter toggle ESC exit", classes="settings-help" ) yield self.help_widget @@ -169,5 +170,8 @@ class VoiceApp(Container): def action_close(self) -> None: self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save())) + def on_key(self, event: events.Key) -> None: + self._handle_vim_navigation_key(event) + def on_blur(self, event: events.Blur) -> None: self.call_after_refresh(self.focus) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 835a128..e6c3a50 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -98,7 +98,12 @@ from vibe.core.tools.permissions import ( PermissionStore, RequiredPermission, ) -from vibe.core.tracing import agent_span, set_tool_result, tool_span +from vibe.core.tracing import ( + agent_span, + build_otel_span_exporter_config, + set_tool_result, + tool_span, +) from vibe.core.trusted_folders import has_agents_md_file from vibe.core.types import ( AgentProfileChangedEvent, @@ -736,6 +741,14 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 provider=provider, timeout=self.config.api_timeout, retry_max_elapsed_time=self.config.api_retry_max_elapsed_time, + enable_otel=( + self.config.enable_telemetry + and self.config.enable_otel + and build_otel_span_exporter_config( + self.config.otel_endpoint, self.config.get_mistral_provider() + ) + is not None + ), ) async def _save_messages(self) -> None: diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index 944166e..346f795 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -1,39 +1,20 @@ from __future__ import annotations -from vibe.core.config._settings import ( +from vibe.core.config._defaults import ( DEFAULT_CONSOLE_BASE_URL, DEFAULT_MISTRAL_API_ENV_KEY, + DEFAULT_MISTRAL_SERVER_URL, + DEFAULT_THEME, + DEFAULT_VIBE_BASE_URL, +) +from vibe.core.config._settings import ( DEFAULT_MODELS, DEFAULT_PROVIDERS, - DEFAULT_THEME, DEFAULT_TRANSCRIBE_MODELS, DEFAULT_TRANSCRIBE_PROVIDERS, DEFAULT_TTS_MODELS, DEFAULT_TTS_PROVIDERS, - DEFAULT_VIBE_BASE_URL, - THINKING_LEVELS, - ConnectorConfig, - ExperimentsConfig, - MCPHttp, - MCPOAuth, - MCPServer, - MCPStaticAuth, - MCPStdio, - MCPStreamableHttp, - MissingAPIKeyError, - ModelConfig, - OtelSpanExporterConfig, - ProjectContextConfig, - ProviderConfig, - SessionLoggingConfig, - ThinkingLevel, TomlFileSettingsSource, - TranscribeClient, - TranscribeModelConfig, - TranscribeProviderConfig, - TTSClient, - TTSModelConfig, - TTSProviderConfig, VibeConfig, load_dotenv_values, resolve_api_key, @@ -51,6 +32,30 @@ from vibe.core.config.layer import ( TrustResolutionError, UntrustedLayerError, ) +from vibe.core.config.models import ( + THINKING_LEVELS, + ConnectorConfig, + ExperimentsConfig, + MCPHttp, + MCPOAuth, + MCPServer, + MCPStaticAuth, + MCPStdio, + MCPStreamableHttp, + MissingAPIKeyError, + ModelConfig, + OtelSpanExporterConfig, + ProjectContextConfig, + ProviderConfig, + SessionLoggingConfig, + ThinkingLevel, + TranscribeClient, + TranscribeModelConfig, + TranscribeProviderConfig, + TTSClient, + TTSModelConfig, + TTSProviderConfig, +) from vibe.core.config.patch import ( AddOperationPatch, ConfigPatch, @@ -82,6 +87,7 @@ from vibe.core.prompts import MissingPromptFileError __all__ = [ "DEFAULT_CONSOLE_BASE_URL", "DEFAULT_MISTRAL_API_ENV_KEY", + "DEFAULT_MISTRAL_SERVER_URL", "DEFAULT_MODELS", "DEFAULT_PROVIDERS", "DEFAULT_THEME", diff --git a/vibe/core/config/_defaults.py b/vibe/core/config/_defaults.py new file mode 100644 index 0000000..27a51ff --- /dev/null +++ b/vibe/core/config/_defaults.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" +DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai" +DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api" +DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai" +DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai" +DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" + +DEFAULT_AUTO_COMPACT_THRESHOLD = 200_000 +DEFAULT_API_TIMEOUT = 720.0 +DEFAULT_API_RETRY_MAX_ELAPSED_TIME = 300.0 + +DEFAULT_THEME = "ansi-dark" diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index 426af47..cba0cbf 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -1,28 +1,13 @@ from __future__ import annotations from collections.abc import MutableMapping -from enum import StrEnum, auto import os from pathlib import Path -import re -import shlex import tomllib -from typing import Annotated, Any, ClassVar, Literal, get_args -from urllib.parse import urljoin +from typing import Any, ClassVar from dotenv import dotenv_values -from mistralai.client.models import SpeechOutputFormat -from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( - DEFAULT_TRACES_EXPORT_PATH, -) -from pydantic import ( - BaseModel, - ConfigDict, - Field, - HttpUrl, - field_validator, - model_validator, -) +from pydantic import Field, field_validator, model_validator from pydantic.fields import FieldInfo from pydantic_core import to_jsonable_python from pydantic_settings import ( @@ -34,9 +19,37 @@ from textual.theme import BUILTIN_THEMES import tomli_w from vibe.core.agents.models import BuiltinAgentName +from vibe.core.config._defaults import ( + DEFAULT_API_RETRY_MAX_ELAPSED_TIME, + DEFAULT_API_TIMEOUT, + DEFAULT_AUTO_COMPACT_THRESHOLD, + DEFAULT_CONSOLE_BASE_URL, + DEFAULT_MISTRAL_API_ENV_KEY, + DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL, + DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL, + DEFAULT_MISTRAL_SERVER_URL, + DEFAULT_THEME, + DEFAULT_VIBE_BASE_URL, +) from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.config.models import ( + THINKING_LEVELS as THINKING_LEVELS, + ConnectorConfig, + ExperimentsConfig, + MCPServer, + MissingAPIKeyError, + ModelConfig, + ProjectContextConfig, + ProviderConfig, + SessionLoggingConfig, + ThinkingLevel, + TranscribeModelConfig, + TranscribeProviderConfig, + TTSModelConfig, + TTSProviderConfig, +) from vibe.core.logger import logger -from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR +from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.prompts import ( SystemPrompt, UtilityPrompt, @@ -44,7 +57,7 @@ from vibe.core.prompts import ( load_system_prompt, ) from vibe.core.types import Backend -from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base +from vibe.core.utils import configure_ssl_context from vibe.core.utils.keyring import get_api_key_from_keyring @@ -94,15 +107,6 @@ def resolve_api_key(env_key: str) -> str | None: return get_api_key_from_keyring(env_key) -class MissingAPIKeyError(RuntimeError): - def __init__(self, env_key: str, provider_name: str) -> None: - super().__init__( - f"Missing {env_key} environment variable for {provider_name} provider" - ) - self.env_key = env_key - self.provider_name = provider_name - - class TomlFileSettingsSource(PydanticBaseSettingsSource): def __init__(self, settings_cls: type[BaseSettings]) -> None: super().__init__(settings_cls) @@ -147,379 +151,6 @@ def _remove_none_values(value: Any) -> Any: return value -def _to_toml_document(value: Any) -> dict[str, Any]: - jsonable = to_jsonable_python(value, fallback=str) - if not isinstance(jsonable, dict): - return {} - return _remove_none_values(jsonable) - - -class ProjectContextConfig(BaseSettings): - model_config = SettingsConfigDict(extra="ignore") - - default_commit_count: int = 5 - timeout_seconds: float = 2.0 - - -class ExperimentsConfig(BaseSettings): - model_config = SettingsConfigDict(extra="ignore") - - enable: bool = True - api_host: str = "https://experiments.mistral.services/" - client_key: str = "sdk-OE8yJgTXZY6tj" - - -class SessionLoggingConfig(BaseSettings): - save_dir: str = "" - session_prefix: str = "session" - enabled: bool = True - - @field_validator("save_dir", mode="before") - @classmethod - def set_default_save_dir(cls, v: str) -> str: - if not v: - return str(SESSION_LOG_DIR.path) - return v - - @field_validator("save_dir", mode="after") - @classmethod - def expand_save_dir(cls, v: str) -> str: - return str(Path(v).expanduser().resolve()) - - -DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" -DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai" -DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api" -DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai" -DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai" - - -class ProviderConfig(BaseModel): - name: str - api_base: str - api_key_env_var: str = "" - browser_auth_base_url: str | None = None - browser_auth_api_base_url: str | None = None - api_style: str = "openai" - backend: Backend = Backend.GENERIC - reasoning_field_name: str = "reasoning_content" - project_id: str = "" - region: str = "" - extra_headers: dict[str, str] = Field(default_factory=dict) - - def _is_legacy_mistral_provider_without_backend(self) -> bool: - return ( - self.name == "mistral" - and self.backend == Backend.GENERIC - and "backend" not in self.model_fields_set - ) - - def _uses_mistral_browser_sign_in_defaults(self) -> bool: - return self.name == "mistral" and ( - self.backend == Backend.MISTRAL - or self._is_legacy_mistral_provider_without_backend() - ) - - @model_validator(mode="after") - def _apply_legacy_mistral_browser_auth_defaults(self) -> ProviderConfig: - if not self._uses_mistral_browser_sign_in_defaults(): - return self - - if self.browser_auth_base_url is None: - self.browser_auth_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL - if self.browser_auth_api_base_url is None: - self.browser_auth_api_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL - return self - - @property - def supports_browser_sign_in(self) -> bool: - return ( - (self.backend == Backend.MISTRAL or self.name == "mistral") - and bool(self.browser_auth_base_url) - and bool(self.browser_auth_api_base_url) - ) - - -class TranscribeClient(StrEnum): - MISTRAL = auto() - - -class TranscribeProviderConfig(BaseModel): - name: str - api_base: str = "wss://api.mistral.ai" - api_key_env_var: str = "" - client: TranscribeClient = TranscribeClient.MISTRAL - - -class _MCPBase(BaseModel): - name: str = Field(description="Short alias used to prefix tool names") - prompt: str | None = Field( - default=None, description="Optional usage hint appended to tool descriptions" - ) - startup_timeout_sec: float = Field( - default=10.0, - gt=0, - description="Timeout in seconds for the server to start and initialize.", - ) - tool_timeout_sec: float = Field( - default=60.0, gt=0, description="Timeout in seconds for tool execution." - ) - sampling_enabled: bool = Field( - default=True, - description="Allow this MCP server to request LLM completions via sampling/createMessage.", - ) - disabled: bool = Field( - default=False, - description="Disable all tools from this MCP server. Tools are still discovered but hidden.", - ) - disabled_tools: list[str] = Field( - default_factory=list, - description=( - "Tool names (without the server prefix) to disable from this server. " - "E.g. ['search', 'read'] to hide '{alias}_search' and '{alias}_read'." - ), - ) - - @field_validator("name", mode="after") - @classmethod - def normalize_name(cls, v: str) -> str: - normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", v) - normalized = normalized.strip("_-") - return normalized[:256] - - -_LEGACY_STATIC_AUTH_KEYS = ( - "headers", - "api_key_env", - "api_key_header", - "api_key_format", -) - - -class MCPStaticAuth(BaseModel): - model_config = ConfigDict(extra="forbid") - - type: Literal["static"] = "static" - headers: dict[str, str] = Field( - default_factory=dict, - description=("Additional HTTP headers (e.g., Authorization or X-API-Key)."), - ) - api_key_env: str = Field( - default="", - description=( - "Environment variable name containing an API token to send for HTTP transport." - ), - ) - api_key_header: str = Field( - default="Authorization", - description=( - "HTTP header name to carry the token when 'api_key_env' is set (e.g., 'Authorization' or 'X-API-Key')." - ), - ) - api_key_format: str = Field( - default="Bearer {token}", - description=( - "Format string for the header value when 'api_key_env' is set. Use '{token}' placeholder." - ), - ) - - def http_headers(self) -> dict[str, str]: - hdrs = dict(self.headers or {}) - env_var = (self.api_key_env or "").strip() - if env_var and (token := os.getenv(env_var)): - target = (self.api_key_header or "").strip() or "Authorization" - if not any(h.lower() == target.lower() for h in hdrs): - try: - value = (self.api_key_format or "{token}").format(token=token) - except Exception: - value = token - hdrs[target] = value - return hdrs - - -class MCPOAuth(BaseModel): - model_config = ConfigDict(extra="forbid") - - type: Literal["oauth"] - scopes: list[str] = Field( - description="OAuth scopes to request. Pass an empty list to accept the AS default." - ) - client_id: str | None = Field( - default=None, - min_length=1, - description="Pre-registered OAuth public client_id (PKCE). Mutually exclusive with client_metadata_url.", - ) - client_metadata_url: HttpUrl | None = Field( - default=None, - description="RFC 9728 client-metadata-document URL. Mutually exclusive with client_id.", - ) - redirect_port: int = Field( - default=47823, - ge=1024, - le=65535, - description="Loopback port for the OAuth callback handler.", - ) - - @model_validator(mode="after") - def _check_client_identity(self) -> MCPOAuth: - if self.client_id and self.client_metadata_url: - raise ValueError("client_id and client_metadata_url are mutually exclusive") - return self - - -MCPAuth = Annotated[MCPStaticAuth | MCPOAuth, Field(discriminator="type")] - - -def _promote_legacy_auth(data: Any) -> Any: - if not isinstance(data, dict): - return data - legacy_present = [k for k in _LEGACY_STATIC_AUTH_KEYS if k in data] - if not legacy_present: - return data - if "auth" in data: - raise ValueError( - "cannot mix top-level " - f"{', '.join(_LEGACY_STATIC_AUTH_KEYS)} with an explicit [auth] block; " - 'move legacy keys into [auth] (type = "static")' - ) - data["auth"] = {"type": "static", **{k: data.pop(k) for k in legacy_present}} - return data - - -class _MCPHttpFields(BaseModel): - url: str = Field(description="Base URL of the MCP HTTP server") - auth: MCPAuth = Field(default_factory=MCPStaticAuth) - - def http_headers(self) -> dict[str, str]: - if isinstance(self.auth, MCPStaticAuth): - return self.auth.http_headers() - return {} - - -class MCPHttp(_MCPBase, _MCPHttpFields): - transport: Literal["http"] - - _promote_legacy_auth = model_validator(mode="before")(_promote_legacy_auth) - - -class MCPStreamableHttp(_MCPBase, _MCPHttpFields): - transport: Literal["streamable-http"] - - _promote_legacy_auth = model_validator(mode="before")(_promote_legacy_auth) - - -class MCPStdio(_MCPBase): - transport: Literal["stdio"] - command: str | list[str] - args: list[str] = Field(default_factory=list) - env: dict[str, str] = Field( - default_factory=dict, - description="Environment variables to set for the MCP server process.", - ) - cwd: str | None = Field( - default=None, description="Working directory for the MCP server process." - ) - - def argv(self) -> list[str]: - base = ( - shlex.split(self.command) - if isinstance(self.command, str) - else list(self.command or []) - ) - return [*base, *self.args] if self.args else base - - -MCPServer = Annotated[ - MCPHttp | MCPStreamableHttp | MCPStdio, Field(discriminator="transport") -] - - -class ConnectorConfig(BaseModel): - name: str = Field(description="Normalized connector alias to match against.") - disabled: bool = Field( - default=False, - description="Disable all tools from this connector. Tools are still discovered but hidden.", - ) - disabled_tools: list[str] = Field( - default_factory=list, - description=( - "Tool names (without the connector prefix) to disable. " - "E.g. ['search'] to hide 'connector_{name}_search'." - ), - ) - - -def _default_alias_to_name(data: Any) -> Any: - if isinstance(data, dict): - if "alias" not in data or data["alias"] is None: - data["alias"] = data.get("name") - return data - - -ThinkingLevel = Literal["off", "low", "medium", "high", "max"] -THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel)) - -DEFAULT_AUTO_COMPACT_THRESHOLD = 200_000 -DEFAULT_API_TIMEOUT = 720.0 -DEFAULT_API_RETRY_MAX_ELAPSED_TIME = 300.0 - - -class ModelConfig(BaseModel): - name: str - provider: str - alias: str - temperature: float = 0.2 - input_price: float = 0.0 # Price per million input tokens - output_price: float = 0.0 # Price per million output tokens - thinking: ThinkingLevel = "off" - supports_images: bool = False - auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD - _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) - - -class TranscribeModelConfig(BaseModel): - name: str - provider: str - alias: str - sample_rate: int = 16000 - encoding: Literal["pcm_s16le"] = "pcm_s16le" - language: str = "en" - target_streaming_delay_ms: int = 500 - - _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) - - -class TTSClient(StrEnum): - MISTRAL = auto() - - -class TTSProviderConfig(BaseModel): - name: str - api_base: str = "https://api.mistral.ai" - api_key_env_var: str = "" - client: TTSClient = TTSClient.MISTRAL - - -class TTSModelConfig(BaseModel): - name: str - provider: str - alias: str - voice: str = "gb_jane_neutral" - response_format: SpeechOutputFormat = "wav" - - _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) - - -class OtelSpanExporterConfig(BaseModel): - model_config = ConfigDict(frozen=True) - - endpoint: str - headers: dict[str, str] | None = None - - -MISTRAL_OTEL_PATH = "/telemetry" -DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" - DEFAULT_PROVIDERS = [ ProviderConfig( name="mistral", @@ -595,8 +226,6 @@ DEFAULT_ACTIVE_TTS_MODEL_CONFIG = TTSModelConfig( DEFAULT_TTS_MODELS = [DEFAULT_ACTIVE_TTS_MODEL_CONFIG] -DEFAULT_THEME = "ansi-dark" - def resolve_theme_name(value: Any) -> str: if not isinstance(value, str) or not value: @@ -609,7 +238,6 @@ def resolve_theme_name(value: Any) -> str: class VibeConfig(BaseSettings): active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias - vim_keybindings: bool = False theme: str = DEFAULT_THEME disable_welcome_banner_animation: bool = False autocopy_to_clipboard: bool = True @@ -800,44 +428,6 @@ class VibeConfig(BaseSettings): def vibe_code_api_key(self) -> str: return resolve_api_key(self.vibe_code_api_key_env_var) or "" - @property - def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None: - # When otel_endpoint is set explicitly, authentication is the user's responsibility - # (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty. - # Otherwise endpoint and API key are derived from the active provider if it's Mistral, - # or the first Mistral provider. - traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/") - if self.otel_endpoint: - return OtelSpanExporterConfig( - endpoint=urljoin( - f"{self.otel_endpoint.rstrip('/')}/", traces_export_path - ) - ) - - provider = self.get_mistral_provider() - - if provider is not None: - server_url = get_server_url_from_api_base(provider.api_base) - api_key_env = provider.api_key_env_var or DEFAULT_MISTRAL_API_ENV_KEY - else: - server_url = None - api_key_env = DEFAULT_MISTRAL_API_ENV_KEY - - endpoint = urljoin( - f"{urljoin(server_url or DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/", - traces_export_path, - ) - - if not (api_key := resolve_api_key(api_key_env)): - logger.warning( - "OTEL tracing enabled but %s is not set; skipping.", api_key_env - ) - return None - - return OtelSpanExporterConfig( - endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"} - ) - @property def system_prompt(self) -> str: return load_system_prompt(self.system_prompt_id) @@ -1132,7 +722,11 @@ class VibeConfig(BaseSettings): return target = mgr.config_file or mgr.user_config_file target.parent.mkdir(parents=True, exist_ok=True) - toml_document = _to_toml_document(config) + jsonable = to_jsonable_python(config, fallback=str) + if not isinstance(jsonable, dict): + toml_document = {} + else: + toml_document = _remove_none_values(jsonable) cls.model_validate(toml_document) with target.open("wb") as f: tomli_w.dump(toml_document, f) diff --git a/vibe/core/config/models.py b/vibe/core/config/models.py new file mode 100644 index 0000000..fd90b20 --- /dev/null +++ b/vibe/core/config/models.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +from enum import StrEnum, auto +import os +from pathlib import Path +import re +import shlex +from typing import Annotated, Any, Literal, get_args + +from mistralai.client.models import SpeechOutputFormat +from pydantic import ( + BaseModel, + ConfigDict, + Field, + HttpUrl, + field_validator, + model_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict + +from vibe.core.config._defaults import ( + DEFAULT_AUTO_COMPACT_THRESHOLD, + DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL, + DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL, +) +from vibe.core.paths import SESSION_LOG_DIR +from vibe.core.types import Backend + + +class MissingAPIKeyError(RuntimeError): + def __init__(self, env_key: str, provider_name: str) -> None: + super().__init__( + f"Missing {env_key} environment variable for {provider_name} provider" + ) + self.env_key = env_key + self.provider_name = provider_name + + +class ProjectContextConfig(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + + default_commit_count: int = 5 + timeout_seconds: float = 2.0 + + +class ExperimentsConfig(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + + enable: bool = True + api_host: str = "https://experiments.mistral.services/" + client_key: str = "sdk-OE8yJgTXZY6tj" + + +class SessionLoggingConfig(BaseSettings): + save_dir: str = "" + session_prefix: str = "session" + enabled: bool = True + + @field_validator("save_dir", mode="before") + @classmethod + def set_default_save_dir(cls, v: str) -> str: + if not v: + return str(SESSION_LOG_DIR.path) + return v + + @field_validator("save_dir", mode="after") + @classmethod + def expand_save_dir(cls, v: str) -> str: + return str(Path(v).expanduser().resolve()) + + +class ProviderConfig(BaseModel): + name: str + api_base: str + api_key_env_var: str = "" + browser_auth_base_url: str | None = None + browser_auth_api_base_url: str | None = None + api_style: str = "openai" + backend: Backend = Backend.GENERIC + reasoning_field_name: str = "reasoning_content" + project_id: str = "" + region: str = "" + extra_headers: dict[str, str] = Field(default_factory=dict) + + def _is_legacy_mistral_provider_without_backend(self) -> bool: + return ( + self.name == "mistral" + and self.backend == Backend.GENERIC + and "backend" not in self.model_fields_set + ) + + def _uses_mistral_browser_sign_in_defaults(self) -> bool: + return self.name == "mistral" and ( + self.backend == Backend.MISTRAL + or self._is_legacy_mistral_provider_without_backend() + ) + + @model_validator(mode="after") + def _apply_legacy_mistral_browser_auth_defaults(self) -> ProviderConfig: + if not self._uses_mistral_browser_sign_in_defaults(): + return self + + if self.browser_auth_base_url is None: + self.browser_auth_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL + if self.browser_auth_api_base_url is None: + self.browser_auth_api_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL + return self + + @property + def supports_browser_sign_in(self) -> bool: + return ( + (self.backend == Backend.MISTRAL or self.name == "mistral") + and bool(self.browser_auth_base_url) + and bool(self.browser_auth_api_base_url) + ) + + +class TranscribeClient(StrEnum): + MISTRAL = auto() + + +class TranscribeProviderConfig(BaseModel): + name: str + api_base: str = "wss://api.mistral.ai" + api_key_env_var: str = "" + client: TranscribeClient = TranscribeClient.MISTRAL + + +class _MCPBase(BaseModel): + name: str = Field(description="Short alias used to prefix tool names") + prompt: str | None = Field( + default=None, description="Optional usage hint appended to tool descriptions" + ) + startup_timeout_sec: float = Field( + default=10.0, + gt=0, + description="Timeout in seconds for the server to start and initialize.", + ) + tool_timeout_sec: float = Field( + default=60.0, gt=0, description="Timeout in seconds for tool execution." + ) + sampling_enabled: bool = Field( + default=True, + description="Allow this MCP server to request LLM completions via sampling/createMessage.", + ) + disabled: bool = Field( + default=False, + description="Disable all tools from this MCP server. Tools are still discovered but hidden.", + ) + disabled_tools: list[str] = Field( + default_factory=list, + description=( + "Tool names (without the server prefix) to disable from this server. " + "E.g. ['search', 'read'] to hide '{alias}_search' and '{alias}_read'." + ), + ) + + @field_validator("name", mode="after") + @classmethod + def normalize_name(cls, v: str) -> str: + normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", v) + normalized = normalized.strip("_-") + return normalized[:256] + + +_LEGACY_STATIC_AUTH_KEYS = ( + "headers", + "api_key_env", + "api_key_header", + "api_key_format", +) + + +class MCPStaticAuth(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["static"] = "static" + headers: dict[str, str] = Field( + default_factory=dict, + description="Additional HTTP headers (e.g., Authorization or X-API-Key).", + ) + api_key_env: str = Field( + default="", + description=( + "Environment variable name containing an API token to send for HTTP transport." + ), + ) + api_key_header: str = Field( + default="Authorization", + description=( + "HTTP header name to carry the token when 'api_key_env' is set (e.g., 'Authorization' or 'X-API-Key')." + ), + ) + api_key_format: str = Field( + default="Bearer {token}", + description=( + "Format string for the header value when 'api_key_env' is set. Use '{token}' placeholder." + ), + ) + + def http_headers(self) -> dict[str, str]: + hdrs = dict(self.headers or {}) + env_var = (self.api_key_env or "").strip() + if env_var and (token := os.getenv(env_var)): + target = (self.api_key_header or "").strip() or "Authorization" + if not any(h.lower() == target.lower() for h in hdrs): + try: + value = (self.api_key_format or "{token}").format(token=token) + except Exception: + value = token + hdrs[target] = value + return hdrs + + +class MCPOAuth(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["oauth"] + scopes: list[str] = Field( + description="OAuth scopes to request. Pass an empty list to accept the AS default." + ) + client_id: str | None = Field( + default=None, + min_length=1, + description="Pre-registered OAuth public client_id (PKCE). Mutually exclusive with client_metadata_url.", + ) + client_metadata_url: HttpUrl | None = Field( + default=None, + description="RFC 9728 client-metadata-document URL. Mutually exclusive with client_id.", + ) + redirect_port: int = Field( + default=47823, + ge=1024, + le=65535, + description="Loopback port for the OAuth callback handler.", + ) + + @model_validator(mode="after") + def _check_client_identity(self) -> MCPOAuth: + if self.client_id and self.client_metadata_url: + raise ValueError("client_id and client_metadata_url are mutually exclusive") + return self + + +MCPAuth = Annotated[MCPStaticAuth | MCPOAuth, Field(discriminator="type")] + + +def _promote_legacy_auth(data: Any) -> Any: + if not isinstance(data, dict): + return data + legacy_present = [k for k in _LEGACY_STATIC_AUTH_KEYS if k in data] + if not legacy_present: + return data + if "auth" in data: + raise ValueError( + "cannot mix top-level " + f"{', '.join(_LEGACY_STATIC_AUTH_KEYS)} with an explicit [auth] block; " + 'move legacy keys into [auth] (type = "static")' + ) + data["auth"] = {"type": "static", **{k: data.pop(k) for k in legacy_present}} + return data + + +class _MCPHttpFields(BaseModel): + url: str = Field(description="Base URL of the MCP HTTP server") + auth: MCPAuth = Field(default_factory=MCPStaticAuth) + + def http_headers(self) -> dict[str, str]: + if isinstance(self.auth, MCPStaticAuth): + return self.auth.http_headers() + return {} + + +class MCPHttp(_MCPBase, _MCPHttpFields): + transport: Literal["http"] + + _promote_legacy_auth = model_validator(mode="before")(_promote_legacy_auth) + + +class MCPStreamableHttp(_MCPBase, _MCPHttpFields): + transport: Literal["streamable-http"] + + _promote_legacy_auth = model_validator(mode="before")(_promote_legacy_auth) + + +class MCPStdio(_MCPBase): + transport: Literal["stdio"] + command: str | list[str] + args: list[str] = Field(default_factory=list) + env: dict[str, str] = Field( + default_factory=dict, + description="Environment variables to set for the MCP server process.", + ) + cwd: str | None = Field( + default=None, description="Working directory for the MCP server process." + ) + + def argv(self) -> list[str]: + base = ( + shlex.split(self.command) + if isinstance(self.command, str) + else list(self.command or []) + ) + return [*base, *self.args] if self.args else base + + +MCPServer = Annotated[ + MCPHttp | MCPStreamableHttp | MCPStdio, Field(discriminator="transport") +] + + +class ConnectorConfig(BaseModel): + name: str = Field(description="Normalized connector alias to match against.") + disabled: bool = Field( + default=False, + description="Disable all tools from this connector. Tools are still discovered but hidden.", + ) + disabled_tools: list[str] = Field( + default_factory=list, + description=( + "Tool names (without the connector prefix) to disable. " + "E.g. ['search'] to hide 'connector_{name}_search'." + ), + ) + + +def _default_alias_to_name(data: Any) -> Any: + if isinstance(data, dict): + if "alias" not in data or data["alias"] is None: + data["alias"] = data.get("name") + return data + + +ThinkingLevel = Literal["off", "low", "medium", "high", "max"] +THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel)) + + +class ModelConfig(BaseModel): + name: str + provider: str + alias: str + temperature: float = 0.2 + input_price: float = 0.0 # Price per million input tokens + output_price: float = 0.0 # Price per million output tokens + thinking: ThinkingLevel = "off" + supports_images: bool = False + auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD + _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) + + +class TranscribeModelConfig(BaseModel): + name: str + provider: str + alias: str + sample_rate: int = 16000 + encoding: Literal["pcm_s16le"] = "pcm_s16le" + language: str = "en" + target_streaming_delay_ms: int = 500 + + _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) + + +class TTSClient(StrEnum): + MISTRAL = auto() + + +class TTSProviderConfig(BaseModel): + name: str + api_base: str = "https://api.mistral.ai" + api_key_env_var: str = "" + client: TTSClient = TTSClient.MISTRAL + + +class TTSModelConfig(BaseModel): + name: str + provider: str + alias: str + voice: str = "gb_jane_neutral" + response_format: SpeechOutputFormat = "wav" + + _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) + + +class OtelSpanExporterConfig(BaseModel): + model_config = ConfigDict(frozen=True) + + endpoint: str + headers: dict[str, str] | None = None diff --git a/vibe/core/config/vibe_schema.py b/vibe/core/config/vibe_schema.py index 129ef12..a297770 100644 --- a/vibe/core/config/vibe_schema.py +++ b/vibe/core/config/vibe_schema.py @@ -7,23 +7,29 @@ from typing import Annotated, Any from pydantic import AfterValidator, BeforeValidator, Field, model_validator from vibe.core.agents.models import BuiltinAgentName -from vibe.core.config._settings import ( - DEFAULT_ACTIVE_MODEL_CONFIG, - DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG, - DEFAULT_ACTIVE_TTS_MODEL_CONFIG, +from vibe.core.config._defaults import ( DEFAULT_API_RETRY_MAX_ELAPSED_TIME, DEFAULT_API_TIMEOUT, DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONSOLE_BASE_URL, DEFAULT_MISTRAL_API_ENV_KEY, + DEFAULT_THEME, + DEFAULT_VIBE_BASE_URL, +) +from vibe.core.config._settings import ( + DEFAULT_ACTIVE_MODEL_CONFIG, + DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG, + DEFAULT_ACTIVE_TTS_MODEL_CONFIG, DEFAULT_MODELS, DEFAULT_PROVIDERS, - DEFAULT_THEME, DEFAULT_TRANSCRIBE_MODELS, DEFAULT_TRANSCRIBE_PROVIDERS, DEFAULT_TTS_MODELS, DEFAULT_TTS_PROVIDERS, - DEFAULT_VIBE_BASE_URL, + resolve_api_key, + resolve_theme_name, +) +from vibe.core.config.models import ( ConnectorConfig, ExperimentsConfig, MCPServer, @@ -36,8 +42,6 @@ from vibe.core.config._settings import ( TranscribeProviderConfig, TTSModelConfig, TTSProviderConfig, - resolve_api_key, - resolve_theme_name, ) from vibe.core.config.schema import ( ConfigSchema, @@ -254,7 +258,6 @@ class VibeConfigSchema(ConfigSchema): applied_migrations: Annotated[list[str], WithConcatMerge()] = Field( default_factory=list ) - vim_keybindings: Annotated[bool, WithReplaceMerge()] = False disable_welcome_banner_animation: Annotated[bool, WithReplaceMerge()] = False autocopy_to_clipboard: Annotated[bool, WithReplaceMerge()] = True file_watcher_for_autocomplete: Annotated[bool, WithReplaceMerge()] = False @@ -295,8 +298,7 @@ class VibeConfigSchema(ConfigSchema): default_factory=ExperimentsConfig ) - @property - def active_model_config(self) -> ModelConfig: + def get_active_model(self) -> ModelConfig: if model := next( (m for m in self.models if m.alias == self.active_model), None ): @@ -314,10 +316,6 @@ class VibeConfigSchema(ConfigSchema): f"Provider '{model.provider}' for model '{model.name}' not found in configuration." ) - @property - def active_provider_config(self) -> ProviderConfig: - return self.get_provider_for_model(self.active_model_config) - @property def system_prompt(self) -> str: return load_system_prompt(self.system_prompt_id) @@ -350,7 +348,7 @@ class VibeConfigSchema(ConfigSchema): compaction_provider = self.get_provider_for_model(self.compaction_model) try: - active_provider = self.active_provider_config + active_provider = self.get_provider_for_model(self.get_active_model()) except ValueError: return self if active_provider.name != compaction_provider.name: @@ -364,7 +362,7 @@ class VibeConfigSchema(ConfigSchema): @model_validator(mode="after") def _check_api_key(self) -> VibeConfigSchema: try: - provider = self.active_provider_config + provider = self.get_provider_for_model(self.get_active_model()) api_key_env = provider.api_key_env_var if api_key_env and not resolve_api_key(api_key_env): raise MissingAPIKeyError(api_key_env, provider.name) diff --git a/vibe/core/llm/backend/factory.py b/vibe/core/llm/backend/factory.py index 0643a76..45df7ff 100644 --- a/vibe/core/llm/backend/factory.py +++ b/vibe/core/llm/backend/factory.py @@ -19,6 +19,7 @@ def create_backend( provider: ProviderConfig, timeout: float = 720.0, retry_max_elapsed_time: float = 300.0, + enable_otel: bool = False, ) -> BackendLike: factory = BACKEND_FACTORY[provider.backend] if provider.backend == Backend.MISTRAL: @@ -26,5 +27,6 @@ def create_backend( provider=provider, timeout=timeout, retry_max_elapsed_time=retry_max_elapsed_time, + enable_otel=enable_otel, ) return factory(provider=provider, timeout=timeout) diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index ffac635..1a07edb 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -31,6 +31,7 @@ from mistralai.client.models import ( UserMessage, ) from mistralai.client.utils.retries import BackoffStrategy, RetryConfig +from mistralai.extra.observability.telemetry import configure_telemetry from vibe.core.config import resolve_api_key from vibe.core.llm.backend._image import to_data_uri as _to_data_uri @@ -201,10 +202,12 @@ class MistralBackend: provider: ProviderConfig, timeout: float = 720.0, retry_max_elapsed_time: float = 300.0, + enable_otel: bool = False, ) -> None: self._client: Mistral | None = None self._http_client: httpx.AsyncClient | None = None self._provider = provider + self._enable_otel = enable_otel self._mapper = MistralMapper() self._api_key = resolve_api_key(self._provider.api_key_env_var) @@ -271,13 +274,16 @@ class MistralBackend: self._http_client = httpx.AsyncClient( verify=build_ssl_context(), follow_redirects=True ) - return Mistral( + client = Mistral( api_key=self._api_key, server_url=self._server_url, timeout_ms=int(self._timeout * 1000), retry_config=self._retry_config, async_client=self._http_client, ) + if self._enable_otel: + configure_telemetry(client, provider="global") + return client def _get_client(self) -> Mistral: if self._client is None: diff --git a/vibe/core/sentry.py b/vibe/core/sentry.py new file mode 100644 index 0000000..e8fdb7e --- /dev/null +++ b/vibe/core/sentry.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import platform +from typing import Any + +from vibe import __version__ +from vibe.core.config import VibeConfig +from vibe.core.telemetry.types import EntrypointMetadata + +# Injected at build time +_SENTRY_DSN = None +_SERVER_NAME = "vibe-cli" + + +def init_sentry( + config: VibeConfig, *, headless: bool, entrypoint_metadata: EntrypointMetadata +) -> bool: + if not config.enable_telemetry: + return False + + import sentry_sdk + from sentry_sdk.integrations.asyncio import AsyncioIntegration + + sentry_sdk.init( + dsn=_SENTRY_DSN, + release=f"vibe@{__version__}", + integrations=[AsyncioIntegration()], + server_name=_SERVER_NAME, # default is socket.gethostname(). It leaks host machine's name + include_local_variables=False, + ) + + if not sentry_sdk.is_initialized(): + return False + + global_tags = { + "headless": "true" if headless else "false", + "os": platform.system().lower(), + "arch": platform.machine().lower(), + "entrypoint": entrypoint_metadata.agent_entrypoint, + "client_name": entrypoint_metadata.client_name, + } + for key, value in global_tags.items(): + sentry_sdk.set_tag(key, value) + return True + + +def capture_sentry_exception( + error: BaseException, + *, + fatal: bool, + tags: dict[str, str] | None = None, + extras: dict[str, Any] | None = None, +) -> None: + import sentry_sdk + + if not sentry_sdk.is_initialized(): + return + + with sentry_sdk.new_scope() as scope: + scope.set_tag("fatal", "true" if fatal else "false") + for key, value in (tags or {}).items(): + scope.set_tag(key, value) + for key, value in (extras or {}).items(): + scope.set_extra(key, value) + scope.capture_exception(error) diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index a2ec8dd..85cf216 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -111,7 +111,6 @@ from `~/.vibe/prompts/`, and finally from the built-in bundled prompts. active_model = "mistral-medium-3.5" # Model alias to use (see [[models]]) # UI preferences -vim_keybindings = false disable_welcome_banner_animation = false autocopy_to_clipboard = true file_watcher_for_autocomplete = false diff --git a/vibe/core/tracing.py b/vibe/core/tracing.py index 83caabb..edca10a 100644 --- a/vibe/core/tracing.py +++ b/vibe/core/tracing.py @@ -4,27 +4,74 @@ import atexit from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin from opentelemetry import baggage, context, trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + DEFAULT_TRACES_EXPORT_PATH, +) from opentelemetry.semconv._incubating.attributes import gen_ai_attributes from opentelemetry.trace import StatusCode from vibe import __version__ +from vibe.core.config import ( + DEFAULT_MISTRAL_API_ENV_KEY, + DEFAULT_MISTRAL_SERVER_URL, + OtelSpanExporterConfig, + resolve_api_key, +) +from vibe.core.utils import get_server_url_from_api_base if TYPE_CHECKING: - from vibe.core.config import VibeConfig + from vibe.core.config import ProviderConfig, VibeConfig from vibe.core.logger import logger VIBE_TRACER_NAME = "mistral_vibe" VIBE_AGENT_NAME = "mistral-vibe" +MISTRAL_OTEL_PATH = "/telemetry" + + +def build_otel_span_exporter_config( + otel_endpoint: str | None, mistral_provider: ProviderConfig | None +) -> OtelSpanExporterConfig | None: + # When otel_endpoint is set explicitly, authentication is the user's responsibility + # (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty. + # Otherwise endpoint and API key are derived from the given Mistral provider. + traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/") + if otel_endpoint: + return OtelSpanExporterConfig( + endpoint=urljoin(f"{otel_endpoint.rstrip('/')}/", traces_export_path) + ) + + if mistral_provider is not None: + server_url = get_server_url_from_api_base(mistral_provider.api_base) + api_key_env = mistral_provider.api_key_env_var or DEFAULT_MISTRAL_API_ENV_KEY + else: + server_url = None + api_key_env = DEFAULT_MISTRAL_API_ENV_KEY + + endpoint = urljoin( + f"{urljoin(server_url or DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/", + traces_export_path, + ) + + if not (api_key := resolve_api_key(api_key_env)): + logger.warning("OTEL tracing enabled but %s is not set; skipping.", api_key_env) + return None + + return OtelSpanExporterConfig( + endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"} + ) def setup_tracing(config: VibeConfig) -> None: if not config.enable_telemetry or not config.enable_otel: return - exporter_cfg = config.otel_span_exporter_config + exporter_cfg = build_otel_span_exporter_config( + config.otel_endpoint, config.get_mistral_provider() + ) if exporter_cfg is None: return diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 6d6e5e5..6c9c25e 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -3,5 +3,4 @@ - **OAuth MCP server setup**: Added `/mcp add` command to add and authenticate OAuth MCP servers directly from the TUI # What's new in v2.18.0 - - **Clipboard image paste**: Paste images directly from clipboard in the TUI on macOS