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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -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 `&` 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("<", "<").replace(">", ">").replace("&", "&")
|
|
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()
|