soothe-cli 0.1.0__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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,447 @@
1
+ """Update lifecycle for `soothe`.
2
+
3
+ Handles version checking against PyPI (with caching), install-method detection,
4
+ auto-upgrade execution, config-driven opt-in/out, and "what's new" tracking.
5
+
6
+ Most public entry points absorb errors and return sentinel values.
7
+ `set_auto_update` raises on write failures so callers can surface
8
+ actionable feedback.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Literal
22
+
23
+ from packaging.version import InvalidVersion, Version
24
+ from soothe_sdk import SOOTHE_HOME
25
+
26
+ from soothe_cli.tui._version import PYPI_URL, USER_AGENT, __version__
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Config directory for version tracking
31
+
32
+ _CONFIG_DIR = Path(SOOTHE_HOME) / "config"
33
+ CACHE_FILE: Path = _CONFIG_DIR / "latest_version.json"
34
+ SEEN_VERSION_FILE: Path = _CONFIG_DIR / "seen_version.json"
35
+ CACHE_TTL = 86_400 # 24 hours
36
+
37
+ InstallMethod = Literal["uv", "pip", "brew", "unknown"]
38
+
39
+ _UPGRADE_COMMANDS: dict[InstallMethod, str] = {
40
+ "uv": "uv tool upgrade soothe",
41
+ "brew": "brew upgrade soothe",
42
+ "pip": "pip install --upgrade soothe",
43
+ }
44
+ """Upgrade commands keyed by install method.
45
+
46
+ `perform_upgrade` runs only the command matching the detected install method;
47
+ no fallback chain.
48
+ """
49
+
50
+ _UPGRADE_TIMEOUT = 120 # seconds
51
+
52
+
53
+ def _parse_version(v: str) -> Version:
54
+ """Parse a PEP 440 version string into a comparable `Version` object.
55
+
56
+ Supports stable (`1.2.3`) and pre-release (`1.2.3a1`, `1.2.3rc2`) versions.
57
+
58
+ Args:
59
+ v: Version string like `'1.2.3'` or `'1.2.3a1'`.
60
+
61
+ Returns:
62
+ A `packaging.version.Version` instance.
63
+ """
64
+ return Version(v.strip()) # raises InvalidVersion for non-PEP 440 strings
65
+
66
+
67
+ def _latest_from_releases(
68
+ releases: dict[str, list[object]],
69
+ *,
70
+ include_prereleases: bool,
71
+ ) -> str | None:
72
+ """Pick the newest version from a PyPI `releases` mapping.
73
+
74
+ Skips versions with no uploaded files (empty entries) and, when
75
+ *include_prereleases* is `False`, skips pre-release versions.
76
+
77
+ Args:
78
+ releases: The `releases` dict from the PyPI JSON API.
79
+ include_prereleases: Whether to consider pre-release versions.
80
+
81
+ Returns:
82
+ The highest matching version string, or `None` if none qualify.
83
+ """
84
+ best: Version | None = None
85
+ best_str: str | None = None
86
+ for ver_str, files in releases.items():
87
+ if not files:
88
+ continue
89
+ try:
90
+ ver = Version(ver_str)
91
+ except InvalidVersion:
92
+ logger.debug("Skipping unparseable release key: %s", ver_str)
93
+ continue
94
+ if not include_prereleases and ver.is_prerelease:
95
+ continue
96
+ if best is None or ver > best:
97
+ best = ver
98
+ best_str = ver_str
99
+ return best_str
100
+
101
+
102
+ def get_latest_version(
103
+ *,
104
+ bypass_cache: bool = False,
105
+ include_prereleases: bool = False,
106
+ ) -> str | None:
107
+ """Fetch the latest soothe version from PyPI, with caching.
108
+
109
+ Results are cached to `CACHE_FILE` to avoid repeated network calls.
110
+ The cache stores both the latest stable and pre-release versions so a
111
+ single PyPI request serves both code paths.
112
+
113
+ Args:
114
+ bypass_cache: Skip the cache and always hit PyPI.
115
+ include_prereleases: When `True`, consider pre-release versions
116
+ (alpha, beta, rc). Stable users should leave this `False`.
117
+
118
+ Returns:
119
+ The latest version string, or `None` on any failure.
120
+ """
121
+ cache_key = "version_prerelease" if include_prereleases else "version"
122
+
123
+ try:
124
+ if not bypass_cache and CACHE_FILE.exists():
125
+ data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
126
+ fresh = time.time() - data.get("checked_at", 0) < CACHE_TTL
127
+ if fresh and cache_key in data:
128
+ return data[cache_key]
129
+ except (OSError, json.JSONDecodeError, TypeError):
130
+ logger.debug("Failed to read update-check cache", exc_info=True)
131
+
132
+ try:
133
+ import requests
134
+ except ImportError:
135
+ logger.warning(
136
+ "requests package not installed — update checks disabled. Install with: pip install requests"
137
+ )
138
+ return None
139
+
140
+ try:
141
+ resp = requests.get(
142
+ PYPI_URL,
143
+ headers={"User-Agent": USER_AGENT},
144
+ timeout=3,
145
+ )
146
+ resp.raise_for_status()
147
+ payload = resp.json()
148
+ stable: str = payload["info"]["version"]
149
+ releases: dict[str, list[object]] = payload.get("releases", {})
150
+ if not releases:
151
+ logger.debug("PyPI response missing or empty 'releases' key")
152
+ prerelease = _latest_from_releases(releases, include_prereleases=True)
153
+ except (requests.RequestException, OSError, KeyError, json.JSONDecodeError):
154
+ logger.debug("Failed to fetch latest version from PyPI", exc_info=True)
155
+ return None
156
+
157
+ try:
158
+ CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
159
+ CACHE_FILE.write_text(
160
+ json.dumps(
161
+ {
162
+ "version": stable,
163
+ "version_prerelease": prerelease,
164
+ "checked_at": time.time(),
165
+ }
166
+ ),
167
+ encoding="utf-8",
168
+ )
169
+ except OSError:
170
+ logger.debug("Failed to write update-check cache", exc_info=True)
171
+
172
+ return prerelease if include_prereleases else stable
173
+
174
+
175
+ def is_update_available(*, bypass_cache: bool = False) -> tuple[bool, str | None]:
176
+ """Check whether a newer version of soothe is available.
177
+
178
+ When the installed version is a pre-release (e.g. `0.0.35a1`),
179
+ pre-release versions on PyPI are included in the comparison so alpha
180
+ testers are notified of newer alphas and the eventual stable release.
181
+ Stable installs only compare against stable PyPI releases.
182
+
183
+ Args:
184
+ bypass_cache: Skip the cache and always hit PyPI.
185
+
186
+ Returns:
187
+ A `(available, latest)` tuple.
188
+
189
+ `available` is `True` when the PyPI version is strictly newer than
190
+ the installed version; `latest` is the version string (or `None`
191
+ when the check fails).
192
+ """
193
+ try:
194
+ installed = _parse_version(__version__)
195
+ except InvalidVersion:
196
+ logger.warning(
197
+ "Installed version %r is not PEP 440 compliant; update checks disabled for this install",
198
+ __version__,
199
+ )
200
+ return False, None
201
+
202
+ include_prereleases = installed.is_prerelease
203
+ latest = get_latest_version(
204
+ bypass_cache=bypass_cache,
205
+ include_prereleases=include_prereleases,
206
+ )
207
+ if latest is None:
208
+ return False, None
209
+
210
+ try:
211
+ if _parse_version(latest) > installed:
212
+ return True, latest
213
+ except InvalidVersion:
214
+ logger.debug("Failed to compare versions", exc_info=True)
215
+
216
+ return False, None
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Install method detection
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ def detect_install_method() -> InstallMethod:
225
+ """Detect how `soothe` was installed.
226
+
227
+ Checks `sys.prefix` against known paths for uv and Homebrew.
228
+
229
+ Returns:
230
+ The detected install method: `'uv'`, `'brew'`, `'pip'`, or `'unknown'`
231
+ (editable/dev installs).
232
+ """
233
+ from soothe_cli.tui.config import _is_editable_install
234
+
235
+ prefix = sys.prefix
236
+ # uv tool installs live under ~/.local/share/uv/tools/
237
+ if "/uv/tools/" in prefix or "\\uv\\tools\\" in prefix:
238
+ return "uv"
239
+ # Homebrew prefixes
240
+ if any(prefix.startswith(p) for p in ("/opt/homebrew", "/usr/local/Cellar", "/home/linuxbrew")):
241
+ return "brew"
242
+ # Editable / dev installs — don't auto-upgrade
243
+ if _is_editable_install():
244
+ return "unknown"
245
+ return "pip"
246
+
247
+
248
+ def upgrade_command(method: InstallMethod | None = None) -> str:
249
+ """Return the shell command to upgrade `soothe`.
250
+
251
+ Falls back to the pip command for unrecognized install methods.
252
+
253
+ Args:
254
+ method: Install method override.
255
+
256
+ Auto-detected if `None`.
257
+ """
258
+ if method is None:
259
+ method = detect_install_method()
260
+ return _UPGRADE_COMMANDS.get(method, _UPGRADE_COMMANDS["pip"])
261
+
262
+
263
+ async def perform_upgrade() -> tuple[bool, str]:
264
+ """Attempt to upgrade `soothe` using the detected install method.
265
+
266
+ Only tries the detected method — does not fall back to other package
267
+ managers to avoid cross-environment contamination.
268
+
269
+ Returns:
270
+ `(success, output)` — *output* is the combined stdout/stderr.
271
+ """
272
+ method = detect_install_method()
273
+ if method == "unknown":
274
+ return False, "Editable install detected — skipping auto-update."
275
+
276
+ cmd = _UPGRADE_COMMANDS.get(method)
277
+ if cmd is None:
278
+ return False, f"No upgrade command for install method: {method}"
279
+
280
+ # Skip brew if binary not on PATH
281
+ if method == "brew" and not shutil.which("brew"):
282
+ return False, "brew not found on PATH."
283
+
284
+ try:
285
+ proc = await asyncio.create_subprocess_shell(
286
+ cmd,
287
+ stdout=asyncio.subprocess.PIPE,
288
+ stderr=asyncio.subprocess.PIPE,
289
+ stdin=asyncio.subprocess.DEVNULL,
290
+ )
291
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_UPGRADE_TIMEOUT)
292
+ output = (stdout or b"").decode() + (stderr or b"").decode()
293
+ if proc.returncode == 0:
294
+ return True, output.strip()
295
+ logger.warning(
296
+ "Upgrade via %s exited with code %d: %s",
297
+ method,
298
+ proc.returncode,
299
+ output.strip(),
300
+ )
301
+ return False, output.strip()
302
+ except TimeoutError:
303
+ proc.kill()
304
+ await proc.wait()
305
+ msg = f"Upgrade command timed out after {_UPGRADE_TIMEOUT}s: {cmd}"
306
+ logger.warning(msg)
307
+ return False, msg
308
+ except OSError:
309
+ logger.warning("Failed to execute upgrade command: %s", cmd, exc_info=True)
310
+ return False, f"Failed to execute: {cmd}"
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Config helpers
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def is_update_check_enabled() -> bool:
319
+ """Return whether update checks are enabled.
320
+
321
+ Checks `SOOTHE_NO_UPDATE_CHECK` env var and the `[update].check` key
322
+ in `config.yml`.
323
+
324
+ Defaults to enabled.
325
+ """
326
+ if os.environ.get("SOOTHE_NO_UPDATE_CHECK"):
327
+ return False
328
+ return _read_update_config().get("check", True)
329
+
330
+
331
+ def is_auto_update_enabled() -> bool:
332
+ """Return whether auto-update is enabled.
333
+
334
+ Opt-in via `SOOTHE_AUTO_UPDATE=1` env var or
335
+ `[update].auto_update = true` in `config.yml`.
336
+
337
+ Defaults to `False`.
338
+
339
+ Always disabled for editable installs.
340
+ """
341
+ from soothe_cli.tui.config import _is_editable_install
342
+
343
+ if _is_editable_install():
344
+ return False
345
+ if os.environ.get("SOOTHE_AUTO_UPDATE", "").lower() in {"1", "true", "yes"}:
346
+ return True
347
+ return _read_update_config().get("auto_update", False)
348
+
349
+
350
+ def set_auto_update(enabled: bool) -> None:
351
+ """Persist the auto-update preference to `config.yml`.
352
+
353
+ Writes `[update].auto_update` so the setting survives across sessions.
354
+
355
+ Args:
356
+ enabled: Whether auto-update should be enabled.
357
+ """
358
+ import contextlib
359
+ import tempfile
360
+
361
+ import yaml
362
+
363
+ config_path = Path(SOOTHE_HOME) / "config" / "config.yml"
364
+ config_path.parent.mkdir(parents=True, exist_ok=True)
365
+
366
+ if config_path.exists():
367
+ with config_path.open("r") as f:
368
+ data = yaml.safe_load(f) or {}
369
+ else:
370
+ data = {}
371
+
372
+ if "update" not in data:
373
+ data["update"] = {}
374
+ data["update"]["auto_update"] = enabled
375
+
376
+ fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
377
+ try:
378
+ with os.fdopen(fd, "w") as f:
379
+ yaml.safe_dump(data, f)
380
+ Path(tmp_path).replace(config_path)
381
+ except BaseException:
382
+ with contextlib.suppress(OSError):
383
+ Path(tmp_path).unlink()
384
+ raise
385
+
386
+
387
+ def _read_update_config() -> dict[str, bool]:
388
+ """Read `[update]` section from `config.yml`.
389
+
390
+ Returns:
391
+ A dict of boolean config values, empty on missing/unreadable file.
392
+ """
393
+ import yaml
394
+
395
+ try:
396
+ config_path = Path(SOOTHE_HOME) / "config" / "config.yml"
397
+ if not config_path.exists():
398
+ return {}
399
+ with config_path.open("r") as f:
400
+ data = yaml.safe_load(f) or {}
401
+ section = data.get("update", {})
402
+ return {k: v for k, v in section.items() if isinstance(v, bool)}
403
+ except (OSError, yaml.YAMLError):
404
+ logger.warning("Could not read [update] config — using defaults", exc_info=True)
405
+ return {}
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # "What's new" tracking
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ def get_seen_version() -> str | None:
414
+ """Return the last version the user saw the "what's new" banner for."""
415
+ try:
416
+ if SEEN_VERSION_FILE.exists():
417
+ data = json.loads(SEEN_VERSION_FILE.read_text(encoding="utf-8"))
418
+ return data.get("version")
419
+ except (OSError, json.JSONDecodeError, KeyError, TypeError):
420
+ logger.debug("Failed to read seen-version file", exc_info=True)
421
+ return None
422
+
423
+
424
+ def mark_version_seen(version: str) -> None:
425
+ """Record that the user has seen the "what's new" banner for *version*."""
426
+ try:
427
+ SEEN_VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
428
+ SEEN_VERSION_FILE.write_text(
429
+ json.dumps({"version": version, "seen_at": time.time()}),
430
+ encoding="utf-8",
431
+ )
432
+ except OSError:
433
+ logger.debug("Failed to write seen-version file", exc_info=True)
434
+
435
+
436
+ def should_show_whats_new() -> bool:
437
+ """Return `True` if this is the first launch on a newer version."""
438
+ seen = get_seen_version()
439
+ if seen is None:
440
+ # First run ever — mark current as seen, don't show banner.
441
+ mark_version_seen(__version__)
442
+ return False
443
+ try:
444
+ return _parse_version(__version__) > _parse_version(seen)
445
+ except InvalidVersion:
446
+ logger.debug("Failed to compare versions for what's-new check", exc_info=True)
447
+ return False
@@ -0,0 +1,9 @@
1
+ """Textual widgets for Soothe.
2
+
3
+ Import directly from submodules, e.g.:
4
+
5
+ ```python
6
+ from soothe_cli.tui.widgets.chat_input import ChatInput
7
+ from soothe_cli.tui.widgets.messages import AssistantMessage
8
+ ```
9
+ """
@@ -0,0 +1,63 @@
1
+ """Shared link-click handling for Textual widgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import webbrowser
7
+ from typing import TYPE_CHECKING
8
+
9
+ from soothe_cli.tui.unicode_security import check_url_safety, strip_dangerous_unicode
10
+
11
+ if TYPE_CHECKING:
12
+ from textual.events import Click
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def open_style_link(event: Click) -> None:
18
+ """Open the URL from a Rich link style on click, if present.
19
+
20
+ Rich `Style(link=...)` embeds OSC 8 terminal hyperlinks, but Textual's
21
+ mouse capture intercepts normal clicks before the terminal can act on them.
22
+ By handling the Textual click event directly we open the URL with a single
23
+ click, matching the behavior of links in the Markdown widget.
24
+
25
+ URLs that fail the safety check (e.g. containing hidden Unicode or
26
+ homograph domains) are blocked and not opened; the event bubbles and a
27
+ warning is logged and displayed as a Textual notification.
28
+
29
+ On success the event is stopped so it does not bubble further. On failure
30
+ (e.g. no browser available in a headless environment) the error is logged at
31
+ debug level and the event bubbles normally.
32
+
33
+ Args:
34
+ event: The Textual click event to inspect.
35
+ """
36
+ url = event.style.link
37
+ if not url:
38
+ return
39
+
40
+ safety = check_url_safety(url)
41
+ if not safety.safe:
42
+ detail = safety.warnings[0] if safety.warnings else "Suspicious URL"
43
+ logger.warning("Blocked suspicious URL: %s (%s)", url, detail)
44
+ try:
45
+ app = getattr(event, "app", None)
46
+ notify = getattr(app, "notify", None)
47
+ if callable(notify):
48
+ safe_url = strip_dangerous_unicode(url)
49
+ notify(
50
+ f"Blocked suspicious URL: {safe_url}\n{detail}",
51
+ severity="warning",
52
+ markup=False,
53
+ )
54
+ except (AttributeError, TypeError):
55
+ logger.debug("Could not send URL-blocked notification", exc_info=True)
56
+ return
57
+
58
+ try:
59
+ webbrowser.open(url)
60
+ except Exception:
61
+ logger.debug("Could not open browser for URL: %s", url, exc_info=True)
62
+ return
63
+ event.stop()