Co-authored-by: Alexis Tacnet <alexis@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com>
Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com>
Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-04 18:26:35 +02:00 committed by GitHub
parent ad0d5c9520
commit 3f8487f761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 10819 additions and 2830 deletions

View file

@ -0,0 +1,64 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea
@pytest.mark.asyncio
async def test_shift_backspace_deletes_character_like_backspace() -> None:
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
text_area = app.query_one(ChatTextArea)
text_area.focus()
await pilot.pause(0.1)
await pilot.press("a", "b", "c")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "abc"
await pilot.press("shift+backspace")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "ab"
@pytest.mark.asyncio
async def test_shift_delete_deletes_character_like_delete() -> None:
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
text_area = app.query_one(ChatTextArea)
text_area.focus()
await pilot.pause(0.1)
await pilot.press("a", "b", "c", "left")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "abc"
await pilot.press("shift+delete")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "ab"
@pytest.mark.asyncio
async def test_shift_backspace_resets_mode_when_empty() -> None:
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
text_area = app.query_one(ChatTextArea)
text_area.focus()
text_area.set_mode("!")
await pilot.pause(0.1)
assert text_area.input_mode == "!"
await pilot.press("shift+backspace")
await pilot.pause(0.1)
assert text_area.input_mode == ">"

View file

@ -0,0 +1,149 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.app import _ImageAttachmentRejection
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
from vibe.core.config import ModelConfig, ProviderConfig
from vibe.core.types import (
MAX_IMAGE_BYTES,
MAX_IMAGES_PER_MESSAGE,
Backend,
ImageAttachment,
)
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
def _vision_config(*, supports_images: bool = True):
models = [
ModelConfig(
name="mistral-vibe-cli-latest",
provider="mistral",
alias="devstral-latest",
supports_images=supports_images,
)
]
providers = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
]
return build_test_vibe_config(
active_model="devstral-latest", models=models, providers=providers
)
@pytest.mark.asyncio
async def test_build_image_attachments_happy_path(tmp_path: Path) -> None:
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], ImageAttachment)
assert result[0].alias == "shot.png"
assert result[0].mime_type == "image/png"
@pytest.mark.asyncio
async def test_build_image_attachments_returns_empty_when_no_images(
tmp_path: Path,
) -> None:
(tmp_path / "notes.md").write_text("hi")
payload = build_path_prompt_payload("read @notes.md", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert result == []
@pytest.mark.asyncio
async def test_build_image_attachments_rejects_too_many_images(tmp_path: Path) -> None:
mentions = []
for i in range(MAX_IMAGES_PER_MESSAGE + 1):
name = f"img{i}.png"
(tmp_path / name).write_bytes(PNG_BYTES)
mentions.append(f"@{name}")
payload = build_path_prompt_payload(" ".join(mentions), base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert isinstance(result, _ImageAttachmentRejection)
assert not result.no_vision
assert "Too many image attachments" in result.message
assert str(MAX_IMAGES_PER_MESSAGE) in result.message
@pytest.mark.asyncio
async def test_build_image_attachments_rejects_non_vision_model(tmp_path: Path) -> None:
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config(supports_images=False))
result = await app._build_image_attachments(payload)
assert isinstance(result, _ImageAttachmentRejection)
assert result.no_vision
assert "does not support images" in result.message
assert "devstral-latest" in result.message
@pytest.mark.asyncio
async def test_build_image_attachments_rejects_oversize_image(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Patch the cap down so we don't need to write 10MB to disk.
monkeypatch.setattr("vibe.cli.textual_ui.app.MAX_IMAGE_BYTES", 32)
(tmp_path / "shot.png").write_bytes(PNG_BYTES + b"\x00" * 64)
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert isinstance(result, _ImageAttachmentRejection)
assert not result.no_vision
assert "shot.png" in result.message
assert "max" in result.message.lower()
def test_max_image_bytes_default_is_10_mib() -> None:
assert MAX_IMAGE_BYTES == 10 * 1024 * 1024
def test_max_images_per_message_default_is_8() -> None:
assert MAX_IMAGES_PER_MESSAGE == 8
@pytest.mark.asyncio
async def test_submit_restores_input_when_image_validation_fails(
tmp_path: Path,
) -> None:
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
app = build_test_vibe_app(config=_vision_config(supports_images=False))
typed = f"look at @{tmp_path / 'shot.png'}"
async with app.run_test() as pilot:
chat_input = app.query_one(ChatInputContainer)
chat_input.value = typed
await pilot.press("enter")
await pilot.pause()
assert chat_input.value == typed
errors = list(app.query(ErrorMessage))
assert errors
assert all(not e._show_border for e in errors)

View file

@ -0,0 +1,221 @@
from __future__ import annotations
from pathlib import Path
import pytest
from textual import events
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.paste_path import (
maybe_prepend_at_for_image_path,
rewrite_bare_image_paths_in_text,
)
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
def test_bare_absolute_image_path_gets_at_prefix(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
rewritten = maybe_prepend_at_for_image_path(str(img))
assert rewritten == f"@{img}"
def test_quoted_image_path_with_spaces_is_unwrapped_and_quoted(tmp_path: Path) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
pasted = f"'{img}'"
rewritten = maybe_prepend_at_for_image_path(pasted)
assert rewritten == f"@'{img}'"
def test_backslash_escaped_image_path_is_unescaped_and_quoted(tmp_path: Path) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
pasted = str(img).replace(" ", "\\ ")
rewritten = maybe_prepend_at_for_image_path(pasted)
assert rewritten == f"@'{img}'"
def test_non_image_file_path_is_left_untouched(tmp_path: Path) -> None:
txt = tmp_path / "readme.md"
txt.write_text("hi")
rewritten = maybe_prepend_at_for_image_path(str(txt))
assert rewritten == str(txt)
def test_missing_image_path_is_left_untouched(tmp_path: Path) -> None:
missing = tmp_path / "nope.png"
rewritten = maybe_prepend_at_for_image_path(str(missing))
assert rewritten == str(missing)
def test_unresolvable_tilde_user_does_not_crash() -> None:
# `~a` raises RuntimeError from Path.expanduser() when user `a` does not
# exist; the rewrite hook must swallow it so every keystroke after `~`
# does not crash the TUI.
assert maybe_prepend_at_for_image_path("~a") == "~a"
assert rewrite_bare_image_paths_in_text("hello ~a world") == "hello ~a world"
def test_multiline_paste_is_left_untouched(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
pasted = f"{img}\nother line"
assert maybe_prepend_at_for_image_path(pasted) == pasted
def test_relative_path_is_left_untouched(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "shot.png").write_bytes(b"\x89PNG")
assert maybe_prepend_at_for_image_path("shot.png") == "shot.png"
def test_already_at_prefixed_path_is_left_untouched(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
pasted = f"@{img}"
assert maybe_prepend_at_for_image_path(pasted) == pasted
@pytest.mark.asyncio
async def test_paste_event_inserts_at_prefixed_path_into_chat_input(
vibe_app: VibeApp, tmp_path: Path
) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
text_area.post_message(events.Paste(text=str(img)))
await pilot.pause()
assert chat_input.value == f"@{img}"
@pytest.mark.asyncio
async def test_paste_event_leaves_non_image_paths_untouched(
vibe_app: VibeApp, tmp_path: Path
) -> None:
txt = tmp_path / "notes.md"
txt.write_text("hi")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
text_area.post_message(events.Paste(text=str(txt)))
await pilot.pause()
assert chat_input.value == str(txt)
def test_rewrite_bare_image_paths_handles_bare_path(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
rewritten = rewrite_bare_image_paths_in_text(f"look at {img} please")
assert rewritten == f"look at @{img} please"
def test_rewrite_bare_image_paths_handles_quoted_path(tmp_path: Path) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
rewritten = rewrite_bare_image_paths_in_text(f"look at '{img}'")
assert rewritten == f"look at @'{img}'"
def test_rewrite_bare_image_paths_is_idempotent(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
once = rewrite_bare_image_paths_in_text(f"see {img}")
twice = rewrite_bare_image_paths_in_text(once)
assert once == twice == f"see @{img}"
def test_rewrite_bare_image_paths_skips_non_image(tmp_path: Path) -> None:
txt = tmp_path / "notes.md"
txt.write_text("hi")
rewritten = rewrite_bare_image_paths_in_text(f"see {txt}")
assert rewritten == f"see {txt}"
def test_rewrite_bare_image_paths_fast_path_skips_stat_for_plain_text(
monkeypatch,
) -> None:
from pathlib import Path as _Path
calls = 0
original = _Path.is_file
def _counting_is_file(self):
nonlocal calls
calls += 1
return original(self)
monkeypatch.setattr(_Path, "is_file", _counting_is_file)
rewrite_bare_image_paths_in_text("hello world, nothing path-shaped here")
rewrite_bare_image_paths_in_text("multi\nline\ntext\nwith no slash")
assert calls == 0
@pytest.mark.asyncio
async def test_text_change_hook_rewrites_quoted_image_path(
vibe_app: VibeApp, tmp_path: Path
) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
# Simulate a non-bracketed-paste insertion (terminal that does
# not emit Paste): set the text directly the way a bulk insert
# would land it.
text_area.text = f"'{img}'"
await pilot.pause()
# No spaces in the path -> the scanner emits an unquoted `@<path>`.
assert chat_input.value == f"@{img}"
@pytest.mark.asyncio
async def test_text_change_hook_rewrites_quoted_image_path_with_spaces(
vibe_app: VibeApp, tmp_path: Path
) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
text_area.text = f"'{img}'"
await pilot.pause()
assert chat_input.value == f"@'{img}'"

View file

@ -0,0 +1,18 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.tool_widgets import _strip_line_numbers
def test_strips_numbered_prefixes() -> None:
content = " 1→first\n 42→second\n 100→third"
assert _strip_line_numbers(content) == "first\nsecond\nthird"
def test_leaves_warning_lines_untouched() -> None:
content = "<vibe_warning>Warning: the file exists but the contents are empty.</vibe_warning>"
assert _strip_line_numbers(content) == content
def test_preserves_arrows_inside_content() -> None:
content = " 1→a → b → c"
assert _strip_line_numbers(content) == "a → b → c"

View file

@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
from weakref import WeakKeyDictionary
from vibe.cli.textual_ui.widgets.messages import UserMessage
from vibe.cli.textual_ui.windowing.history import build_history_widgets
from vibe.core.types import ImageAttachment, LLMMessage, Role
def _att(path: Path, alias: str) -> ImageAttachment:
path.write_bytes(b"\x89PNG")
return ImageAttachment(path=path, alias=alias, mime_type="image/png")
def test_attachments_footer_singular(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "shot.png")
rendered = UserMessage._format_attachments_footer([att])
assert "attached image:" in rendered
assert '[link="file://' in rendered
assert "]shot.png[/link]" in rendered
def test_attachments_footer_plural(tmp_path: Path) -> None:
a = _att(tmp_path / "a.png", "a.png")
b = _att(tmp_path / "b.png", "b.png")
rendered = UserMessage._format_attachments_footer([a, b])
assert "attached images:" in rendered
assert rendered.count('[link="file://') == 2
assert "a.png" in rendered
assert "b.png" in rendered
def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "weird [bracket].png")
rendered = UserMessage._format_attachments_footer([att])
# Rich's escape() turns "[" into "\[".
assert "\\[bracket]" in rendered
def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "shot.png")
msg = LLMMessage(role=Role.user, content="look at @shot.png", images=[att])
widgets = build_history_widgets(
[msg],
tool_call_map={},
start_index=0,
tools_collapsed=False,
history_widget_indices=WeakKeyDictionary(),
)
assert len(widgets) == 1
user_widget = widgets[0]
assert isinstance(user_widget, UserMessage)
assert user_widget._images == [att]
def test_resumed_user_message_with_images_only_still_mounts(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "shot.png")
msg = LLMMessage(role=Role.user, content="", images=[att])
widgets = build_history_widgets(
[msg],
tool_call_map={},
start_index=0,
tools_collapsed=False,
history_widget_indices=WeakKeyDictionary(),
)
assert len(widgets) == 1
assert isinstance(widgets[0], UserMessage)