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>
221 lines
6.5 KiB
Python
221 lines
6.5 KiB
Python
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}'"
|