v2.18.2 (#857)
Co-authored-by: Cyprien <courtot.c@gmail.com> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Peter Evers <peter.evers@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Yousria <yousria.debaud@mistral.ai> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
ed5b7192e6
commit
d50704e694
110 changed files with 1663 additions and 764 deletions
|
|
@ -4,6 +4,7 @@ from unittest.mock import patch
|
|||
|
||||
from pydantic import BaseModel
|
||||
import pytest
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.core.config import VibeConfig
|
||||
|
|
@ -76,3 +77,36 @@ class TestGracePeriod:
|
|||
assert approval_app.selected_option == 1
|
||||
approval_app.action_move_up()
|
||||
assert approval_app.selected_option == 0
|
||||
|
||||
|
||||
class TestVimKeybindings:
|
||||
def test_j_moves_down(self, approval_app: ApprovalApp):
|
||||
with patch.object(approval_app, "_update_options"):
|
||||
assert approval_app.selected_option == 0
|
||||
|
||||
approval_app.on_key(events.Key("j", "j"))
|
||||
|
||||
assert approval_app.selected_option == 1
|
||||
|
||||
def test_k_moves_up(self, approval_app: ApprovalApp):
|
||||
with patch.object(approval_app, "_update_options"):
|
||||
assert approval_app.selected_option == 0
|
||||
|
||||
approval_app.on_key(events.Key("k", "k"))
|
||||
|
||||
# Wraps to last option (index 3)
|
||||
assert approval_app.selected_option == 3
|
||||
|
||||
def test_vim_navigation_works_during_grace_period(self, approval_app: ApprovalApp):
|
||||
with (
|
||||
patch("vibe.cli.textual_ui.widgets.approval_app.time") as mock_time,
|
||||
patch.object(approval_app, "_update_options"),
|
||||
):
|
||||
mock_time.monotonic.return_value = 100.0 + 0.01
|
||||
assert approval_app.is_within_grace_period()
|
||||
|
||||
assert approval_app.selected_option == 0
|
||||
approval_app.on_key(events.Key("j", "j"))
|
||||
assert approval_app.selected_option == 1
|
||||
approval_app.on_key(events.Key("k", "k"))
|
||||
assert approval_app.selected_option == 0
|
||||
|
|
|
|||
|
|
@ -716,3 +716,66 @@ class TestMultiSelectSubmit:
|
|||
app.action_move_down()
|
||||
|
||||
assert app.selected_option == 0
|
||||
|
||||
|
||||
class TestVimKeybindings:
|
||||
def test_j_moves_down(self, single_question_args):
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
assert app.selected_option == 0
|
||||
|
||||
app.on_key(events.Key("j", "j"))
|
||||
|
||||
assert app.selected_option == 1
|
||||
|
||||
def test_k_moves_up(self, single_question_args):
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
assert app.selected_option == 0
|
||||
|
||||
app.on_key(events.Key("k", "k"))
|
||||
|
||||
# Wraps to last option (Other = index 2)
|
||||
assert app.selected_option == 2
|
||||
|
||||
def test_j_k_ignored_when_other_input_focused(self, single_question_args):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
app = QuestionApp(single_question_args)
|
||||
app.other_input = MagicMock()
|
||||
app.other_input.has_focus = True
|
||||
|
||||
app.on_key(events.Key("j", "j"))
|
||||
assert app.selected_option == 0
|
||||
|
||||
def test_j_moves_down_for_single_question(self):
|
||||
# Vim navigation must fire before the len <= 1 early return in on_key.
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
|
||||
args = AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Only question?",
|
||||
header="Q",
|
||||
options=[Choice(label="A"), Choice(label="B")],
|
||||
)
|
||||
]
|
||||
)
|
||||
app = QuestionApp(args)
|
||||
assert app.selected_option == 0
|
||||
|
||||
app.on_key(events.Key("j", "j"))
|
||||
|
||||
assert app.selected_option == 1
|
||||
|
|
|
|||
29
tests/cli/test_rewind_app_vim.py
Normal file
29
tests/cli/test_rewind_app_vim.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.widgets.rewind_app import RewindApp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(monkeypatch: pytest.MonkeyPatch) -> RewindApp:
|
||||
widget = RewindApp("preview", has_file_changes=True)
|
||||
monkeypatch.setattr(widget, "_update_options", lambda: None)
|
||||
return widget
|
||||
|
||||
|
||||
class TestRewindAppVimKeybindings:
|
||||
def test_j_moves_down(self, app: RewindApp):
|
||||
assert app.selected_option == 0
|
||||
|
||||
app.on_key(events.Key("j", "j"))
|
||||
|
||||
assert app.selected_option == 1
|
||||
|
||||
def test_k_wraps_to_last(self, app: RewindApp):
|
||||
assert app.selected_option == 0
|
||||
|
||||
app.on_key(events.Key("k", "k"))
|
||||
|
||||
assert app.selected_option == len(app._options) - 1
|
||||
|
|
@ -91,3 +91,28 @@ async def test_theme_picker_select_persists_and_applies() -> None:
|
|||
assert len(app.query(ThemePickerApp)) == 0
|
||||
assert app.config.theme == target
|
||||
assert app.theme == target
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_theme_picker_jk_moves_cursor() -> None:
|
||||
from textual.widgets import OptionList
|
||||
|
||||
config = build_test_vibe_config()
|
||||
config.theme = "ansi-dark"
|
||||
app = build_test_vibe_app(config=config)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_theme()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
option_list = app.query_one(ThemePickerApp).query_one(OptionList)
|
||||
start = option_list.highlighted
|
||||
assert start is not None
|
||||
|
||||
await pilot.press("j")
|
||||
await pilot.pause(0.1)
|
||||
assert option_list.highlighted == (start + 1) % option_list.option_count
|
||||
|
||||
await pilot.press("k")
|
||||
await pilot.pause(0.1)
|
||||
assert option_list.highlighted == start
|
||||
|
|
|
|||
30
tests/cli/test_voice_app_vim.py
Normal file
30
tests/cli/test_voice_app_vim.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
|
||||
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(monkeypatch: pytest.MonkeyPatch) -> VoiceApp:
|
||||
widget = VoiceApp(VibeConfig())
|
||||
monkeypatch.setattr(widget, "_update_display", lambda: None)
|
||||
return widget
|
||||
|
||||
|
||||
class TestVoiceAppVimKeybindings:
|
||||
def test_j_moves_down(self, app: VoiceApp):
|
||||
assert app.selected_index == 0
|
||||
|
||||
app.on_key(events.Key("j", "j"))
|
||||
|
||||
assert app.selected_index == 1
|
||||
|
||||
def test_k_wraps_to_last(self, app: VoiceApp):
|
||||
assert app.selected_index == 0
|
||||
|
||||
app.on_key(events.Key("k", "k"))
|
||||
|
||||
assert app.selected_index == len(app.settings) - 1
|
||||
183
tests/cli/textual_ui/test_event_handler_error_muting.py
Normal file
183
tests/cli/textual_ui/test_event_handler_error_muting.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
def _call_event(call_id: str) -> ToolCallEvent:
|
||||
return ToolCallEvent(
|
||||
tool_name="stub_tool",
|
||||
tool_class=FakeTool,
|
||||
args=FakeToolArgs(),
|
||||
tool_call_id=call_id,
|
||||
)
|
||||
|
||||
|
||||
def _error_result(call_id: str) -> ToolResultEvent:
|
||||
return ToolResultEvent(
|
||||
tool_name="stub_tool",
|
||||
tool_class=FakeTool,
|
||||
result=None,
|
||||
error="boom",
|
||||
tool_call_id=call_id,
|
||||
)
|
||||
|
||||
|
||||
def _ok_result(call_id: str) -> ToolResultEvent:
|
||||
return ToolResultEvent(
|
||||
tool_name="stub_tool", tool_class=FakeTool, result=None, tool_call_id=call_id
|
||||
)
|
||||
|
||||
|
||||
def _make_handler() -> tuple[EventHandler, AsyncMock]:
|
||||
mount_callback = AsyncMock()
|
||||
handler = EventHandler(
|
||||
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
||||
)
|
||||
return handler, mount_callback
|
||||
|
||||
|
||||
def _last_result_widget(mount_callback: AsyncMock) -> ToolResultMessage:
|
||||
for call in reversed(mount_callback.call_args_list):
|
||||
widget = call.args[0]
|
||||
if isinstance(widget, ToolResultMessage):
|
||||
return widget
|
||||
raise AssertionError("no ToolResultMessage was mounted")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_result_is_registered_as_pending() -> None:
|
||||
handler, mount_callback = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
await handler.handle_event(_error_result("a"))
|
||||
|
||||
assert len(handler._pending_error_results) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_tool_call_keeps_error_muted() -> None:
|
||||
handler, mount_callback = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
await handler.handle_event(_error_result("a"))
|
||||
result_widget = _last_result_widget(mount_callback)
|
||||
result_widget.escalate_error = Mock()
|
||||
|
||||
await handler.handle_event(_call_event("b"))
|
||||
|
||||
result_widget.escalate_error.assert_not_called()
|
||||
assert handler._pending_error_results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_end_escalates_error() -> None:
|
||||
handler, mount_callback = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
await handler.handle_event(_error_result("a"))
|
||||
result_widget = _last_result_widget(mount_callback)
|
||||
result_widget.escalate_error = Mock()
|
||||
|
||||
handler.escalate_unresolved_errors()
|
||||
|
||||
result_widget.escalate_error.assert_called_once()
|
||||
assert handler._pending_error_results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_result_is_not_pending() -> None:
|
||||
handler, mount_callback = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
await handler.handle_event(_ok_result("a"))
|
||||
|
||||
assert handler._pending_error_results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_arg_update_before_result_does_not_register_error() -> None:
|
||||
handler, _ = _make_handler()
|
||||
|
||||
# Same tool_call_id re-emitted as a streaming arg update, before any result.
|
||||
await handler.handle_event(_call_event("a"))
|
||||
await handler.handle_event(_call_event("a"))
|
||||
|
||||
assert handler._pending_error_results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_errors_escalated_together_at_turn_end() -> None:
|
||||
handler, mount_callback = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
await handler.handle_event(_call_event("b"))
|
||||
await handler.handle_event(_error_result("a"))
|
||||
await handler.handle_event(_error_result("b"))
|
||||
mocks: list[Mock] = []
|
||||
for widget in handler._pending_error_results:
|
||||
mock = Mock()
|
||||
widget.escalate_error = mock
|
||||
mocks.append(mock)
|
||||
assert len(mocks) == 2
|
||||
|
||||
handler.escalate_unresolved_errors()
|
||||
|
||||
for mock in mocks:
|
||||
mock.assert_called_once()
|
||||
assert handler._pending_error_results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_holds_muted_square_for_in_flight_call() -> None:
|
||||
handler, _ = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
tool_call = handler.tool_calls["a"]
|
||||
show_muted = Mock()
|
||||
stop_spinning = Mock()
|
||||
tool_call.show_muted = show_muted
|
||||
tool_call.stop_spinning = stop_spinning
|
||||
|
||||
handler.stop_current_tool_call(cancelled=True)
|
||||
|
||||
show_muted.assert_called_once()
|
||||
stop_spinning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_error_shows_red_cross_for_in_flight_call() -> None:
|
||||
handler, _ = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
tool_call = handler.tool_calls["a"]
|
||||
show_muted = Mock()
|
||||
stop_spinning = Mock()
|
||||
tool_call.show_muted = show_muted
|
||||
tool_call.stop_spinning = stop_spinning
|
||||
|
||||
handler.stop_current_tool_call(success=False)
|
||||
|
||||
stop_spinning.assert_called_once()
|
||||
show_muted.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_does_not_escalate_pending_errors() -> None:
|
||||
handler, mount_callback = _make_handler()
|
||||
|
||||
await handler.handle_event(_call_event("a"))
|
||||
await handler.handle_event(_error_result("a"))
|
||||
result_widget = _last_result_widget(mount_callback)
|
||||
result_widget.escalate_error = Mock()
|
||||
|
||||
handler.stop_current_tool_call(cancelled=True)
|
||||
|
||||
result_widget.escalate_error.assert_not_called()
|
||||
assert handler._pending_error_results == []
|
||||
106
tests/cli/textual_ui/test_tool_error_muting_widgets.py
Normal file
106
tests/cli/textual_ui/test_tool_error_muting_widgets.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.content import Content
|
||||
from textual.widgets import Static
|
||||
|
||||
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
||||
from vibe.cli.textual_ui.widgets.collapsible import CollapsibleSection
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class _ToolApp(App[None]):
|
||||
def __init__(
|
||||
self, call_event: ToolCallEvent, result_event: ToolResultEvent
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._call_event = call_event
|
||||
self._result_event = result_event
|
||||
self.call_widget: ToolCallMessage | None = None
|
||||
self.result_widget: ToolResultMessage | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Vertical(id="root")
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
root = self.query_one("#root", Vertical)
|
||||
self.call_widget = ToolCallMessage(self._call_event)
|
||||
await root.mount(self.call_widget)
|
||||
self.result_widget = ToolResultMessage(self._result_event, self.call_widget)
|
||||
await root.mount(self.result_widget)
|
||||
|
||||
|
||||
def _rendered(widget: Static) -> Content:
|
||||
content = widget.render()
|
||||
assert isinstance(content, Content)
|
||||
return content
|
||||
|
||||
|
||||
def _call_event() -> ToolCallEvent:
|
||||
return ToolCallEvent(
|
||||
tool_name="stub_tool",
|
||||
tool_class=FakeTool,
|
||||
args=FakeToolArgs(),
|
||||
tool_call_id="a",
|
||||
)
|
||||
|
||||
|
||||
def _error_result() -> ToolResultEvent:
|
||||
return ToolResultEvent(
|
||||
tool_name="stub_tool",
|
||||
tool_class=FakeTool,
|
||||
result=None,
|
||||
error="boom",
|
||||
tool_call_id="a",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_renders_muted_then_escalates_icon_and_styling() -> None:
|
||||
app = _ToolApp(_call_event(), _error_result())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
call_widget = app.call_widget
|
||||
result_widget = app.result_widget
|
||||
assert call_widget is not None and result_widget is not None
|
||||
|
||||
icon = call_widget._indicator_widget
|
||||
assert icon is not None
|
||||
# Default: held as a neutral grey square while the verdict is unknown.
|
||||
assert _rendered(icon).plain == "□"
|
||||
assert icon.has_class("muted")
|
||||
assert not icon.has_class("error")
|
||||
assert not result_widget.has_class("error-text")
|
||||
|
||||
result_widget.escalate_error()
|
||||
await pilot.pause()
|
||||
|
||||
# Escalated: red-cross icon, but the folded body keeps its muted style
|
||||
# (only the "Error" word colored, no whole-body error-text).
|
||||
assert _rendered(icon).plain == "✕"
|
||||
assert icon.has_class("error")
|
||||
assert not icon.has_class("muted")
|
||||
assert not result_widget.has_class("error-text")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folded_error_detail_colors_only_the_error_word() -> None:
|
||||
app = _ToolApp(_call_event(), _error_result())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
result_widget = app.result_widget
|
||||
assert result_widget is not None
|
||||
|
||||
section = result_widget.query_one(CollapsibleSection)
|
||||
detail = section.query_one(Static)
|
||||
# A markup-enabled Static is required so only "Error" can be colored.
|
||||
assert not isinstance(detail, NoMarkupStatic)
|
||||
content = _rendered(detail)
|
||||
assert content.plain == "Error: boom"
|
||||
assert any(
|
||||
span.start == 0 and span.end == len("Error") for span in content.spans
|
||||
)
|
||||
|
|
@ -5,7 +5,7 @@ from textual.app import App, ComposeResult
|
|||
|
||||
from vibe.cli.textual_ui.widgets.links import (
|
||||
LinkStatic,
|
||||
link_markup,
|
||||
link_content,
|
||||
linkify_urls_in_text,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import WebSearchResultWidget
|
||||
|
|
@ -13,32 +13,41 @@ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
|
|||
from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource
|
||||
|
||||
|
||||
def test_link_markup_encodes_url_in_action_and_renders_label() -> None:
|
||||
def _click_actions(content: object) -> list[str]:
|
||||
spans = getattr(content, "spans", [])
|
||||
return [
|
||||
span.style.meta["@click"]
|
||||
for span in spans
|
||||
if span.style.meta and "@click" in span.style.meta
|
||||
]
|
||||
|
||||
|
||||
def test_link_content_encodes_url_in_action_and_keeps_label() -> None:
|
||||
# The action arg is percent-encoded; the visible label is the page name.
|
||||
assert (
|
||||
link_markup("Example", "https://example.com")
|
||||
== "[@click=open_url('https%3A%2F%2Fexample.com')]Example[/]"
|
||||
)
|
||||
content = link_content("Example", "https://example.com")
|
||||
assert content.plain == "Example"
|
||||
assert _click_actions(content) == ["open_url('https%3A%2F%2Fexample.com')"]
|
||||
|
||||
|
||||
def test_link_markup_only_links_http_schemes() -> None:
|
||||
# Non-http(s) schemes render as the plain label, not a clickable @click span.
|
||||
def test_link_content_only_links_http_schemes() -> None:
|
||||
# Non-http(s) schemes render as the plain label, with no clickable @click span.
|
||||
for url in ("file:///etc/passwd", "javascript:alert(1)", "vscode://x"):
|
||||
assert link_markup(url, url) == url
|
||||
assert "@click" not in link_markup(url, url)
|
||||
content = link_content(url, url)
|
||||
assert content.plain == url
|
||||
assert _click_actions(content) == []
|
||||
|
||||
|
||||
def test_link_markup_handles_previously_unsafe_urls() -> None:
|
||||
# Brackets, quotes, parens — all encoded into the action, never a fallback.
|
||||
def test_link_content_handles_previously_unsafe_urls() -> None:
|
||||
# Brackets, quotes, parens live in the encoded action, never in the label text.
|
||||
for url in (
|
||||
"https://e.org/x[1]",
|
||||
"https://e.org/it's",
|
||||
"https://e.org/x)",
|
||||
"https://en.wikipedia.org/wiki/Python_(programming_language)",
|
||||
):
|
||||
markup = link_markup(url, url)
|
||||
assert markup.startswith("[@click=open_url('")
|
||||
assert "[1]" not in markup.split("]", 1)[0] # raw bracket not in the tag
|
||||
content = link_content(url, url)
|
||||
assert content.plain == url # literal text, never re-parsed as markup
|
||||
assert len(_click_actions(content)) == 1
|
||||
|
||||
|
||||
class _Harness(App):
|
||||
|
|
@ -48,7 +57,7 @@ class _Harness(App):
|
|||
self.opened: list[str] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield LinkStatic(link_markup(self.url, self.url))
|
||||
yield LinkStatic(link_content(self.url, self.url))
|
||||
|
||||
def open_url(self, url: str, *, new_tab: bool = True) -> None:
|
||||
self.opened.append(url)
|
||||
|
|
@ -112,33 +121,36 @@ async def test_unsafe_schemes_are_rejected(scheme: str) -> None:
|
|||
def test_linkify_urls_in_text_auto_detects_url() -> None:
|
||||
# Once a tool is opted into linkification, URLs are found in the message
|
||||
# itself — the call site doesn't have to point at the URL span.
|
||||
markup = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)")
|
||||
assert markup.startswith("Fetched ")
|
||||
assert (
|
||||
"[@click=open_url('https%3A%2F%2Fexample.com')]https://example.com[/]" in markup
|
||||
)
|
||||
content = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)")
|
||||
assert content.plain == "Fetched https://example.com (10 chars, text/html)"
|
||||
assert _click_actions(content) == ["open_url('https%3A%2F%2Fexample.com')"]
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_handles_multiple_urls() -> None:
|
||||
markup = linkify_urls_in_text("see https://a.com and https://b.com here")
|
||||
assert "[@click=open_url('https%3A%2F%2Fa.com')]https://a.com[/]" in markup
|
||||
assert "[@click=open_url('https%3A%2F%2Fb.com')]https://b.com[/]" in markup
|
||||
content = linkify_urls_in_text("see https://a.com and https://b.com here")
|
||||
assert _click_actions(content) == [
|
||||
"open_url('https%3A%2F%2Fa.com')",
|
||||
"open_url('https%3A%2F%2Fb.com')",
|
||||
]
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_keeps_balanced_parens_in_url() -> None:
|
||||
# Wikipedia-style URLs with `(…)` were the reason the @click action is
|
||||
# percent-encoded; Rich's URL detector already keeps them in the span.
|
||||
url = "https://en.wikipedia.org/wiki/Python_(programming_language)"
|
||||
markup = linkify_urls_in_text(f"see {url} for details")
|
||||
assert link_markup(url, url) in markup
|
||||
content = linkify_urls_in_text(f"see {url} for details")
|
||||
assert url in content.plain
|
||||
assert _click_actions(content) == [
|
||||
"open_url('https%3A%2F%2Fen.wikipedia.org%2Fwiki%2F"
|
||||
"Python_%28programming_language%29')"
|
||||
]
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_escapes_and_keeps_plain_when_no_url() -> None:
|
||||
# Brackets must be escaped so raw tool text can't break the markup.
|
||||
assert (
|
||||
linkify_urls_in_text("Searched '[a]' (2 sources)")
|
||||
== "Searched '\\[a]' (2 sources)"
|
||||
)
|
||||
def test_linkify_urls_in_text_keeps_brackets_literal_when_no_url() -> None:
|
||||
# Raw tool text with brackets stays literal (Content is never markup-parsed).
|
||||
content = linkify_urls_in_text("Searched '[a]' (2 sources)")
|
||||
assert content.plain == "Searched '[a]' (2 sources)"
|
||||
assert _click_actions(content) == []
|
||||
|
||||
|
||||
async def _rendered_lines(widget: WebSearchResultWidget) -> list[str]:
|
||||
|
|
@ -216,7 +228,7 @@ async def test_tool_call_message_set_result_text_renders_clickable_url() -> None
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> None:
|
||||
async def test_tool_call_message_set_result_text_keeps_brackets_literal_off() -> None:
|
||||
call = ToolCallMessage(tool_name="bash")
|
||||
|
||||
class _H(App):
|
||||
|
|
@ -234,3 +246,27 @@ async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> N
|
|||
assert "@click=open_url" not in rendered
|
||||
assert "https://example.com" in rendered
|
||||
assert "[exit 0]" in rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("linkify", [False, True])
|
||||
async def test_tool_call_message_renders_malformed_markup_without_crashing(
|
||||
linkify: bool,
|
||||
) -> None:
|
||||
# A bash summary like `git tag [/foo bar]` reads as a broken Rich closing
|
||||
# tag and used to raise MarkupError when rendered into a markup=True widget.
|
||||
call = ToolCallMessage(tool_name="bash")
|
||||
|
||||
class _H(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield call
|
||||
|
||||
text = "ran: git tag [/foo bar] && echo [done] https://example.com"
|
||||
async with _H().run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
call.set_result_text(text, linkify=linkify)
|
||||
await pilot.pause(0.1)
|
||||
rendered = str(call._text_widget.render()) if call._text_widget else ""
|
||||
|
||||
assert "[/foo bar]" in rendered
|
||||
assert "[done]" in rendered
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue