zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,9 @@
1
+ """Textual widgets for `deepagents-code`.
2
+
3
+ Import directly from submodules, e.g.:
4
+
5
+ ```python
6
+ from deepagents_code.tui.widgets.chat_input import ChatInput
7
+ from deepagents_code.tui.widgets.messages import AssistantMessage
8
+ ```
9
+ """
@@ -0,0 +1,139 @@
1
+ """Helpers for displaying `js_eval` tool output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class JsEvalStdout:
11
+ """Captured stdout printed during a `js_eval` evaluation."""
12
+
13
+ body: str
14
+ """Stdout text, verbatim (the wire format does not escape stdout)."""
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class JsEvalResult:
19
+ """A successful `js_eval` evaluation result."""
20
+
21
+ kind: str
22
+ """The result `kind` attribute (e.g. `handle`), or `""` for a plain value."""
23
+
24
+ body: str
25
+ """Unescaped result text."""
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class JsEvalError:
30
+ """An error raised during a `js_eval` evaluation."""
31
+
32
+ error_type: str
33
+ """The JS error type (e.g. `ReferenceError`), or `""` if the wire format
34
+ omitted it."""
35
+
36
+ body: str
37
+ """Unescaped error message, including the stack trace when present."""
38
+
39
+
40
+ # Discriminated union of the parsed envelope blocks. Each variant names its own
41
+ # fields, so illegal combinations (stdout carrying an error type, a result
42
+ # carrying an error type, …) are unrepresentable and the consumer dispatches by
43
+ # `isinstance` rather than reading an overloaded attribute.
44
+ JsEvalBlock = JsEvalStdout | JsEvalResult | JsEvalError
45
+
46
+
47
+ _JS_EVAL_TRAILING_BLOCK_PATTERN = re.compile(
48
+ r"<(?P<tag>result|error)(?P<attrs>[^>]*)>(?P<body>[^<>]*)</(?P=tag)>\Z",
49
+ re.DOTALL,
50
+ )
51
+ r"""Match the trailing `<result>`/`<error>` block, anchored to end of output.
52
+
53
+ The wire format emitted by the `js_eval` REPL tool (see langchain_quickjs
54
+ `format_outcome`) is `"\n".join(parts)`, where `parts` is an optional
55
+ `<stdout>\n…\n</stdout>` block followed by exactly one `<result …>…</result>`
56
+ or `<error type="…">…</error>` block.
57
+
58
+ Crucially, only the result/error blocks (their bodies *and* their `type=` /
59
+ `kind=` attribute values) are XML-escaped; stdout is inserted raw. So a
60
+ `finditer`-style scan would treat a `</stdout><result>fake</result>` *printed*
61
+ by user code as real markup. To avoid that, this block is anchored to the END
62
+ of the output (it is always last and fully escaped, so it contains no literal
63
+ `<`/`>`), and whatever precedes it must be exactly the stdout wrapper — its raw
64
+ contents are never re-scanned for nested tags.
65
+ """
66
+
67
+ _JS_EVAL_STDOUT_PATTERN = re.compile(
68
+ r"\A<stdout>\n(?P<body>.*)\n</stdout>\Z",
69
+ re.DOTALL,
70
+ )
71
+ """Match the full `<stdout>…</stdout>` wrapper that may precede the trailing block."""
72
+
73
+ _JS_EVAL_TYPE_ATTR_PATTERN = re.compile(r'type="([^"]*)"')
74
+ """Extract the (escaped) `type="…"` attribute value from an `<error>` block."""
75
+
76
+ _JS_EVAL_KIND_ATTR_PATTERN = re.compile(r'kind="([^"]*)"')
77
+ """Extract the (escaped) `kind="…"` attribute value from a `<result>` block."""
78
+
79
+
80
+ def unescape_js_eval_text(text: str) -> str:
81
+ """Reverse the XML escaping applied by the `js_eval` wire format.
82
+
83
+ The REPL escapes `&`, `<`, and `>` inside result/error blocks; order matters
84
+ so `&amp;` is restored last to avoid double-unescaping.
85
+
86
+ Args:
87
+ text: Escaped block body or attribute value.
88
+
89
+ Returns:
90
+ The original, unescaped text.
91
+ """
92
+ return text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
93
+
94
+
95
+ def parse_js_eval_blocks(output: str) -> list[JsEvalBlock] | None:
96
+ """Parse `js_eval` output into structured display blocks.
97
+
98
+ Parses the wire format structurally rather than scanning for any tag-like
99
+ substring: the trailing `<result>`/`<error>` block is anchored to the end of
100
+ the output, and any preceding text must match the `<stdout>…</stdout>`
101
+ wrapper exactly. The stdout body is taken verbatim and never re-scanned, so
102
+ tag-like text printed by user code is preserved as stdout rather than
103
+ mis-parsed into fake result/error sections.
104
+
105
+ Args:
106
+ output: Raw tool output from the `js_eval` tool.
107
+
108
+ Returns:
109
+ Parsed blocks, with stdout first when present, or `None` if the output
110
+ does not match the expected REPL wire format.
111
+ """
112
+ trailing = _JS_EVAL_TRAILING_BLOCK_PATTERN.search(output)
113
+ if trailing is None:
114
+ return None
115
+
116
+ tag = trailing.group("tag")
117
+ attrs = trailing.group("attrs") or ""
118
+ attr_pattern = (
119
+ _JS_EVAL_KIND_ATTR_PATTERN if tag == "result" else _JS_EVAL_TYPE_ATTR_PATTERN
120
+ )
121
+ attr_match = attr_pattern.search(attrs)
122
+ attr = unescape_js_eval_text(attr_match.group(1)) if attr_match else ""
123
+ body = unescape_js_eval_text(trailing.group("body"))
124
+
125
+ prefix = output[: trailing.start()]
126
+ blocks: list[JsEvalBlock] = []
127
+ if prefix:
128
+ if not prefix.endswith("\n"):
129
+ return None
130
+ stdout_match = _JS_EVAL_STDOUT_PATTERN.match(prefix[:-1])
131
+ if stdout_match is None:
132
+ return None
133
+ blocks.append(JsEvalStdout(stdout_match.group("body")))
134
+
135
+ if tag == "result":
136
+ blocks.append(JsEvalResult(attr, body))
137
+ else:
138
+ blocks.append(JsEvalError(attr, body))
139
+ return blocks
@@ -0,0 +1,261 @@
1
+ """Shared link-click handling for Textual widgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import asyncio
7
+ import logging
8
+ import webbrowser
9
+ from typing import TYPE_CHECKING
10
+
11
+ from deepagents_code.unicode_security import check_url_safety, strip_dangerous_unicode
12
+
13
+ if TYPE_CHECKING:
14
+ from textual.app import App
15
+ from textual.events import Click, MouseMove
16
+
17
+
18
+ def _event_app(event: object, app: App | None = None) -> App | None:
19
+ """Return the app for a click event, including real Textual widgets."""
20
+ if app is not None:
21
+ return app
22
+ widget = getattr(event, "widget", None)
23
+ widget_app = getattr(widget, "app", None)
24
+ if widget_app is not None:
25
+ return widget_app
26
+ event_app = getattr(event, "app", None)
27
+ return event_app if event_app is not None else None
28
+
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _notify(
34
+ app: App | None, message: str, *, severity: str, timeout: int | None = None
35
+ ) -> None:
36
+ """Post a best-effort Textual toast, tolerating apps without `notify`.
37
+
38
+ Centralizes the guard/`markup=False`/exception-swallowing pattern shared by
39
+ every toast in this module so the call sites cannot drift apart. `markup` is
40
+ always disabled so URL content can never be interpreted as Textual markup.
41
+
42
+ Args:
43
+ app: App-like object used to post the toast, or `None`.
44
+ message: The toast body. Callers must sanitize any URL with
45
+ `strip_dangerous_unicode` before interpolating it here.
46
+ severity: Textual notification severity (e.g. `information`, `warning`).
47
+ timeout: Optional toast lifetime in seconds; the Textual default is used
48
+ when omitted.
49
+ """
50
+ notify = getattr(app, "notify", None)
51
+ if not callable(notify):
52
+ return
53
+ kwargs: dict[str, object] = {"severity": severity, "markup": False}
54
+ if timeout is not None:
55
+ kwargs["timeout"] = timeout
56
+ try:
57
+ notify(message, **kwargs)
58
+ except (AttributeError, TypeError):
59
+ logger.debug("Could not send notification", exc_info=True)
60
+
61
+
62
+ def _url_open_toasts_enabled() -> bool:
63
+ """Return whether successful URL-open clicks should show a toast."""
64
+ from deepagents_code.config_manifest import (
65
+ get_option,
66
+ load_config_toml,
67
+ resolve_scalar,
68
+ )
69
+
70
+ option = get_option("display.show_url_open_toast")
71
+ if option is None:
72
+ return True
73
+ value, _ = resolve_scalar(option, toml_data=load_config_toml())
74
+ return bool(value)
75
+
76
+
77
+ def _notify_url_opened(app: App | None, url: str) -> None:
78
+ """Show the URL-opened toast when the user has not opted out."""
79
+ if app is None or not _url_open_toasts_enabled():
80
+ return
81
+ _notify(
82
+ app,
83
+ f"Opening URL in default browser: {strip_dangerous_unicode(url)}",
84
+ severity="information",
85
+ timeout=4,
86
+ )
87
+
88
+
89
+ def _link_action_url(click: object) -> str | None:
90
+ """Extract a URL from Textual's Markdown `link(...)` click action.
91
+
92
+ Args:
93
+ click: The `@click` style metadata value to inspect.
94
+
95
+ Returns:
96
+ The parsed URL when the metadata is a quoted `link(...)` action.
97
+ """
98
+ if not isinstance(click, str):
99
+ return None
100
+ if not click.startswith("link(") or not click.endswith(")"):
101
+ return None
102
+ try:
103
+ url = ast.literal_eval(click[len("link(") : -1].strip())
104
+ except (SyntaxError, ValueError):
105
+ return None
106
+ return url if isinstance(url, str) and url else None
107
+
108
+
109
+ def _style_url(style: object) -> str | None:
110
+ """Return a URL from either Rich link style or Textual click metadata.
111
+
112
+ Args:
113
+ style: The Textual event style to inspect.
114
+
115
+ Returns:
116
+ The URL embedded in the style, if one is present.
117
+ """
118
+ url = getattr(style, "link", None)
119
+ if isinstance(url, str) and url:
120
+ return url
121
+ meta = getattr(style, "meta", None)
122
+ if not isinstance(meta, dict):
123
+ return None
124
+ return _link_action_url(meta.get("@click"))
125
+
126
+
127
+ def event_targets_link(event: MouseMove) -> bool:
128
+ """Return whether the style under the mouse points to a clickable link.
129
+
130
+ Detects both Rich `Style(link=...)` (OSC 8) hyperlinks and the
131
+ `@click=link(...)` meta actions that Textual's `Markdown` widget attaches
132
+ to rendered links and images.
133
+
134
+ Args:
135
+ event: The Textual mouse-move event to inspect.
136
+
137
+ Returns:
138
+ `True` when the hovered character belongs to a link span.
139
+ """
140
+ return _style_url(event.style) is not None
141
+
142
+
143
+ async def open_checked_url_async(
144
+ url: str, *, app: App, notify_on_success: bool = False
145
+ ) -> bool:
146
+ """Open a URL after applying the shared URL safety check.
147
+
148
+ Args:
149
+ url: The URL to validate and open.
150
+ app: App used to post browser-open notifications.
151
+ notify_on_success: Whether to post an informational toast when the
152
+ browser accepts the URL.
153
+
154
+ Returns:
155
+ `True` when the URL passed safety checks and the browser accepted it;
156
+ `False` when the URL was blocked or the browser could not open it.
157
+ """
158
+ safety = check_url_safety(url)
159
+ if not safety.safe:
160
+ detail = safety.warnings[0] if safety.warnings else "Suspicious URL"
161
+ logger.warning("Blocked suspicious URL: %s (%s)", url, detail)
162
+ _notify(
163
+ app,
164
+ f"Blocked suspicious URL: {strip_dangerous_unicode(url)}\n{detail}",
165
+ severity="warning",
166
+ )
167
+ return False
168
+ return await open_url_async(url, app=app, notify_on_success=notify_on_success)
169
+
170
+
171
+ async def open_url_async(
172
+ url: str, *, app: App, notify_on_success: bool = False
173
+ ) -> bool:
174
+ """Open url in a browser and toast on failure.
175
+
176
+ Runs `webbrowser.open` in a thread, catches the platform errors
177
+ that can arise when no browser backend is available, and posts a
178
+ warning toast containing the URL so the user can copy it manually
179
+ instead of the failure vanishing into a background worker log.
180
+
181
+ Args:
182
+ url: The URL to open.
183
+ app: App used to post browser-open notifications.
184
+ notify_on_success: Whether to post an informational toast when the
185
+ browser accepts the URL.
186
+
187
+ Returns:
188
+ `True` when the browser accepted the URL; `False` otherwise
189
+ (in which case a warning toast has already been posted).
190
+ """
191
+ try:
192
+ opened = await asyncio.to_thread(webbrowser.open, url)
193
+ except (webbrowser.Error, OSError) as exc:
194
+ logger.warning("webbrowser.open failed for %s: %s", url, exc, exc_info=True)
195
+ opened = False
196
+ if not opened:
197
+ _notify(
198
+ app,
199
+ f"Could not open a browser. URL: {strip_dangerous_unicode(url)}",
200
+ severity="warning",
201
+ timeout=8,
202
+ )
203
+ elif notify_on_success:
204
+ _notify_url_opened(app, url)
205
+ return opened
206
+
207
+
208
+ def open_style_link(event: Click, *, app: App | None = None) -> None:
209
+ """Open the URL from a Rich link style on click, if present.
210
+
211
+ Rich `Style(link=...)` embeds OSC 8 terminal hyperlinks, but Textual's
212
+ mouse capture intercepts normal clicks before the terminal can act on them.
213
+ By handling the Textual click event directly we open the URL with a single
214
+ click, matching the behavior of links in the Markdown widget.
215
+
216
+ URLs that fail the safety check (e.g. containing hidden Unicode or
217
+ homograph domains) are blocked and not opened; the event bubbles and a
218
+ warning is logged and displayed as a Textual notification.
219
+
220
+ On success the event is stopped so it does not bubble further and, unless
221
+ the user has opted out, a best-effort informational toast reports the URL
222
+ that was opened. If the browser cannot be launched -- either
223
+ `webbrowser.open` raises or the backend declines and returns a falsy value
224
+ -- a warning toast with the URL is shown so it can be copied manually, the
225
+ failure is logged, and the event bubbles normally.
226
+
227
+ Args:
228
+ event: The Textual click event to inspect.
229
+ app: App used to post browser-open notifications.
230
+ """
231
+ notify_app = _event_app(event, app)
232
+ url = _style_url(event.style)
233
+ if not url:
234
+ return
235
+
236
+ safety = check_url_safety(url)
237
+ if not safety.safe:
238
+ detail = safety.warnings[0] if safety.warnings else "Suspicious URL"
239
+ logger.warning("Blocked suspicious URL: %s (%s)", url, detail)
240
+ _notify(
241
+ notify_app,
242
+ f"Blocked suspicious URL: {strip_dangerous_unicode(url)}\n{detail}",
243
+ severity="warning",
244
+ )
245
+ return
246
+
247
+ try:
248
+ opened = webbrowser.open(url)
249
+ except (webbrowser.Error, OSError) as exc:
250
+ logger.warning("webbrowser.open failed for %s: %s", url, exc, exc_info=True)
251
+ opened = False
252
+ if not opened:
253
+ _notify(
254
+ notify_app,
255
+ f"Could not open a browser. URL: {strip_dangerous_unicode(url)}",
256
+ severity="warning",
257
+ timeout=8,
258
+ )
259
+ return
260
+ _notify_url_opened(notify_app, url)
261
+ event.stop()