diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index c49a9ae..e75b1f6 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -10,6 +10,8 @@ on: jobs: configure: runs-on: ubuntu-latest + permissions: + contents: read outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: @@ -42,6 +44,8 @@ jobs: build-and-upload: needs: configure name: ${{ matrix.os }}-${{ matrix.arch }} + permissions: + contents: read strategy: matrix: ${{ fromJSON(needs.configure.outputs.matrix) }} runs-on: ${{ matrix.runner }} @@ -131,6 +135,8 @@ jobs: nix-build: needs: configure name: "Nix: ${{ matrix.os }}-${{ matrix.arch }}" + permissions: + contents: read strategy: matrix: ${{ fromJSON(needs.configure.outputs.matrix) }} runs-on: ${{ matrix.runner }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a57431..b786cf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,8 @@ jobs: pre-commit: name: Pre-commit runs-on: ubuntu-latest - + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 @@ -51,7 +52,8 @@ jobs: tests: name: Tests runs-on: ubuntu-latest - + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 @@ -85,7 +87,8 @@ jobs: snapshot-tests: name: Snapshot Tests runs-on: ubuntu-latest - + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index c3a4b84..0bc4b0e 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -8,6 +8,7 @@ jobs: label-component: runs-on: ubuntu-latest permissions: + contents: read issues: write steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19b0617..44a5bce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,8 @@ jobs: name: Release Zed Extension runs-on: ubuntu-latest if: github.repository == 'mistralai/mistral-vibe' + permissions: + contents: read environment: name: zed steps: diff --git a/.vscode/launch.json b/.vscode/launch.json index a2dbf24..ff70cd4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "1.3.3", + "version": "1.3.4", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a247f..0daee9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.4] - 2026-01-07 + +### Fixed + +- markup in blinking messages +- safety around Bash and AGENTS.md +- explicit permissions to GitHub Actions workflows +- improve render performance in long sessions + ## [1.3.3] - 2025-12-26 ### Fixed diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 1b2715e..b043d1c 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "1.3.3" +version = "1.3.4" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.3/vibe-acp-darwin-aarch64-1.3.3.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.4/vibe-acp-darwin-aarch64-1.3.4.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.3/vibe-acp-darwin-x86_64-1.3.3.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.4/vibe-acp-darwin-x86_64-1.3.4.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.3/vibe-acp-linux-aarch64-1.3.3.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.4/vibe-acp-linux-aarch64-1.3.4.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.3/vibe-acp-linux-x86_64-1.3.3.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.4/vibe-acp-linux-x86_64-1.3.4.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.3/vibe-acp-windows-aarch64-1.3.3.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.4/vibe-acp-windows-aarch64-1.3.4.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.3/vibe-acp-windows-x86_64-1.3.3.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.4/vibe-acp-windows-x86_64-1.3.4.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 8df8936..c74ce51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "1.3.3" +version = "1.3.4" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -44,6 +44,8 @@ dependencies = [ "watchfiles>=1.1.1", "pyperclip>=1.11.0", "textual-speedups>=0.2.1", + "tree-sitter>=0.25.2", + "tree-sitter-bash>=0.25.1", ] [project.urls] diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index bf3da93..c4c09ee 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -41,7 +41,7 @@ class TestACPInitialize: ), ) assert response.agentInfo == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.3" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.4" ) assert response.authMethods == [] @@ -63,7 +63,7 @@ class TestACPInitialize: ), ) assert response.agentInfo == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.3" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.4" ) assert response.authMethods is not None diff --git a/tests/cli/test_no_markup_static.py b/tests/cli/test_no_markup_static.py new file mode 100644 index 0000000..c0261c7 --- /dev/null +++ b/tests/cli/test_no_markup_static.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import pytest +from textual.markup import MarkupError +from textual.visual import visualize +from textual.widgets import Static + +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic + + +def test_static_raises_on_invalid_markup() -> None: + widget = Static() + with pytest.raises(MarkupError): + visualize(widget, "[/]", markup=True) + + +def test_no_markup_static_allows_invalid_markup() -> None: + widget = NoMarkupStatic("[/]") + assert str(widget.render()) == "[/]" diff --git a/uv.lock b/uv.lock index 1546df8..92681dd 100644 --- a/uv.lock +++ b/uv.lock @@ -661,7 +661,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "1.3.3" +version = "1.3.4" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -680,6 +680,8 @@ dependencies = [ { name = "textual" }, { name = "textual-speedups" }, { name = "tomli-w" }, + { name = "tree-sitter" }, + { name = "tree-sitter-bash" }, { name = "watchfiles" }, ] @@ -720,6 +722,8 @@ requires-dist = [ { name = "textual", specifier = ">=1.0.0" }, { name = "textual-speedups", specifier = ">=0.2.1" }, { name = "tomli-w", specifier = ">=1.2.0" }, + { name = "tree-sitter", specifier = ">=0.25.2" }, + { name = "tree-sitter-bash", specifier = ">=0.25.1" }, { name = "watchfiles", specifier = ">=1.1.1" }, ] @@ -1601,6 +1605,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "tree-sitter" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/67492014ce32729b63d7ef318a19f9cfedd855d677de5773476caf771e96/tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd", size = 146926, upload-time = "2025-09-25T17:37:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/a278b15e6b263e86c5e301c82a60923fa7c59d44f78d7a110a89a413e640/tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601", size = 137712, upload-time = "2025-09-25T17:37:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/423bba15d2bf6473ba67846ba5244b988cd97a4b1ea2b146822162256794/tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053", size = 607873, upload-time = "2025-09-25T17:37:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/b430d2cb43f8badfb3a3fa9d6cd7c8247698187b5674008c9d67b2a90c8e/tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614", size = 636313, upload-time = "2025-09-25T17:37:46.68Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/5f97098dbba807331d666a0997662e82d066e84b17d92efab575d283822f/tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae", size = 631370, upload-time = "2025-09-25T17:37:47.993Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/87caaed663fabc35e18dc704cd0e9800a0ee2f22bd18b9cbe7c10799895d/tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b", size = 127157, upload-time = "2025-09-25T17:37:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/d5/23/f8467b408b7988aff4ea40946a4bd1a2c1a73d17156a9d039bbaff1e2ceb/tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8", size = 113975, upload-time = "2025-09-25T17:37:49.922Z" }, + { url = "https://files.pythonhosted.org/packages/07/e3/d9526ba71dfbbe4eba5e51d89432b4b333a49a1e70712aa5590cd22fc74f/tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0", size = 146776, upload-time = "2025-09-25T17:37:50.898Z" }, + { url = "https://files.pythonhosted.org/packages/42/97/4bd4ad97f85a23011dd8a535534bb1035c4e0bac1234d58f438e15cff51f/tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87", size = 137732, upload-time = "2025-09-25T17:37:51.877Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/1e968aa0b1b567988ed522f836498a6a9529a74aab15f09dd9ac1e41f505/tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab", size = 609456, upload-time = "2025-09-25T17:37:52.925Z" }, + { url = "https://files.pythonhosted.org/packages/48/b6/cf08f4f20f4c9094006ef8828555484e842fc468827ad6e56011ab668dbd/tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358", size = 636772, upload-time = "2025-09-25T17:37:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/57/e2/d42d55bf56360987c32bc7b16adb06744e425670b823fb8a5786a1cea991/tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0", size = 631522, upload-time = "2025-09-25T17:37:55.833Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/af9604ebe275a9345d88c3ace0cf2a1341aa3f8ef49dd9fc11662132df8a/tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721", size = 130864, upload-time = "2025-09-25T17:37:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/e64621037357acb83d912276ffd30a859ef117f9c680f2e3cb955f47c680/tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f", size = 117470, upload-time = "2025-09-25T17:37:58.431Z" }, +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/0e/f0108be910f1eef6499eabce517e79fe3b12057280ed398da67ce2426cba/tree_sitter_bash-0.25.1.tar.gz", hash = "sha256:bfc0bdaa77bc1e86e3c6652e5a6e140c40c0a16b84185c2b63ad7cd809b88f14", size = 419703, upload-time = "2025-12-02T17:01:08.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/8e/37e7364d9c9c58da89e05c510671d8c45818afd7b31c6939ab72f8dc6c04/tree_sitter_bash-0.25.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0e6235f59e366d220dde7d830196bed597d01e853e44d8ccd1a82c5dd2500acf", size = 194160, upload-time = "2025-12-02T17:00:59.047Z" }, + { url = "https://files.pythonhosted.org/packages/23/bb/2d2cfbb1f89aaeb1ec892624f069d92d058d06bb66f16b9ec9fb5873ab60/tree_sitter_bash-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f4a34a6504c7c5b2a9b8c5c4065531dea19ca2c35026e706cf2eeeebe2c92512", size = 202659, upload-time = "2025-12-02T17:01:00.275Z" }, + { url = "https://files.pythonhosted.org/packages/25/f0/1bb25519be27460255d3899db677313cfa1e6306988fbf456a3d7e211bbb/tree_sitter_bash-0.25.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e76c4cfb20b076552406782b7f8c2a3946835993df0a44df006de54b7030c7dc", size = 230596, upload-time = "2025-12-02T17:01:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/d7/22/9f70bc3d3b942ab9fc0f89c1dc9e087519a3a94f64ae6b7377aae3a7a0f0/tree_sitter_bash-0.25.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f484c4bb8796cde7a87ca351e6116f09653edac0eb3c6d238566359dd28b117", size = 231981, upload-time = "2025-12-02T17:01:02.859Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c3/f1540e42cd41b323c6821e45e52e1aed6ed386209aad52db996f05703963/tree_sitter_bash-0.25.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5e76af6df46d958c7f5b6d5884c9743218e3902a00ccb493ec92728b1084430b", size = 228364, upload-time = "2025-12-02T17:01:03.997Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/c3050a6277dfcac8c480f514dc4fe49f3f65f0eac68b4702cbaca2584e85/tree_sitter_bash-0.25.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a3332d71c7b7d5f78259b19d02d0ea111fcb82b72712ee4a93aaa5b226d3f0a8", size = 230074, upload-time = "2025-12-02T17:01:05.05Z" }, + { url = "https://files.pythonhosted.org/packages/71/0f/203fe6b27211387f4b9ba8c4a321567ca4ded2624dae6ccdbd2b6e940e17/tree_sitter_bash-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:52a6802d9218f86278aa3e8b459c3abdad67eed0fde1f9f13aca5b6c634217a6", size = 195574, upload-time = "2025-12-02T17:01:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/47/75/4ca1a9fabd8fb5aea78cea70f7837ce4dbf2afae115f62051e5fa99cba1c/tree_sitter_bash-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:59115057ec2bae319e8082ff29559861045002964c3431ccb0fc92aa4bc9bccb", size = 191196, upload-time = "2025-12-02T17:01:07.486Z" }, +] + [[package]] name = "twine" version = "6.2.0" diff --git a/vibe/__init__.py b/vibe/__init__.py index fce9a53..18d0e1e 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "1.3.3" +__version__ = "1.3.4" diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index fbe5707..6dcd688 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -8,7 +8,7 @@ from rich import print as rprint from vibe import __version__ from vibe.core.paths.config_paths import unlock_config_paths -from vibe.core.trusted_folders import trusted_folders_manager +from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager from vibe.setup.trusted_folders.trust_folder_dialog import ( TrustDialogQuitException, ask_trust_folder, @@ -105,7 +105,7 @@ def parse_arguments() -> argparse.Namespace: def check_and_resolve_trusted_folder() -> None: cwd = Path.cwd() - if not (cwd / ".vibe").exists() or cwd.resolve() == Path.home().resolve(): + if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve(): return is_folder_trusted = trusted_folders_manager.is_trusted(cwd) diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 6174ae9..97d9133 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -41,6 +41,7 @@ from vibe.cli.textual_ui.widgets.messages import ( WarningMessage, ) from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.path_display import PathDisplay from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner @@ -174,7 +175,7 @@ class VibeApp(App): # noqa: PLR0904 yield PathDisplay( self.config.displayed_workdir or self.config.effective_workdir ) - yield Static(id="spacer") + yield NoMarkupStatic(id="spacer") yield ContextProgress() async def on_mount(self) -> None: @@ -320,6 +321,24 @@ class VibeApp(App): # noqa: PLR0904 await self._switch_to_input_app() + async def on_compact_message_completed( + self, message: CompactMessage.Completed + ) -> None: + messages_area = self.query_one("#messages") + children = list(messages_area.children) + + try: + compact_index = children.index(message.compact_widget) + except ValueError: + return + + if compact_index == 0: + return + + with self.batch_update(): + for widget in children[:compact_index]: + await widget.remove() + def _set_tool_permission_always( self, tool_name: str, save_permanently: bool = False ) -> None: @@ -502,31 +521,32 @@ class VibeApp(App): # noqa: PLR0904 messages_area = self.query_one("#messages") tool_call_map: dict[str, str] = {} - for msg in self._loaded_messages: - if msg.role == Role.system: - continue + with self.batch_update(): + for msg in self._loaded_messages: + if msg.role == Role.system: + continue - match msg.role: - case Role.user: - if msg.content: - await messages_area.mount(UserMessage(msg.content)) + match msg.role: + case Role.user: + if msg.content: + await messages_area.mount(UserMessage(msg.content)) - case Role.assistant: - await self._mount_history_assistant_message( - msg, messages_area, tool_call_map - ) - - case Role.tool: - tool_name = msg.name or tool_call_map.get( - msg.tool_call_id or "", "tool" - ) - await messages_area.mount( - ToolResultMessage( - tool_name=tool_name, - content=msg.content, - collapsed=self._tools_collapsed, + case Role.assistant: + await self._mount_history_assistant_message( + msg, messages_area, tool_call_map + ) + + case Role.tool: + tool_name = msg.name or tool_call_map.get( + msg.tool_call_id or "", "tool" + ) + await messages_area.mount( + ToolResultMessage( + tool_name=tool_name, + content=msg.content, + collapsed=self._tools_collapsed, + ) ) - ) async def _mount_history_assistant_message( self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str] diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index aa7ec57..20727ce 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -3,10 +3,9 @@ from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING -from textual.widgets import Static - from vibe.cli.textual_ui.widgets.compact import CompactMessage from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage +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 ( AssistantEvent, @@ -142,9 +141,7 @@ class EventHandler: self.current_compact = None async def _handle_unknown_event(self, event: BaseEvent) -> None: - await self.mount_callback( - Static(str(event), markup=False, classes="unknown-event") - ) + await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event")) def stop_current_tool_call(self) -> None: if self.current_tool_call: diff --git a/vibe/cli/textual_ui/widgets/approval_app.py b/vibe/cli/textual_ui/widgets/approval_app.py index 4839c59..281e787 100644 --- a/vibe/cli/textual_ui/widgets/approval_app.py +++ b/vibe/cli/textual_ui/widgets/approval_app.py @@ -10,6 +10,7 @@ from textual.containers import Container, Vertical, VerticalScroll 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.tool_widgets import get_approval_widget from vibe.core.config import VibeConfig @@ -67,7 +68,7 @@ class ApprovalApp(Container): def compose(self) -> ComposeResult: with Vertical(id="approval-content"): - self.title_widget = Static( + self.title_widget = NoMarkupStatic( f"⚠ {self.tool_name} command", classes="approval-title" ) yield self.title_widget @@ -78,16 +79,16 @@ class ApprovalApp(Container): ) yield self.tool_info_container - yield Static("") + yield NoMarkupStatic("") for _ in range(3): - widget = Static("", classes="approval-option") + widget = NoMarkupStatic("", classes="approval-option") self.option_widgets.append(widget) yield widget - yield Static("") + yield NoMarkupStatic("") - self.help_widget = Static( + self.help_widget = NoMarkupStatic( "↑↓ navigate Enter select ESC reject", classes="approval-help" ) yield self.help_widget diff --git a/vibe/cli/textual_ui/widgets/chat_input/body.py b/vibe/cli/textual_ui/widgets/chat_input/body.py index ed10b2f..f49664a 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/body.py +++ b/vibe/cli/textual_ui/widgets/chat_input/body.py @@ -8,10 +8,10 @@ from textual.app import ComposeResult from textual.containers import Horizontal from textual.message import Message from textual.widget import Widget -from textual.widgets import Static from vibe.cli.history_manager import HistoryManager from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic class ChatInputBody(Widget): @@ -23,7 +23,7 @@ class ChatInputBody(Widget): def __init__(self, history_file: Path | None = None, **kwargs: Any) -> None: super().__init__(**kwargs) self.input_widget: ChatTextArea | None = None - self.prompt_widget: Static | None = None + self.prompt_widget: NoMarkupStatic | None = None if history_file: self.history = HistoryManager(history_file) @@ -34,7 +34,7 @@ class ChatInputBody(Widget): def compose(self) -> ComposeResult: with Horizontal(): - self.prompt_widget = Static(">", id="prompt") + self.prompt_widget = NoMarkupStatic(">", id="prompt") yield self.prompt_widget self.input_widget = ChatTextArea(placeholder="Ask anything...", id="input") diff --git a/vibe/cli/textual_ui/widgets/compact.py b/vibe/cli/textual_ui/widgets/compact.py index 84a2229..10a18b1 100644 --- a/vibe/cli/textual_ui/widgets/compact.py +++ b/vibe/cli/textual_ui/widgets/compact.py @@ -1,9 +1,16 @@ from __future__ import annotations +from textual.message import Message + from vibe.cli.textual_ui.widgets.status_message import StatusMessage class CompactMessage(StatusMessage): + class Completed(Message): + def __init__(self, compact_widget: CompactMessage) -> None: + super().__init__() + self.compact_widget = compact_widget + def __init__(self) -> None: super().__init__() self.add_class("compact-message") @@ -36,6 +43,7 @@ class CompactMessage(StatusMessage): self.old_tokens = old_tokens self.new_tokens = new_tokens self.stop_spinning(success=True) + self.post_message(self.Completed(self)) def set_error(self, error_message: str) -> None: self.error_message = error_message diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py index d6965e3..1c476f1 100644 --- a/vibe/cli/textual_ui/widgets/config_app.py +++ b/vibe/cli/textual_ui/widgets/config_app.py @@ -11,6 +11,7 @@ from textual.theme import BUILTIN_THEMES from textual.widgets import Static from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic if TYPE_CHECKING: from vibe.core.config import VibeConfig @@ -85,19 +86,19 @@ class ConfigApp(Container): def compose(self) -> ComposeResult: with Vertical(id="config-content"): - self.title_widget = Static("Settings", classes="settings-title") + self.title_widget = NoMarkupStatic("Settings", classes="settings-title") yield self.title_widget - yield Static("") + yield NoMarkupStatic("") for _ in self.settings: - widget = Static("", classes="settings-option") + widget = NoMarkupStatic("", classes="settings-option") self.setting_widgets.append(widget) yield widget - yield Static("") + yield NoMarkupStatic("") - self.help_widget = Static( + self.help_widget = NoMarkupStatic( "↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help" ) yield self.help_widget @@ -143,12 +144,13 @@ class ConfigApp(Container): current: str = self.changes.get(key, setting["value"]) options: list[str] = setting["options"] + new_value = "" try: current_idx = options.index(current) next_idx = (current_idx + 1) % len(options) - new_value: str = options[next_idx] + new_value = options[next_idx] except (ValueError, IndexError): - new_value: str = options[0] if options else current + new_value = options[0] if options else current self.changes[key] = new_value diff --git a/vibe/cli/textual_ui/widgets/context_progress.py b/vibe/cli/textual_ui/widgets/context_progress.py index 63d62bf..f9e803e 100644 --- a/vibe/cli/textual_ui/widgets/context_progress.py +++ b/vibe/cli/textual_ui/widgets/context_progress.py @@ -4,7 +4,8 @@ from dataclasses import dataclass from typing import Any from textual.reactive import reactive -from textual.widgets import Static + +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic @dataclass @@ -13,7 +14,7 @@ class TokenState: current_tokens: int = 0 -class ContextProgress(Static): +class ContextProgress(NoMarkupStatic): tokens = reactive(TokenState()) def __init__(self, **kwargs: Any) -> None: diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py index 8b72cfa..5e018ef 100644 --- a/vibe/cli/textual_ui/widgets/loading.py +++ b/vibe/cli/textual_ui/widgets/loading.py @@ -9,6 +9,7 @@ from textual.app import ComposeResult from textual.containers import Horizontal from textual.widgets import Static +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType @@ -55,10 +56,10 @@ class LoadingWidget(SpinnerMixin, Static): self.status = status or self._get_default_status() self.current_color_index = 0 self.transition_progress = 0 - self.char_widgets: list[Static] = [] - self.ellipsis_widget: Static | None = None + self._status_widget: Static | None = None self.hint_widget: Static | None = None self.start_time: float | None = None + self._last_elapsed: int = -1 def _get_easter_egg(self) -> str | None: EASTER_EGG_PROBABILITY = 0.10 @@ -85,7 +86,7 @@ class LoadingWidget(SpinnerMixin, Static): def set_status(self, status: str) -> None: self.status = self._apply_easter_egg(status) - self._rebuild_chars() + self._update_animation() def compose(self) -> ComposeResult: with Horizontal(classes="loading-container"): @@ -94,34 +95,14 @@ class LoadingWidget(SpinnerMixin, Static): ) yield self._indicator_widget - with Horizontal(classes="loading-status"): - for char in self.status: - widget = Static(char, classes="loading-char") - self.char_widgets.append(widget) - yield widget + self._status_widget = Static("", classes="loading-status") + yield self._status_widget - self.ellipsis_widget = Static("… ", classes="loading-ellipsis") - yield self.ellipsis_widget - - self.hint_widget = Static("(0s esc to interrupt)", classes="loading-hint") + self.hint_widget = NoMarkupStatic( + "(0s esc to interrupt)", classes="loading-hint" + ) yield self.hint_widget - def _rebuild_chars(self) -> None: - if not self.is_mounted: - return - - status_container = self.query_one(".loading-status", Horizontal) - - status_container.remove_children() - self.char_widgets.clear() - - for char in self.status: - widget = Static(char, classes="loading-char") - self.char_widgets.append(widget) - status_container.mount(widget) - - self._update_animation() - def on_mount(self) -> None: self.start_time = time() self._update_animation() @@ -144,24 +125,26 @@ class LoadingWidget(SpinnerMixin, Static): return next_color return current_color + def _build_status_text(self) -> str: + parts = [] + for i, char in enumerate(self.status): + color = self._get_color_for_position(1 + i) + parts.append(f"[{color}]{char}[/]") + ellipsis_start = 1 + len(self.status) + color_ellipsis = self._get_color_for_position(ellipsis_start) + parts.append(f"[{color_ellipsis}]… [/]") + return "".join(parts) + def _update_animation(self) -> None: - total_elements = 1 + len(self.char_widgets) + 2 + total_elements = 1 + len(self.status) + 1 if self._indicator_widget: spinner_char = self._spinner.next_frame() color = self._get_color_for_position(0) self._indicator_widget.update(f"[{color}]{spinner_char}[/]") - for i, widget in enumerate(self.char_widgets): - position = 1 + i - color = self._get_color_for_position(position) - widget.update(f"[{color}]{self.status[i]}[/]") - - if self.ellipsis_widget: - ellipsis_start = 1 + len(self.status) - color_ellipsis = self._get_color_for_position(ellipsis_start) - color_space = self._get_color_for_position(ellipsis_start + 1) - self.ellipsis_widget.update(f"[{color_ellipsis}]…[/][{color_space}] [/]") + if self._status_widget: + self._status_widget.update(self._build_status_text()) self.transition_progress += 1 if self.transition_progress > total_elements: @@ -172,4 +155,6 @@ class LoadingWidget(SpinnerMixin, Static): if self.hint_widget and self.start_time is not None: elapsed = int(time() - self.start_time) - self.hint_widget.update(f"({elapsed}s esc to interrupt)") + if elapsed != self._last_elapsed: + self._last_elapsed = elapsed + self.hint_widget.update(f"({elapsed}s esc to interrupt)") diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index b3e14ba..1dce0b4 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -7,10 +7,11 @@ from textual.containers import Horizontal, Vertical from textual.widgets import Markdown, Static from textual.widgets._markdown import MarkdownStream +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType -class NonSelectableStatic(Static): +class NonSelectableStatic(NoMarkupStatic): @property def text_selection(self) -> None: return None @@ -42,7 +43,7 @@ class UserMessage(Static): def compose(self) -> ComposeResult: with Horizontal(classes="user-message-container"): yield NonSelectableStatic("> ", classes="user-message-prompt") - yield Static(self._content, markup=False, classes="user-message-content") + yield NoMarkupStatic(self._content, classes="user-message-content") if self._pending: self.add_class("pending") @@ -137,8 +138,8 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase): self._spinner.current_frame(), classes="reasoning-indicator" ) yield self._indicator_widget - self._status_text_widget = Static( - self.SPINNING_TEXT, markup=False, classes="reasoning-collapsed-text" + self._status_text_widget = NoMarkupStatic( + self.SPINNING_TEXT, classes="reasoning-collapsed-text" ) yield self._status_text_widget self._triangle_widget = NonSelectableStatic( @@ -204,9 +205,8 @@ class InterruptMessage(Static): def compose(self) -> ComposeResult: with Horizontal(classes="interrupt-container"): yield ExpandingBorder(classes="interrupt-border") - yield Static( + yield NoMarkupStatic( "Interrupted · What should Vibe do instead?", - markup=False, classes="interrupt-content", ) @@ -223,18 +223,20 @@ class BashOutputMessage(Static): def compose(self) -> ComposeResult: with Vertical(classes="bash-output-container"): with Horizontal(classes="bash-cwd-line"): - yield Static(self._cwd, markup=False, classes="bash-cwd") - yield Static("", classes="bash-cwd-spacer") + yield NoMarkupStatic(self._cwd, classes="bash-cwd") + yield NoMarkupStatic("", classes="bash-cwd-spacer") if self._exit_code == 0: - yield Static("✓", classes="bash-exit-success") + yield NoMarkupStatic("✓", classes="bash-exit-success") else: - yield Static("✗", classes="bash-exit-failure") - yield Static(f" ({self._exit_code})", classes="bash-exit-code") + yield NoMarkupStatic("✗", classes="bash-exit-failure") + yield NoMarkupStatic( + f" ({self._exit_code})", classes="bash-exit-code" + ) with Horizontal(classes="bash-command-line"): - yield Static("> ", classes="bash-chevron") - yield Static(self._command, markup=False, classes="bash-command") - yield Static("", classes="bash-command-spacer") - yield Static(self._output, markup=False, classes="bash-output") + yield NoMarkupStatic("> ", classes="bash-chevron") + yield NoMarkupStatic(self._command, classes="bash-command") + yield NoMarkupStatic("", classes="bash-command-spacer") + yield NoMarkupStatic(self._output, classes="bash-output") class ErrorMessage(Static): @@ -248,8 +250,8 @@ class ErrorMessage(Static): def compose(self) -> ComposeResult: with Horizontal(classes="error-container"): yield ExpandingBorder(classes="error-border") - self._content_widget = Static( - self._get_text(), markup=False, classes="error-content" + self._content_widget = NoMarkupStatic( + self._get_text(), classes="error-content" ) yield self._content_widget @@ -278,4 +280,4 @@ class WarningMessage(Static): with Horizontal(classes="warning-container"): if self._show_border: yield ExpandingBorder(classes="warning-border") - yield Static(self._message, markup=False, classes="warning-content") + yield NoMarkupStatic(self._message, classes="warning-content") diff --git a/vibe/cli/textual_ui/widgets/no_markup_static.py b/vibe/cli/textual_ui/widgets/no_markup_static.py new file mode 100644 index 0000000..ae461bc --- /dev/null +++ b/vibe/cli/textual_ui/widgets/no_markup_static.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + +from textual.visual import VisualType +from textual.widgets import Static + + +class NoMarkupStatic(Static): + def __init__(self, content: VisualType = "", **kwargs: Any) -> None: + super().__init__(content, markup=False, **kwargs) diff --git a/vibe/cli/textual_ui/widgets/path_display.py b/vibe/cli/textual_ui/widgets/path_display.py index 844cc90..01c694c 100644 --- a/vibe/cli/textual_ui/widgets/path_display.py +++ b/vibe/cli/textual_ui/widgets/path_display.py @@ -2,10 +2,10 @@ from __future__ import annotations from pathlib import Path -from textual.widgets import Static +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -class PathDisplay(Static): +class PathDisplay(NoMarkupStatic): def __init__(self, path: Path | str) -> None: super().__init__() self.can_focus = False diff --git a/vibe/cli/textual_ui/widgets/spinner.py b/vibe/cli/textual_ui/widgets/spinner.py index e14c51c..aac5b88 100644 --- a/vibe/cli/textual_ui/widgets/spinner.py +++ b/vibe/cli/textual_ui/widgets/spinner.py @@ -147,3 +147,8 @@ class SpinnerMixin: self._indicator_widget.add_class("error") if self._status_text_widget and self.COMPLETED_TEXT: self._status_text_widget.update(self.COMPLETED_TEXT) + + def on_unmount(self) -> None: + if self._spinner_timer: + self._spinner_timer.stop() + self._spinner_timer = None diff --git a/vibe/cli/textual_ui/widgets/status_message.py b/vibe/cli/textual_ui/widgets/status_message.py index 32592ed..5608244 100644 --- a/vibe/cli/textual_ui/widgets/status_message.py +++ b/vibe/cli/textual_ui/widgets/status_message.py @@ -7,16 +7,17 @@ from textual.containers import Horizontal from textual.widgets import Static from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType -class StatusMessage(SpinnerMixin, Static): +class StatusMessage(SpinnerMixin, NoMarkupStatic): SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE def __init__(self, initial_text: str = "", **kwargs: Any) -> None: self._initial_text = initial_text self._indicator_widget: Static | None = None - self._text_widget: Static | None = None + self._text_widget: NoMarkupStatic | None = None self.success = True self.init_spinner() super().__init__(**kwargs) @@ -24,14 +25,10 @@ class StatusMessage(SpinnerMixin, Static): def compose(self) -> ComposeResult: with Horizontal(): self._indicator_widget = NonSelectableStatic( - self._spinner.current_frame(), - markup=False, - classes="status-indicator-icon", + self._spinner.current_frame(), classes="status-indicator-icon" ) yield self._indicator_widget - self._text_widget = Static( - "", markup=False, classes="status-indicator-text" - ) + self._text_widget = NoMarkupStatic("", classes="status-indicator-text") yield self._text_widget 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 a86ee9a..6f5d4e7 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -8,6 +8,7 @@ from textual.app import ComposeResult from textual.containers import Vertical from textual.widgets import Markdown, Static +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS from vibe.core.tools.builtins.bash import BashArgs, BashResult from vibe.core.tools.builtins.grep import GrepArgs, GrepResult @@ -51,15 +52,15 @@ def parse_search_replace_to_diff(content: str) -> list[str]: def render_diff_line(line: str) -> Static: """Render a single diff line with appropriate styling.""" if line.startswith("---") or line.startswith("+++"): - return Static(line, markup=False, classes="diff-header") + return NoMarkupStatic(line, classes="diff-header") elif line.startswith("-"): - return Static(line, markup=False, classes="diff-removed") + return NoMarkupStatic(line, classes="diff-removed") elif line.startswith("+"): - return Static(line, markup=False, classes="diff-added") + return NoMarkupStatic(line, classes="diff-added") elif line.startswith("@@"): - return Static(line, markup=False, classes="diff-range") + return NoMarkupStatic(line, classes="diff-range") else: - return Static(line, markup=False, classes="diff-context") + return NoMarkupStatic(line, classes="diff-context") class ToolApprovalWidget[TArgs: BaseModel](Vertical): @@ -80,10 +81,8 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical): if len(value_str) > MAX_MSG_SIZE: hidden = len(value_str) - MAX_MSG_SIZE value_str = value_str[:MAX_MSG_SIZE] + f"… ({hidden} more characters)" - yield Static( - f"{field_name}: {value_str}", - markup=False, - classes="approval-description", + yield NoMarkupStatic( + f"{field_name}: {value_str}", classes="approval-description" ) @@ -115,9 +114,9 @@ class ToolResultWidget[TResult: BaseModel](Static): def _header(self) -> ComposeResult: """Yield the standard header. Subclasses can call this then add content.""" if self.collapsed: - yield Static(f"{self.message} {self._hint()}", markup=False) + yield NoMarkupStatic(f"{self.message} {self._hint()}") else: - yield Static(self.message, markup=False) + yield NoMarkupStatic(self.message) def compose(self) -> ComposeResult: """Default: show message and optionally result fields.""" @@ -127,10 +126,8 @@ class ToolResultWidget[TResult: BaseModel](Static): for field_name in type(self.result).model_fields: value = getattr(self.result, field_name) if value is not None and value not in ("", []): - yield Static( - f"{field_name}: {value}", - markup=False, - classes="tool-result-detail", + yield NoMarkupStatic( + f"{field_name}: {value}", classes="tool-result-detail" ) @@ -144,24 +141,18 @@ class BashResultWidget(ToolResultWidget[BashResult]): yield from self._header() if self.collapsed or not self.result: return - yield Static( - f"returncode: {self.result.returncode}", - markup=False, - classes="tool-result-detail", + yield NoMarkupStatic( + f"returncode: {self.result.returncode}", classes="tool-result-detail" ) if self.result.stdout: sep = "\n" if "\n" in self.result.stdout else " " - yield Static( - f"stdout:{sep}{self.result.stdout}", - markup=False, - classes="tool-result-detail", + yield NoMarkupStatic( + f"stdout:{sep}{self.result.stdout}", classes="tool-result-detail" ) if self.result.stderr: sep = "\n" if "\n" in self.result.stderr else " " - yield Static( - f"stderr:{sep}{self.result.stderr}", - markup=False, - classes="tool-result-detail", + yield NoMarkupStatic( + f"stderr:{sep}{self.result.stderr}", classes="tool-result-detail" ) @@ -170,10 +161,8 @@ class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]): path = Path(self.args.path) file_extension = path.suffix.lstrip(".") or "text" - yield Static( - f"File: {self.args.path}", markup=False, classes="approval-description" - ) - yield Static("") + yield NoMarkupStatic(f"File: {self.args.path}", classes="approval-description") + yield NoMarkupStatic("") yield Markdown(f"```{file_extension}\n{self.args.content}\n```") @@ -182,26 +171,22 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]): yield from self._header() if self.collapsed or not self.result: return - yield Static( - f"Path: {self.result.path}", markup=False, classes="tool-result-detail" - ) - yield Static( - f"Bytes: {self.result.bytes_written}", - markup=False, - classes="tool-result-detail", + yield NoMarkupStatic(f"Path: {self.result.path}", classes="tool-result-detail") + yield NoMarkupStatic( + f"Bytes: {self.result.bytes_written}", classes="tool-result-detail" ) if self.result.content: - yield Static("") + yield NoMarkupStatic("") ext = Path(self.result.path).suffix.lstrip(".") or "text" yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```") class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]): def compose(self) -> ComposeResult: - yield Static( - f"File: {self.args.file_path}", markup=False, classes="approval-description" + yield NoMarkupStatic( + f"File: {self.args.file_path}", classes="approval-description" ) - yield Static("") + yield NoMarkupStatic("") diff_lines = parse_search_replace_to_diff(self.args.content) for line in diff_lines: @@ -213,37 +198,30 @@ class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]): yield from self._header() if self.collapsed or not self.result: return - yield Static( - f"File: {self.result.file}", markup=False, classes="tool-result-detail" - ) - yield Static( + yield NoMarkupStatic(f"File: {self.result.file}", classes="tool-result-detail") + yield NoMarkupStatic( f"Blocks applied: {self.result.blocks_applied}", - markup=False, classes="tool-result-detail", ) - yield Static( - f"Lines changed: {self.result.lines_changed}", - markup=False, - classes="tool-result-detail", + yield NoMarkupStatic( + f"Lines changed: {self.result.lines_changed}", classes="tool-result-detail" ) for warning in self.result.warnings: - yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning") + yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") if self.result.content: - yield Static("") + yield NoMarkupStatic("") for line in parse_search_replace_to_diff(self.result.content): yield render_diff_line(line) class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]): def compose(self) -> ComposeResult: - yield Static( - f"Action: {self.args.action}", markup=False, classes="approval-description" + yield NoMarkupStatic( + f"Action: {self.args.action}", classes="approval-description" ) if self.args.todos: - yield Static( - f"Todos: {len(self.args.todos)} items", - markup=False, - classes="approval-description", + yield NoMarkupStatic( + f"Todos: {len(self.args.todos)} items", classes="approval-description" ) @@ -252,13 +230,13 @@ class TodoResultWidget(ToolResultWidget[TodoResult]): def compose(self) -> ComposeResult: if self.collapsed: - yield Static(f"{self.message} {self._hint()}", markup=False) + yield NoMarkupStatic(f"{self.message} {self._hint()}") else: - yield Static(f"{self.message} {self._hint()}", markup=False) - yield Static("") + yield NoMarkupStatic(f"{self.message} {self._hint()}") + yield NoMarkupStatic("") if not self.result or not self.result.todos: - yield Static("No todos", markup=False, classes="todo-empty") + yield NoMarkupStatic("No todos", classes="todo-empty") return # Group todos by status @@ -280,8 +258,8 @@ class TodoResultWidget(ToolResultWidget[TodoResult]): for status in ["in_progress", "pending", "completed", "cancelled"]: for todo in by_status[status]: icon = self._get_status_icon(status) - yield Static( - f"{icon} {todo.content}", markup=False, classes=f"todo-{status}" + yield NoMarkupStatic( + f"{icon} {todo.content}", classes=f"todo-{status}" ) def _get_status_icon(self, status: str) -> str: @@ -291,20 +269,14 @@ class TodoResultWidget(ToolResultWidget[TodoResult]): class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]): def compose(self) -> ComposeResult: - yield Static( - f"path: {self.args.path}", markup=False, classes="approval-description" - ) + yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description") if self.args.offset > 0: - yield Static( - f"offset: {self.args.offset}", - markup=False, - classes="approval-description", + yield NoMarkupStatic( + f"offset: {self.args.offset}", classes="approval-description" ) if self.args.limit is not None: - yield Static( - f"limit: {self.args.limit}", - markup=False, - classes="approval-description", + yield NoMarkupStatic( + f"limit: {self.args.limit}", classes="approval-description" ) @@ -314,32 +286,26 @@ class ReadFileResultWidget(ToolResultWidget[ReadFileResult]): if self.collapsed: return if self.result: - yield Static( - f"Path: {self.result.path}", markup=False, classes="tool-result-detail" + yield NoMarkupStatic( + f"Path: {self.result.path}", classes="tool-result-detail" ) for warning in self.warnings: - yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning") + yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") if self.result and self.result.content: - yield Static("") + yield NoMarkupStatic("") ext = Path(self.result.path).suffix.lstrip(".") or "text" yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```") class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]): def compose(self) -> ComposeResult: - yield Static( - f"pattern: {self.args.pattern}", - markup=False, - classes="approval-description", - ) - yield Static( - f"path: {self.args.path}", markup=False, classes="approval-description" + yield NoMarkupStatic( + f"pattern: {self.args.pattern}", classes="approval-description" ) + yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description") if self.args.max_matches is not None: - yield Static( - f"max_matches: {self.args.max_matches}", - markup=False, - classes="approval-description", + yield NoMarkupStatic( + f"max_matches: {self.args.max_matches}", classes="approval-description" ) @@ -349,9 +315,9 @@ class GrepResultWidget(ToolResultWidget[GrepResult]): if self.collapsed: return for warning in self.warnings: - yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning") + yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") if self.result and self.result.matches: - yield Static("") + yield NoMarkupStatic("") yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```") diff --git a/vibe/cli/textual_ui/widgets/tools.py b/vibe/cli/textual_ui/widgets/tools.py index 6c8c165..0426978 100644 --- a/vibe/cli/textual_ui/widgets/tools.py +++ b/vibe/cli/textual_ui/widgets/tools.py @@ -5,6 +5,7 @@ from textual.containers import Horizontal, Vertical from textual.widgets import Static from vibe.cli.textual_ui.widgets.messages import ExpandingBorder +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.status_message import StatusMessage from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS @@ -99,11 +100,11 @@ class ToolResultMessage(Static): self.add_class("error-text") if self.collapsed: await self._content_container.mount( - Static(f"Error. {self._hint()}", markup=False) + NoMarkupStatic(f"Error. {self._hint()}") ) else: await self._content_container.mount( - Static(f"Error: {self._event.error}", markup=False) + NoMarkupStatic(f"Error: {self._event.error}") ) return @@ -112,11 +113,11 @@ class ToolResultMessage(Static): reason = self._event.skip_reason or "User skipped" if self.collapsed: await self._content_container.mount( - Static(f"Skipped. {self._hint()}", markup=False) + NoMarkupStatic(f"Skipped. {self._hint()}") ) else: await self._content_container.mount( - Static(f"Skipped: {reason}", markup=False) + NoMarkupStatic(f"Skipped: {reason}") ) return @@ -146,15 +147,15 @@ class ToolResultMessage(Static): if self.collapsed: await self._content_container.mount( - Static(f"{self._tool_name} completed {self._hint()}", markup=False) + NoMarkupStatic(f"{self._tool_name} completed {self._hint()}") ) return if self._content: - await self._content_container.mount(Static(self._content, markup=False)) + await self._content_container.mount(NoMarkupStatic(self._content)) else: await self._content_container.mount( - Static(f"{self._tool_name} completed.", markup=False) + NoMarkupStatic(f"{self._tool_name} completed.") ) async def set_collapsed(self, collapsed: bool) -> None: diff --git a/vibe/core/config.py b/vibe/core/config.py index 24238f7..ff6a9c8 100644 --- a/vibe/core/config.py +++ b/vibe/core/config.py @@ -24,8 +24,6 @@ from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR from vibe.core.prompts import SystemPrompt from vibe.core.tools.base import BaseToolConfig -PROJECT_DOC_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"] - def load_api_keys_from_env() -> None: if GLOBAL_ENV_FILE.path.is_file(): diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index 0e4cdd5..abea48e 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -10,10 +10,10 @@ import sys import time from typing import TYPE_CHECKING -from vibe.core.config import PROJECT_DOC_FILENAMES from vibe.core.llm.format import get_active_tool_classes from vibe.core.paths.config_paths import INSTRUCTIONS_FILE from vibe.core.prompts import UtilityPrompt +from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager from vibe.core.utils import is_dangerous_directory, is_windows if TYPE_CHECKING: @@ -30,7 +30,9 @@ def _load_user_instructions() -> str: def _load_project_doc(workdir: Path, max_bytes: int) -> str: - for name in PROJECT_DOC_FILENAMES: + if not trusted_folders_manager.is_trusted(workdir): + return "" + for name in TRUSTABLE_FILENAMES: path = workdir / name try: return path.read_text("utf-8", errors="ignore")[:max_bytes] diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py index 70a520f..2aaf54a 100644 --- a/vibe/core/tools/builtins/bash.py +++ b/vibe/core/tools/builtins/bash.py @@ -1,13 +1,15 @@ from __future__ import annotations import asyncio +from functools import lru_cache import os -import re import signal import sys from typing import ClassVar, Literal, final from pydantic import BaseModel, Field +from tree_sitter import Language, Node, Parser +import tree_sitter_bash as tsbash from vibe.core.tools.base import ( BaseTool, @@ -19,6 +21,37 @@ from vibe.core.tools.base import ( from vibe.core.utils import is_windows +@lru_cache(maxsize=1) +def _get_parser() -> Parser: + return Parser(Language(tsbash.language())) + + +def _extract_commands(command: str) -> list[str]: + parser = _get_parser() + tree = parser.parse(command.encode("utf-8")) + + commands: list[str] = [] + + def find_commands(node: Node) -> None: + if node.type == "command": + parts = [] + for child in node.children: + if ( + child.type + in {"command_name", "word", "string", "raw_string", "concatenation"} + and child.text is not None + ): + parts.append(child.text.decode("utf-8")) + if parts: + commands.append(" ".join(parts)) + + for child in node.children: + find_commands(child) + + find_commands(tree.root_node) + return commands + + def _get_subprocess_encoding() -> str: if sys.platform == "win32": # Windows console uses OEM code page (e.g., cp850, cp1252) @@ -167,9 +200,10 @@ class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]): description: ClassVar[str] = "Run a one-off bash command and capture its output." def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None: - command_parts = re.split(r"(?:&&|\|\||;|\|)", args.command) - command_parts = [part.strip() for part in command_parts if part.strip()] + if is_windows(): + return None + command_parts = _extract_commands(args.command) if not command_parts: return None diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index 788aedb..0c5e4bc 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -7,6 +7,17 @@ import tomli_w from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE +TRUSTABLE_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"] + + +def has_trustable_content(path: Path) -> bool: + if (path / ".vibe").exists(): + return True + for name in TRUSTABLE_FILENAMES: + if (path / name).exists(): + return True + return False + class TrustedFoldersManager: def __init__(self) -> None: diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index 90b11a1..a80957f 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -12,6 +12,7 @@ from textual.validation import Length from textual.widgets import Input, Link, Static from vibe.cli.clipboard import copy_selection_to_clipboard +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import VibeConfig from vibe.core.paths.global_paths import GLOBAL_ENV_FILE from vibe.setup.onboarding.base import OnboardingScreen @@ -48,10 +49,10 @@ class ApiKeyScreen(OnboardingScreen): return help_url, help_name = PROVIDER_HELP[self.provider.name] - yield Static(f"Grab your {provider_name} API key from the {help_name}:") + yield NoMarkupStatic(f"Grab your {provider_name} API key from the {help_name}:") yield Center( Horizontal( - Static("→ ", classes="link-chevron"), + NoMarkupStatic("→ ", classes="link-chevron"), Link(help_url, url=help_url), classes="link-row", ) @@ -60,7 +61,7 @@ class ApiKeyScreen(OnboardingScreen): def _compose_config_docs(self) -> ComposeResult: yield Static("[dim]Learn more about Vibe configuration:[/]") yield Horizontal( - Static("→ ", classes="link-chevron"), + NoMarkupStatic("→ ", classes="link-chevron"), Link(CONFIG_DOCS_URL, url=CONFIG_DOCS_URL), classes="link-row", ) @@ -76,17 +77,17 @@ class ApiKeyScreen(OnboardingScreen): ) with Vertical(id="api-key-outer"): - yield Static("", classes="spacer") - yield Center(Static("One last thing...", id="api-key-title")) + yield NoMarkupStatic("", classes="spacer") + yield Center(NoMarkupStatic("One last thing...", id="api-key-title")) with Center(): with Vertical(id="api-key-content"): yield from self._compose_provider_link(provider_name) - yield Static( + yield NoMarkupStatic( "...and paste it below to finish the setup:", id="paste-hint" ) yield Center(Horizontal(self.input_widget, id="input-box")) - yield Static("", id="feedback") - yield Static("", classes="spacer") + yield NoMarkupStatic("", id="feedback") + yield NoMarkupStatic("", classes="spacer") yield Vertical( Vertical(*self._compose_config_docs(), id="config-docs-group"), id="config-docs-section", @@ -96,7 +97,7 @@ class ApiKeyScreen(OnboardingScreen): self.input_widget.focus() def on_input_changed(self, event: Input.Changed) -> None: - feedback = self.query_one("#feedback", Static) + feedback = self.query_one("#feedback", NoMarkupStatic) input_box = self.query_one("#input-box") if event.validation_result is None: diff --git a/vibe/setup/onboarding/screens/theme_selection.py b/vibe/setup/onboarding/screens/theme_selection.py index cf18e7b..fab2034 100644 --- a/vibe/setup/onboarding/screens/theme_selection.py +++ b/vibe/setup/onboarding/screens/theme_selection.py @@ -10,6 +10,7 @@ from textual.theme import BUILTIN_THEMES from textual.widgets import Markdown, Static from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import VibeConfig from vibe.setup.onboarding.base import OnboardingScreen @@ -67,19 +68,19 @@ class ThemeSelectionScreen(OnboardingScreen): def _compose_theme_list(self) -> ComposeResult: for _ in range(VISIBLE_NEIGHBORS * 2 + 1): - widget = Static("", classes="theme-item") + widget = NoMarkupStatic("", classes="theme-item") self._theme_widgets.append(widget) yield widget def compose(self) -> ComposeResult: with Center(id="theme-outer"): with Vertical(id="theme-content"): - yield Static("Select your preferred theme", id="theme-title") + yield NoMarkupStatic("Select your preferred theme", id="theme-title") yield Center( Horizontal( - Static("Navigate ↑ ↓", id="nav-hint"), + NoMarkupStatic("Navigate ↑ ↓", id="nav-hint"), Vertical(*self._compose_theme_list(), id="theme-list"), - Static("Press Enter \u21b5", id="enter-hint"), + NoMarkupStatic("Press Enter \u21b5", id="enter-hint"), id="theme-row", ) ) diff --git a/vibe/setup/onboarding/screens/welcome.py b/vibe/setup/onboarding/screens/welcome.py index 34aee96..6fcc7fe 100644 --- a/vibe/setup/onboarding/screens/welcome.py +++ b/vibe/setup/onboarding/screens/welcome.py @@ -8,6 +8,7 @@ from textual.containers import Center, Vertical from textual.timer import Timer from textual.widgets import Static +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.setup.onboarding.base import OnboardingScreen WELCOME_PREFIX = "Welcome to " @@ -68,7 +69,7 @@ class WelcomeScreen(OnboardingScreen): with Center(): yield Static("", id="welcome-text") with Center(): - yield Static("", id="enter-hint", classes="hidden") + yield NoMarkupStatic("", id="enter-hint", classes="hidden") def on_mount(self) -> None: self._welcome_text = self.query_one("#welcome-text", Static) diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py index 2b10bee..92f9ec5 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.py +++ b/vibe/setup/trusted_folders/trust_folder_dialog.py @@ -15,6 +15,7 @@ from vibe.cli.textual_ui.terminal_theme import ( TERMINAL_THEME_NAME, capture_terminal_theme, ) +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE @@ -50,14 +51,14 @@ class TrustFolderDialog(CenterMiddle): def compose(self) -> ComposeResult: with CenterMiddle(id="trust-dialog"): - yield Static("⚠ Trust this folder?", id="trust-dialog-title") - yield Static( + yield NoMarkupStatic("⚠ Trust this folder?", id="trust-dialog-title") + yield NoMarkupStatic( str(self.folder_path), id="trust-dialog-path", classes="trust-dialog-path", ) - yield Static( - "A .vibe/ directory was found here. Should Vibe load custom configuration and tools from it?", + yield NoMarkupStatic( + "Files that can modify your Mistral Vibe setup were found here. Do you trust this folder?", id="trust-dialog-message", classes="trust-dialog-message", ) @@ -65,13 +66,17 @@ class TrustFolderDialog(CenterMiddle): with Horizontal(id="trust-options-container"): options = ["Yes", "No"] for idx, text in enumerate(options): - widget = Static(f" {idx + 1}. {text}", classes="trust-option") + widget = NoMarkupStatic( + f" {idx + 1}. {text}", classes="trust-option" + ) self.option_widgets.append(widget) yield widget - yield Static("← → navigate Enter select", classes="trust-dialog-help") + yield NoMarkupStatic( + "← → navigate Enter select", classes="trust-dialog-help" + ) - yield Static( + yield NoMarkupStatic( f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}", id="trust-dialog-save-info", classes="trust-dialog-save-info",