Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Luis Cardoso <luis.cardoso@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-01-07 17:57:27 +01:00 committed by Mathias Gesbert
parent 4d449be276
commit add3ab5245
36 changed files with 399 additions and 259 deletions

View file

@ -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 }}

View file

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

View file

@ -8,6 +8,7 @@ jobs:
label-component:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5

View file

@ -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:

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{
"version": "1.3.3",
"version": "1.3.4",
"configurations": [
{
"name": "ACP Server",

View file

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

View file

@ -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"

View file

@ -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]

View file

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

View file

@ -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()) == "[/]"

51
uv.lock generated
View file

@ -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"

View file

@ -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"

View file

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

View file

@ -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,6 +521,7 @@ class VibeApp(App): # noqa: PLR0904
messages_area = self.query_one("#messages")
tool_call_map: dict[str, str] = {}
with self.batch_update():
for msg in self._loaded_messages:
if msg.role == Role.system:
continue

View file

@ -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:

View file

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

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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)
if elapsed != self._last_elapsed:
self._last_elapsed = elapsed
self.hint_widget.update(f"({elapsed}s esc to interrupt)")

View file

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

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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```")

View file

@ -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:

View file

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

View file

@ -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]

View file

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

View file

@ -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:

View file

@ -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:

View file

@ -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",
)
)

View file

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

View file

@ -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",