mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): toast when opening a clicked URL (#4368)
Clicking a URL in the interactive TUI now shows a confirmation toast indicating that the link is opening in the default browser. --- Clicking links in the TUI previously opened the browser silently, with no in-app feedback. Shared link handling now posts an informational toast on successful opens: `Opening URL in default browser: <url>` The displayed URL is sanitized with `strip_dangerous_unicode` and rendered with `markup=False`, matching the existing blocked-URL and browser-failure notifications. This covers both Rich style links and Textual Markdown links, including assistant messages, the startup splash LangSmith tracing links, `/auth` docs links, MCP login, thread selector, welcome, and similar modal/link surfaces that share the helper. The success toast is enabled by default and can be disabled with either: ```toml [ui] show_url_open_toast = false ``` or: ```bash DEEPAGENTS_CODE_SHOW_URL_OPEN_TOAST=0 ``` Made by [Open SWE](https://openswe.vercel.app/agents/0c261ed0-9c77-04cb-35fa-cb8f1c1da3db) Credit to @tom21100227 for the initial idea --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -218,6 +218,13 @@ config value on launch, so a `/scrollbar` toggle will not appear to "stick"
|
||||
across restarts while the env var remains set.
|
||||
"""
|
||||
|
||||
SHOW_URL_OPEN_TOAST = "DEEPAGENTS_CODE_SHOW_URL_OPEN_TOAST"
|
||||
"""Show a confirmation toast after clicking a URL that opens in a browser.
|
||||
|
||||
Defaults to enabled; set to a falsy value (`0`, `false`, `no`, `off`, or empty)
|
||||
to suppress the success toast while still opening URLs normally.
|
||||
"""
|
||||
|
||||
THEME = "DEEPAGENTS_CODE_THEME"
|
||||
"""Force the CLI to launch with this theme name when set."""
|
||||
|
||||
|
||||
@@ -7809,7 +7809,7 @@ class DeepAgentsApp(App):
|
||||
# returns True on errors so transient state failures suppress this warning
|
||||
# rather than showing a false empty-thread note.
|
||||
parts: list[str | Content | tuple[str, str | TStyle]] = [
|
||||
f"Opening tracing project {project_name!r}:\n",
|
||||
f"Opening tracing project {project_name!r} in default browser:\n",
|
||||
(url, TStyle(dim=True, italic=True, link=url)),
|
||||
]
|
||||
if not await self._has_conversation_messages():
|
||||
|
||||
@@ -860,6 +860,15 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
default=False,
|
||||
env_var=_env_vars.NO_TERMINAL_ESCAPE,
|
||||
),
|
||||
ConfigOption(
|
||||
key="display.show_url_open_toast",
|
||||
group="Display",
|
||||
summary="Show a confirmation toast after clicking a URL.",
|
||||
kind=OptionKind.BOOL,
|
||||
default=True,
|
||||
env_var=_env_vars.SHOW_URL_OPEN_TOAST,
|
||||
toml_keys=("ui", "show_url_open_toast"),
|
||||
),
|
||||
ConfigOption(
|
||||
key="display.onboarding_integrations_screen",
|
||||
group="Display",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import logging
|
||||
import webbrowser
|
||||
@@ -13,9 +14,116 @@ if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
from textual.events import Click, MouseMove
|
||||
|
||||
|
||||
def _event_app(event: object, app: App | None = None) -> App | None:
|
||||
"""Return the app for a click event, including real Textual widgets."""
|
||||
if app is not None:
|
||||
return app
|
||||
widget = getattr(event, "widget", None)
|
||||
widget_app = getattr(widget, "app", None)
|
||||
if widget_app is not None:
|
||||
return widget_app
|
||||
event_app = getattr(event, "app", None)
|
||||
return event_app if event_app is not None else None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _notify(
|
||||
app: App | None, message: str, *, severity: str, timeout: int | None = None
|
||||
) -> None:
|
||||
"""Post a best-effort Textual toast, tolerating apps without `notify`.
|
||||
|
||||
Centralizes the guard/`markup=False`/exception-swallowing pattern shared by
|
||||
every toast in this module so the call sites cannot drift apart. `markup` is
|
||||
always disabled so URL content can never be interpreted as Textual markup.
|
||||
|
||||
Args:
|
||||
app: App-like object used to post the toast, or `None`.
|
||||
message: The toast body. Callers must sanitize any URL with
|
||||
`strip_dangerous_unicode` before interpolating it here.
|
||||
severity: Textual notification severity (e.g. `information`, `warning`).
|
||||
timeout: Optional toast lifetime in seconds; the Textual default is used
|
||||
when omitted.
|
||||
"""
|
||||
notify = getattr(app, "notify", None)
|
||||
if not callable(notify):
|
||||
return
|
||||
kwargs: dict[str, object] = {"severity": severity, "markup": False}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
try:
|
||||
notify(message, **kwargs)
|
||||
except (AttributeError, TypeError):
|
||||
logger.debug("Could not send notification", exc_info=True)
|
||||
|
||||
|
||||
def _url_open_toasts_enabled() -> bool:
|
||||
"""Return whether successful URL-open clicks should show a toast."""
|
||||
from deepagents_code.config_manifest import (
|
||||
get_option,
|
||||
load_config_toml,
|
||||
resolve_scalar,
|
||||
)
|
||||
|
||||
option = get_option("display.show_url_open_toast")
|
||||
if option is None:
|
||||
return True
|
||||
value, _ = resolve_scalar(option, toml_data=load_config_toml())
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _notify_url_opened(app: App | None, url: str) -> None:
|
||||
"""Show the URL-opened toast when the user has not opted out."""
|
||||
if app is None or not _url_open_toasts_enabled():
|
||||
return
|
||||
_notify(
|
||||
app,
|
||||
f"Opening URL in default browser: {strip_dangerous_unicode(url)}",
|
||||
severity="information",
|
||||
timeout=4,
|
||||
)
|
||||
|
||||
|
||||
def _link_action_url(click: object) -> str | None:
|
||||
"""Extract a URL from Textual's Markdown `link(...)` click action.
|
||||
|
||||
Args:
|
||||
click: The `@click` style metadata value to inspect.
|
||||
|
||||
Returns:
|
||||
The parsed URL when the metadata is a quoted `link(...)` action.
|
||||
"""
|
||||
if not isinstance(click, str):
|
||||
return None
|
||||
if not click.startswith("link(") or not click.endswith(")"):
|
||||
return None
|
||||
try:
|
||||
url = ast.literal_eval(click[len("link(") : -1].strip())
|
||||
except (SyntaxError, ValueError):
|
||||
return None
|
||||
return url if isinstance(url, str) and url else None
|
||||
|
||||
|
||||
def _style_url(style: object) -> str | None:
|
||||
"""Return a URL from either Rich link style or Textual click metadata.
|
||||
|
||||
Args:
|
||||
style: The Textual event style to inspect.
|
||||
|
||||
Returns:
|
||||
The URL embedded in the style, if one is present.
|
||||
"""
|
||||
url = getattr(style, "link", None)
|
||||
if isinstance(url, str) and url:
|
||||
return url
|
||||
meta = getattr(style, "meta", None)
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
return _link_action_url(meta.get("@click"))
|
||||
|
||||
|
||||
def event_targets_link(event: MouseMove) -> bool:
|
||||
"""Return whether the style under the mouse points to a clickable link.
|
||||
|
||||
@@ -29,14 +137,40 @@ def event_targets_link(event: MouseMove) -> bool:
|
||||
Returns:
|
||||
`True` when the hovered character belongs to a link span.
|
||||
"""
|
||||
style = event.style
|
||||
if style.link:
|
||||
return True
|
||||
click = style.meta.get("@click")
|
||||
return isinstance(click, str) and click.startswith("link(")
|
||||
return _style_url(event.style) is not None
|
||||
|
||||
|
||||
async def open_url_async(url: str, *, app: App) -> bool:
|
||||
async def open_checked_url_async(
|
||||
url: str, *, app: App, notify_on_success: bool = False
|
||||
) -> bool:
|
||||
"""Open a URL after applying the shared URL safety check.
|
||||
|
||||
Args:
|
||||
url: The URL to validate and open.
|
||||
app: App used to post browser-open notifications.
|
||||
notify_on_success: Whether to post an informational toast when the
|
||||
browser accepts the URL.
|
||||
|
||||
Returns:
|
||||
`True` when the URL passed safety checks and the browser accepted it;
|
||||
`False` when the URL was blocked or the browser could not open it.
|
||||
"""
|
||||
safety = check_url_safety(url)
|
||||
if not safety.safe:
|
||||
detail = safety.warnings[0] if safety.warnings else "Suspicious URL"
|
||||
logger.warning("Blocked suspicious URL: %s (%s)", url, detail)
|
||||
_notify(
|
||||
app,
|
||||
f"Blocked suspicious URL: {strip_dangerous_unicode(url)}\n{detail}",
|
||||
severity="warning",
|
||||
)
|
||||
return False
|
||||
return await open_url_async(url, app=app, notify_on_success=notify_on_success)
|
||||
|
||||
|
||||
async def open_url_async(
|
||||
url: str, *, app: App, notify_on_success: bool = False
|
||||
) -> bool:
|
||||
"""Open url in a browser and toast on failure.
|
||||
|
||||
Runs `webbrowser.open` in a thread, catches the platform errors
|
||||
@@ -46,7 +180,9 @@ async def open_url_async(url: str, *, app: App) -> bool:
|
||||
|
||||
Args:
|
||||
url: The URL to open.
|
||||
app: App used to post the failure toast.
|
||||
app: App used to post browser-open notifications.
|
||||
notify_on_success: Whether to post an informational toast when the
|
||||
browser accepts the URL.
|
||||
|
||||
Returns:
|
||||
`True` when the browser accepted the URL; `False` otherwise
|
||||
@@ -58,16 +194,18 @@ async def open_url_async(url: str, *, app: App) -> bool:
|
||||
logger.warning("webbrowser.open failed for %s: %s", url, exc, exc_info=True)
|
||||
opened = False
|
||||
if not opened:
|
||||
app.notify(
|
||||
f"Could not open a browser. URL: {url}",
|
||||
_notify(
|
||||
app,
|
||||
f"Could not open a browser. URL: {strip_dangerous_unicode(url)}",
|
||||
severity="warning",
|
||||
timeout=8,
|
||||
markup=False,
|
||||
)
|
||||
elif notify_on_success:
|
||||
_notify_url_opened(app, url)
|
||||
return opened
|
||||
|
||||
|
||||
def open_style_link(event: Click) -> None:
|
||||
def open_style_link(event: Click, *, app: App | None = None) -> None:
|
||||
"""Open the URL from a Rich link style on click, if present.
|
||||
|
||||
Rich `Style(link=...)` embeds OSC 8 terminal hyperlinks, but Textual's
|
||||
@@ -79,14 +217,19 @@ def open_style_link(event: Click) -> None:
|
||||
homograph domains) are blocked and not opened; the event bubbles and a
|
||||
warning is logged and displayed as a Textual notification.
|
||||
|
||||
On success the event is stopped so it does not bubble further. On failure
|
||||
(e.g. no browser available in a headless environment) the error is logged at
|
||||
debug level and the event bubbles normally.
|
||||
On success the event is stopped so it does not bubble further and, unless
|
||||
the user has opted out, a best-effort informational toast reports the URL
|
||||
that was opened. If the browser cannot be launched -- either
|
||||
`webbrowser.open` raises or the backend declines and returns a falsy value
|
||||
-- a warning toast with the URL is shown so it can be copied manually, the
|
||||
failure is logged, and the event bubbles normally.
|
||||
|
||||
Args:
|
||||
event: The Textual click event to inspect.
|
||||
app: App used to post browser-open notifications.
|
||||
"""
|
||||
url = event.style.link
|
||||
notify_app = _event_app(event, app)
|
||||
url = _style_url(event.style)
|
||||
if not url:
|
||||
return
|
||||
|
||||
@@ -94,23 +237,25 @@ def open_style_link(event: Click) -> None:
|
||||
if not safety.safe:
|
||||
detail = safety.warnings[0] if safety.warnings else "Suspicious URL"
|
||||
logger.warning("Blocked suspicious URL: %s (%s)", url, detail)
|
||||
try:
|
||||
app = getattr(event, "app", None)
|
||||
notify = getattr(app, "notify", None)
|
||||
if callable(notify):
|
||||
safe_url = strip_dangerous_unicode(url)
|
||||
notify(
|
||||
f"Blocked suspicious URL: {safe_url}\n{detail}",
|
||||
severity="warning",
|
||||
markup=False,
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
logger.debug("Could not send URL-blocked notification", exc_info=True)
|
||||
_notify(
|
||||
notify_app,
|
||||
f"Blocked suspicious URL: {strip_dangerous_unicode(url)}\n{detail}",
|
||||
severity="warning",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
logger.debug("Could not open browser for URL: %s", url, exc_info=True)
|
||||
opened = webbrowser.open(url)
|
||||
except (webbrowser.Error, OSError) as exc:
|
||||
logger.warning("webbrowser.open failed for %s: %s", url, exc, exc_info=True)
|
||||
opened = False
|
||||
if not opened:
|
||||
_notify(
|
||||
notify_app,
|
||||
f"Could not open a browser. URL: {strip_dangerous_unicode(url)}",
|
||||
severity="warning",
|
||||
timeout=8,
|
||||
)
|
||||
return
|
||||
_notify_url_opened(notify_app, url)
|
||||
event.stop()
|
||||
|
||||
@@ -43,7 +43,11 @@ from deepagents_code.widgets._js_eval_display import (
|
||||
JsEvalStdout,
|
||||
parse_js_eval_blocks,
|
||||
)
|
||||
from deepagents_code.widgets._links import event_targets_link, open_style_link
|
||||
from deepagents_code.widgets._links import (
|
||||
event_targets_link,
|
||||
open_checked_url_async,
|
||||
open_style_link,
|
||||
)
|
||||
from deepagents_code.widgets.diff import compose_diff_lines
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -718,7 +722,7 @@ class AssistantMessage(Vertical):
|
||||
"""
|
||||
from textual.widgets import Markdown
|
||||
|
||||
yield Markdown("", id="assistant-content")
|
||||
yield Markdown("", id="assistant-content", open_links=False)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Store reference to markdown widget."""
|
||||
@@ -743,6 +747,11 @@ class AssistantMessage(Vertical):
|
||||
if self._markdown is not None:
|
||||
self._markdown.styles.pointer = "text"
|
||||
|
||||
async def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None:
|
||||
"""Open Markdown links with the same toast feedback as style links."""
|
||||
event.stop()
|
||||
await open_checked_url_async(event.href, app=self.app, notify_on_success=True)
|
||||
|
||||
def _get_markdown(self) -> Markdown:
|
||||
"""Get the markdown widget, querying if not cached.
|
||||
|
||||
|
||||
@@ -3885,7 +3885,10 @@ class TestTraceCommand:
|
||||
mock_open.assert_called_once_with(expected_url)
|
||||
app_msgs = app.query(AppMessage)
|
||||
rendered = "\n".join(str(w._content) for w in app_msgs)
|
||||
assert f"Opening tracing project 'proj':\n{expected_url}" in rendered
|
||||
assert (
|
||||
f"Opening tracing project 'proj' in default browser:\n{expected_url}"
|
||||
in rendered
|
||||
)
|
||||
|
||||
async def test_trace_warns_when_no_messages_sent(self) -> None:
|
||||
"""Should append a note when the thread has no messages yet."""
|
||||
@@ -4225,7 +4228,7 @@ class TestTraceCommand:
|
||||
app_msgs = app.query(AppMessage)
|
||||
rendered = "\n".join(str(w._content) for w in app_msgs)
|
||||
assert (
|
||||
"Opening tracing project 'proj':\n"
|
||||
"Opening tracing project 'proj' in default browser:\n"
|
||||
"https://smith.langchain.com/t/test-thread-123"
|
||||
) in rendered
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
"""Unit tests for style-link click handling."""
|
||||
|
||||
import os
|
||||
import webbrowser
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from deepagents_code.widgets._links import event_targets_link, open_style_link
|
||||
from deepagents_code._env_vars import SHOW_URL_OPEN_TOAST
|
||||
from deepagents_code.widgets._links import (
|
||||
event_targets_link,
|
||||
open_checked_url_async,
|
||||
open_style_link,
|
||||
open_url_async,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
|
||||
def _move_event(
|
||||
@@ -43,15 +55,231 @@ def _event_with_link(url: str) -> SimpleNamespace:
|
||||
)
|
||||
|
||||
|
||||
def _event_with_meta(meta: dict[str, str]) -> SimpleNamespace:
|
||||
"""Build a minimal click event whose URL comes from style metadata."""
|
||||
return SimpleNamespace(
|
||||
style=SimpleNamespace(link=None, meta=meta),
|
||||
app=SimpleNamespace(notify=MagicMock()),
|
||||
stop=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
def test_open_style_link_opens_browser_and_stops_event() -> None:
|
||||
"""Safe links should open and stop event propagation."""
|
||||
"""Safe links should open, toast confirmation, and stop event propagation."""
|
||||
event = _event_with_link("https://example.com")
|
||||
|
||||
with patch("deepagents_code.widgets._links.webbrowser.open") as mock_open:
|
||||
with (
|
||||
patch.dict(os.environ, {SHOW_URL_OPEN_TOAST: "1"}),
|
||||
patch("deepagents_code.widgets._links.webbrowser.open") as mock_open,
|
||||
):
|
||||
mock_open.return_value = True
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
mock_open.assert_called_once_with("https://example.com")
|
||||
event.stop.assert_called_once()
|
||||
event.app.notify.assert_called_once()
|
||||
args, kwargs = event.app.notify.call_args
|
||||
assert args[0] == "Opening URL in default browser: https://example.com"
|
||||
assert kwargs["severity"] == "information"
|
||||
assert kwargs["markup"] is False
|
||||
assert kwargs["timeout"] == 4
|
||||
|
||||
|
||||
def test_open_style_link_stops_event_even_if_toast_fails() -> None:
|
||||
"""A failing success toast must not turn a successful open into a bubble."""
|
||||
event = _event_with_link("https://example.com")
|
||||
event.app.notify.side_effect = TypeError("notify signature mismatch")
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {SHOW_URL_OPEN_TOAST: "1"}),
|
||||
patch("deepagents_code.widgets._links.webbrowser.open", return_value=True),
|
||||
):
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
event.app.notify.assert_called_once()
|
||||
event.stop.assert_called_once()
|
||||
|
||||
|
||||
def test_open_style_link_notifies_from_event_widget_app() -> None:
|
||||
"""Real Textual click events expose the app through `event.widget.app`."""
|
||||
notify = MagicMock()
|
||||
event = SimpleNamespace(
|
||||
style=SimpleNamespace(link="https://example.com", meta={}),
|
||||
widget=SimpleNamespace(app=SimpleNamespace(notify=notify)),
|
||||
stop=MagicMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {SHOW_URL_OPEN_TOAST: "1"}),
|
||||
patch("deepagents_code.widgets._links.webbrowser.open", return_value=True),
|
||||
):
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
notify.assert_called_once()
|
||||
args, kwargs = notify.call_args
|
||||
assert args[0] == "Opening URL in default browser: https://example.com"
|
||||
assert kwargs["severity"] == "information"
|
||||
assert kwargs["markup"] is False
|
||||
event.stop.assert_called_once()
|
||||
|
||||
|
||||
async def test_open_url_async_can_toast_on_success() -> None:
|
||||
"""Async link opening can opt into the same success toast."""
|
||||
notify = MagicMock()
|
||||
app = cast("App[None]", SimpleNamespace(notify=notify))
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {SHOW_URL_OPEN_TOAST: "1"}),
|
||||
patch("deepagents_code.widgets._links.webbrowser.open", return_value=True),
|
||||
):
|
||||
opened = await open_url_async(
|
||||
"https://example.com",
|
||||
app=app,
|
||||
notify_on_success=True,
|
||||
)
|
||||
|
||||
assert opened is True
|
||||
notify.assert_called_once()
|
||||
args, kwargs = notify.call_args
|
||||
assert args[0] == "Opening URL in default browser: https://example.com"
|
||||
assert kwargs["severity"] == "information"
|
||||
assert kwargs["markup"] is False
|
||||
|
||||
|
||||
async def test_open_checked_url_async_blocks_suspicious_url() -> None:
|
||||
"""Async checked opening should block suspicious URLs before the browser."""
|
||||
notify = MagicMock()
|
||||
app = cast("App[None]", SimpleNamespace(notify=notify))
|
||||
|
||||
with patch("deepagents_code.widgets._links.webbrowser.open") as mock_open:
|
||||
opened = await open_checked_url_async(
|
||||
"https://example.com/\u200b[admin]",
|
||||
app=app,
|
||||
notify_on_success=True,
|
||||
)
|
||||
|
||||
assert opened is False
|
||||
mock_open.assert_not_called()
|
||||
notify.assert_called_once()
|
||||
args, kwargs = notify.call_args
|
||||
assert "Blocked suspicious URL" in args[0]
|
||||
assert "https://example.com/[admin]" in args[0]
|
||||
assert kwargs["severity"] == "warning"
|
||||
assert kwargs["markup"] is False
|
||||
|
||||
|
||||
async def test_open_url_async_warns_on_failure() -> None:
|
||||
"""Async link opening warns with the URL when the browser declines."""
|
||||
notify = MagicMock()
|
||||
app = cast("App[None]", SimpleNamespace(notify=notify))
|
||||
|
||||
with patch("deepagents_code.widgets._links.webbrowser.open", return_value=False):
|
||||
opened = await open_url_async("https://example.com", app=app)
|
||||
|
||||
assert opened is False
|
||||
notify.assert_called_once()
|
||||
args, kwargs = notify.call_args
|
||||
assert "https://example.com" in args[0]
|
||||
assert kwargs["severity"] == "warning"
|
||||
assert kwargs["markup"] is False
|
||||
|
||||
|
||||
def test_open_style_link_opens_markdown_link_action() -> None:
|
||||
"""Markdown `@click=link(...)` metadata should open like Rich link styles."""
|
||||
event = _event_with_meta({"@click": "link('https://example.com/docs')"})
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {SHOW_URL_OPEN_TOAST: "1"}),
|
||||
patch("deepagents_code.widgets._links.webbrowser.open") as mock_open,
|
||||
):
|
||||
mock_open.return_value = True
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
mock_open.assert_called_once_with("https://example.com/docs")
|
||||
event.stop.assert_called_once()
|
||||
event.app.notify.assert_called_once()
|
||||
|
||||
|
||||
def test_open_style_link_env_can_suppress_success_toast() -> None:
|
||||
"""The env var can disable success toasts while still opening URLs."""
|
||||
event = _event_with_link("https://example.com")
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {SHOW_URL_OPEN_TOAST: "0"}),
|
||||
patch(
|
||||
"deepagents_code.config_manifest.load_config_toml",
|
||||
return_value={"ui": {"show_url_open_toast": True}},
|
||||
),
|
||||
patch("deepagents_code.widgets._links.webbrowser.open", return_value=True),
|
||||
):
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
event.stop.assert_called_once()
|
||||
event.app.notify.assert_not_called()
|
||||
|
||||
|
||||
def test_open_style_link_config_can_suppress_success_toast() -> None:
|
||||
"""The config file can disable success toasts when env is unset."""
|
||||
event = _event_with_link("https://example.com")
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {SHOW_URL_OPEN_TOAST: ""}),
|
||||
patch(
|
||||
"deepagents_code.config_manifest.load_config_toml",
|
||||
return_value={"ui": {"show_url_open_toast": False}},
|
||||
),
|
||||
patch("deepagents_code.widgets._links.webbrowser.open", return_value=True),
|
||||
):
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
event.stop.assert_called_once()
|
||||
event.app.notify.assert_not_called()
|
||||
|
||||
|
||||
def test_open_style_link_warns_when_browser_does_not_open() -> None:
|
||||
"""When the browser backend declines, warn the user and bubble the event."""
|
||||
event = _event_with_link("https://example.com")
|
||||
|
||||
with patch("deepagents_code.widgets._links.webbrowser.open") as mock_open:
|
||||
mock_open.return_value = False
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
mock_open.assert_called_once_with("https://example.com")
|
||||
event.stop.assert_not_called()
|
||||
event.app.notify.assert_called_once()
|
||||
args, kwargs = event.app.notify.call_args
|
||||
assert "https://example.com" in args[0]
|
||||
assert kwargs["severity"] == "warning"
|
||||
assert kwargs["markup"] is False
|
||||
|
||||
|
||||
def test_open_style_link_warns_when_browser_open_raises() -> None:
|
||||
"""A `webbrowser.Error` should warn the user and bubble the event."""
|
||||
event = _event_with_link("https://example.com")
|
||||
|
||||
with patch(
|
||||
"deepagents_code.widgets._links.webbrowser.open",
|
||||
side_effect=webbrowser.Error("no browser backend"),
|
||||
):
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
event.stop.assert_not_called()
|
||||
event.app.notify.assert_called_once()
|
||||
args, kwargs = event.app.notify.call_args
|
||||
assert "https://example.com" in args[0]
|
||||
assert kwargs["severity"] == "warning"
|
||||
assert kwargs["markup"] is False
|
||||
|
||||
|
||||
def test_open_style_link_ignores_malformed_markdown_link_action() -> None:
|
||||
"""Malformed Markdown link metadata should not reach the browser opener."""
|
||||
event = _event_with_meta({"@click": "link(https://example.com)"})
|
||||
|
||||
with patch("deepagents_code.widgets._links.webbrowser.open") as mock_open:
|
||||
open_style_link(event) # ty: ignore
|
||||
|
||||
mock_open.assert_not_called()
|
||||
event.stop.assert_not_called()
|
||||
event.app.notify.assert_not_called()
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from rich.style import Style
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.content import Content
|
||||
from textual.widgets import Markdown
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.input import INPUT_HIGHLIGHT_PATTERN
|
||||
@@ -356,6 +357,56 @@ class TestAssistantMessageLinkPointer:
|
||||
assert msg._markdown is not None
|
||||
assert msg._markdown.styles.pointer == "text"
|
||||
|
||||
async def test_markdown_open_links_is_disabled(self) -> None:
|
||||
"""The app handles Markdown links so it can show URL-opened toasts."""
|
||||
async with _AssistantMessageApp().run_test() as pilot:
|
||||
markdown = pilot.app.query_one("#assistant-content", Markdown)
|
||||
|
||||
assert markdown._open_links is False
|
||||
|
||||
async def test_markdown_link_clicked_uses_checked_toast_helper(self) -> None:
|
||||
"""Clicked Markdown links should use the checked browser/toast helper."""
|
||||
async with _AssistantMessageApp().run_test() as pilot:
|
||||
msg = pilot.app.query_one("#assistant", AssistantMessage)
|
||||
event = SimpleNamespace(href="https://example.com/docs", stop=MagicMock())
|
||||
|
||||
with patch(
|
||||
"deepagents_code.widgets.messages.open_checked_url_async",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as mock_open:
|
||||
await msg.on_markdown_link_clicked(event) # ty: ignore
|
||||
|
||||
event.stop.assert_called_once()
|
||||
mock_open.assert_awaited_once_with(
|
||||
"https://example.com/docs",
|
||||
app=pilot.app,
|
||||
notify_on_success=True,
|
||||
)
|
||||
|
||||
async def test_markdown_link_clicked_blocks_suspicious_url(self) -> None:
|
||||
"""Markdown links should apply the same URL safety check as style links."""
|
||||
async with _AssistantMessageApp().run_test() as pilot:
|
||||
msg = pilot.app.query_one("#assistant", AssistantMessage)
|
||||
event = SimpleNamespace(
|
||||
href="https://example.com/\u200b[admin]",
|
||||
stop=MagicMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(pilot.app, "notify") as notify,
|
||||
patch("deepagents_code.widgets._links.webbrowser.open") as mock_open,
|
||||
):
|
||||
await msg.on_markdown_link_clicked(event) # ty: ignore
|
||||
|
||||
event.stop.assert_called_once()
|
||||
mock_open.assert_not_called()
|
||||
notify.assert_called_once()
|
||||
args, kwargs = notify.call_args
|
||||
assert "Blocked suspicious URL" in args[0]
|
||||
assert "https://example.com/[admin]" in args[0]
|
||||
assert kwargs["severity"] == "warning"
|
||||
assert kwargs["markup"] is False
|
||||
|
||||
def test_mouse_move_before_mount_is_noop(self) -> None:
|
||||
"""Hovering before mount (no markdown widget yet) must not raise."""
|
||||
msg = AssistantMessage()
|
||||
|
||||
Reference in New Issue
Block a user