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,420 @@
|
|
|
1
|
+
r"""Runtime patches over Textual internals, imported for side effect.
|
|
2
|
+
|
|
3
|
+
This module hosts two independent best-effort patches over private Textual
|
|
4
|
+
APIs. Each guards its own import/assignment and degrades to stock Textual
|
|
5
|
+
behavior (logging a warning) if the targeted internals move, so they have
|
|
6
|
+
separate lifecycles — do not delete the whole file when only one lands
|
|
7
|
+
upstream.
|
|
8
|
+
|
|
9
|
+
1. Alt-modifier preservation on legacy `ESC + <byte>` sequences. Upstream
|
|
10
|
+
`XTermParser._sequence_to_key_events` drops the `alt` flag on the
|
|
11
|
+
tuple-branch fast path, so VSCode's `sendSequence` shift+enter binding
|
|
12
|
+
(which writes `\x1b\r` to the PTY) arrives as bare `enter` instead of
|
|
13
|
+
`alt+enter`. Tracked in Textualize/textual#6378. Remove this patch and
|
|
14
|
+
the Textual pin comment in `pyproject.toml` when that lands.
|
|
15
|
+
|
|
16
|
+
2. Kitty lock-key and sub-field handling. Two related problems with the
|
|
17
|
+
pinned Textual parser:
|
|
18
|
+
|
|
19
|
+
a. Lock keys (Caps Lock / Num Lock / Scroll Lock) must never produce
|
|
20
|
+
text, but terminals encode them inconsistently. kitty/Ghostty/VS Code
|
|
21
|
+
send the functional key code (`CSI 57358 ... u`) with associated text
|
|
22
|
+
set to the letter the *next* key would have produced. iTerm2 instead
|
|
23
|
+
reports the Caps Lock toggle as a bare upper-case ASCII letter (`CSI
|
|
24
|
+
65 u` → 'A') with no modifier or associated-text field — not a valid
|
|
25
|
+
encoding for a real key press per the kitty spec. Either way the chat
|
|
26
|
+
input would type a stray capital. The patch collapses both forms to a
|
|
27
|
+
single character-less `caps_lock` event, regardless of the modifier,
|
|
28
|
+
associated-text, or event-type sub-fields the terminal includes.
|
|
29
|
+
|
|
30
|
+
b. `_re_extended_key` only accepts `;`-separated numeric fields, so any
|
|
31
|
+
*non-lock* kitty sequence carrying `:`-separated sub-fields — alternate
|
|
32
|
+
keys (`unicode:shifted:base`) or an event-type (`modifiers:event`) —
|
|
33
|
+
fails to match and is re-emitted one byte at a time as literal text.
|
|
34
|
+
The patch strips the `:` sub-fields before Textual parses the sequence
|
|
35
|
+
so it resolves to a single key event.
|
|
36
|
+
|
|
37
|
+
Remove when the pinned Textual neutralizes lock keys and widens its parser.
|
|
38
|
+
|
|
39
|
+
3. Double-click word selection. Stock Textual selects the entire widget on
|
|
40
|
+
a click chain; these patches narrow a double-click (and double-click
|
|
41
|
+
drag) to word boundaries. No upstream issue tracks this yet, so it has
|
|
42
|
+
no removal criterion — it stays until Textual grows native word select.
|
|
43
|
+
|
|
44
|
+
Imported for side effect from `app.py` before any `App()` is created.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import logging
|
|
50
|
+
import re
|
|
51
|
+
from inspect import isawaitable
|
|
52
|
+
from typing import TYPE_CHECKING
|
|
53
|
+
|
|
54
|
+
from rich.text import Text
|
|
55
|
+
from textual import __version__ as _textual_version
|
|
56
|
+
from textual.content import Content
|
|
57
|
+
from textual.geometry import Offset
|
|
58
|
+
from textual.selection import Selection
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from collections.abc import Iterable
|
|
62
|
+
|
|
63
|
+
from textual.events import Click, Event
|
|
64
|
+
from textual.screen import Screen
|
|
65
|
+
from textual.selection import SelectState
|
|
66
|
+
from textual.widget import Widget
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
_ESC_PREFIX_LEN = 2
|
|
71
|
+
_DOUBLE_CLICK_CHAIN = 2
|
|
72
|
+
_TRIPLE_CLICK_CHAIN = 3
|
|
73
|
+
_DEEPAGENTS_WORD_SELECT_ACTIVE = "_deepagents_word_select_active"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
from textual import events
|
|
77
|
+
from textual._ansi_sequences import ( # noqa: PLC2701
|
|
78
|
+
ANSI_SEQUENCES_KEYS,
|
|
79
|
+
IGNORE_SEQUENCE,
|
|
80
|
+
)
|
|
81
|
+
from textual._xterm_parser import XTermParser # noqa: PLC2701
|
|
82
|
+
|
|
83
|
+
_original = XTermParser._sequence_to_key_events
|
|
84
|
+
except (ImportError, AttributeError) as exc: # pragma: no cover - defensive
|
|
85
|
+
logger.warning("Textual keyboard parser patch skipped: %s", exc)
|
|
86
|
+
else:
|
|
87
|
+
# Kitty functional key codes for the lock keys (Caps Lock, Scroll Lock,
|
|
88
|
+
# Num Lock). The kitty protocol assigns these Private Use Area codepoints;
|
|
89
|
+
# they appear as the leading key-code field of a `CSI ... u` sequence.
|
|
90
|
+
_KITTY_LOCK_KEY_CODES = frozenset({"57358", "57359", "57360"})
|
|
91
|
+
_KITTY_LOCK_KEY_NAMES = {
|
|
92
|
+
"57358": "caps_lock",
|
|
93
|
+
"57359": "scroll_lock",
|
|
94
|
+
"57360": "num_lock",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Any `CSI <code>[:...][;...] u` sequence. Group 1 is the leading key-code
|
|
98
|
+
# field (before any `:` alternate-key sub-field); `_lock_key_event` checks
|
|
99
|
+
# it against the lock-key set. The match is deliberately broad so the code
|
|
100
|
+
# is extracted regardless of the modifier / associated-text / event-type
|
|
101
|
+
# sub-fields that follow, which iTerm2 and other terminals encode in
|
|
102
|
+
# varying shapes.
|
|
103
|
+
_KITTY_KEY_SEQUENCE = re.compile(r"\x1b\[(\d+)[\d;:]*u")
|
|
104
|
+
|
|
105
|
+
# Kitty extended-key sequence carrying `:` sub-fields (alternate keys or an
|
|
106
|
+
# event-type sub-field). The pinned Textual's `_re_extended_key` rejects the
|
|
107
|
+
# colons, so non-lock keys with these sub-fields would otherwise leak as
|
|
108
|
+
# literal text — strip the sub-fields so they parse to a single key event.
|
|
109
|
+
_KITTY_SUBFIELD_KEY = re.compile(r"\x1b\[[\d;:]*:[\d;:]*[u~ABCDEFHPQRS]")
|
|
110
|
+
|
|
111
|
+
# iTerm2 reports the Caps Lock toggle as a `CSI u` sequence whose primary
|
|
112
|
+
# key code is the *uppercase* ASCII letter that would be produced next
|
|
113
|
+
# (e.g. `CSI 65 u` → 'A'), with no real modifier bits and no associated
|
|
114
|
+
# text. The kitty spec requires the primary code to be the unshifted
|
|
115
|
+
# (lower-case) code point, so a bare upper-case letter here is iTerm2's
|
|
116
|
+
# Caps Lock artifact rather than a real key press. Group 1 is the code
|
|
117
|
+
# point; group 2 the optional modifier field; group 3 the optional text.
|
|
118
|
+
_KITTY_CSI_U = re.compile(
|
|
119
|
+
r"\x1b\[(\d+)(?::\d+)*(?:;(\d+)[\d:]*)?(?:;(\d+)[\d:]*)?u"
|
|
120
|
+
)
|
|
121
|
+
_ASCII_UPPER_A = 65
|
|
122
|
+
_ASCII_UPPER_Z = 90
|
|
123
|
+
# Modifier mask for the "real" modifiers (shift|alt|ctrl|super|hyper|meta);
|
|
124
|
+
# excludes the caps_lock (64) and num_lock (128) lock bits.
|
|
125
|
+
_REAL_MODIFIER_MASK = 0b111111
|
|
126
|
+
|
|
127
|
+
def _spurious_caps_lock(sequence: str) -> bool:
|
|
128
|
+
"""Whether `sequence` is iTerm2's bare Caps Lock toggle report.
|
|
129
|
+
|
|
130
|
+
Matches a `CSI u` key whose primary code point is an upper-case ASCII
|
|
131
|
+
letter with no real modifiers and no associated-text field — which the
|
|
132
|
+
kitty spec never produces for a genuine key press.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
`True` if `sequence` is the spurious Caps Lock toggle report.
|
|
136
|
+
"""
|
|
137
|
+
match = _KITTY_CSI_U.fullmatch(sequence)
|
|
138
|
+
if match is None:
|
|
139
|
+
return False
|
|
140
|
+
code = int(match.group(1))
|
|
141
|
+
if not _ASCII_UPPER_A <= code <= _ASCII_UPPER_Z:
|
|
142
|
+
return False
|
|
143
|
+
modifier_bits = (int(match.group(2)) - 1) if match.group(2) else 0
|
|
144
|
+
has_text = match.group(3) is not None
|
|
145
|
+
return modifier_bits & _REAL_MODIFIER_MASK == 0 and not has_text
|
|
146
|
+
|
|
147
|
+
def _strip_kitty_subfields(sequence: str) -> str:
|
|
148
|
+
"""Drop `:` sub-fields from a kitty extended-key sequence.
|
|
149
|
+
|
|
150
|
+
Keeps the primary value of each `;`-separated field (the unicode key
|
|
151
|
+
code, modifier mask, and associated text), which is all Textual reads.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The sequence with every `:` sub-field removed.
|
|
155
|
+
"""
|
|
156
|
+
body, terminator = sequence[2:-1], sequence[-1]
|
|
157
|
+
primary = ";".join(field.split(":", 1)[0] for field in body.split(";"))
|
|
158
|
+
return f"\x1b[{primary}{terminator}"
|
|
159
|
+
|
|
160
|
+
def _lock_key_event(sequence: str) -> events.Key | None:
|
|
161
|
+
"""Return a text-free lock-key event for a kitty lock-key sequence.
|
|
162
|
+
|
|
163
|
+
Lock keys must never produce text. Under the kitty protocol with
|
|
164
|
+
associated-text reporting, terminals (notably iTerm2) encode Caps
|
|
165
|
+
Lock as a `CSI 57358 ... u` sequence whose associated-text field is
|
|
166
|
+
the letter the *next* key would have produced — Textual then either
|
|
167
|
+
types that letter or, when `:` sub-fields are present, leaks the raw
|
|
168
|
+
sequence byte by byte. Collapsing any lock-key sequence to a single
|
|
169
|
+
character-less event stops both failure modes at the source, for
|
|
170
|
+
every widget.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A `Key` event for the lock key, or `None` if `sequence` is not a
|
|
174
|
+
kitty lock-key sequence.
|
|
175
|
+
"""
|
|
176
|
+
match = _KITTY_KEY_SEQUENCE.fullmatch(sequence)
|
|
177
|
+
if match is None or match.group(1) not in _KITTY_LOCK_KEY_CODES:
|
|
178
|
+
return None
|
|
179
|
+
return events.Key(_KITTY_LOCK_KEY_NAMES[match.group(1)], None)
|
|
180
|
+
|
|
181
|
+
def _emit_alt(keys: tuple, character: str | None) -> Iterable[events.Key]:
|
|
182
|
+
for key in keys:
|
|
183
|
+
yield events.Key(f"alt+{key.value}", character)
|
|
184
|
+
|
|
185
|
+
def _sequence_to_key_events_with_alt(
|
|
186
|
+
self: XTermParser, sequence: str, alt: bool = False
|
|
187
|
+
) -> Iterable[events.Key]:
|
|
188
|
+
# Lock keys (Caps Lock / Num Lock / Scroll Lock) must never type. Emit
|
|
189
|
+
# a single character-less event regardless of how the terminal encoded
|
|
190
|
+
# the modifiers, associated text, or event-type sub-fields.
|
|
191
|
+
if (lock_event := _lock_key_event(sequence)) is not None:
|
|
192
|
+
yield lock_event
|
|
193
|
+
return
|
|
194
|
+
# iTerm2 reports the Caps Lock toggle as a bare upper-case letter (e.g.
|
|
195
|
+
# `CSI 65 u` → 'A') rather than the kitty `57358` functional code. Drop
|
|
196
|
+
# it so the toggle never types a stray capital into the input.
|
|
197
|
+
if _spurious_caps_lock(sequence):
|
|
198
|
+
yield events.Key("caps_lock", None)
|
|
199
|
+
return
|
|
200
|
+
# Normalize any other kitty sequence with `:` sub-fields so it resolves
|
|
201
|
+
# to a single key event instead of leaking raw bytes.
|
|
202
|
+
if _KITTY_SUBFIELD_KEY.fullmatch(sequence):
|
|
203
|
+
sequence = _strip_kitty_subfields(sequence)
|
|
204
|
+
# Fast path: \x1b<byte> on first pass. Short-circuits the ~100 ms
|
|
205
|
+
# escape-delay wait when both bytes arrive together. Semantic side
|
|
206
|
+
# effect: \x1b\x1b dispatches as `alt+escape` with no delay, matching
|
|
207
|
+
# crossterm and Node TTY.
|
|
208
|
+
if not alt and len(sequence) == _ESC_PREFIX_LEN and sequence[0] == "\x1b":
|
|
209
|
+
inner = ANSI_SEQUENCES_KEYS.get(sequence[1])
|
|
210
|
+
if inner is not IGNORE_SEQUENCE and isinstance(inner, tuple):
|
|
211
|
+
yield from _emit_alt(inner, None)
|
|
212
|
+
return
|
|
213
|
+
# Correctness fix (Textualize/textual#6378): preserve `alt` on the
|
|
214
|
+
# reissue path for single-byte tuple mappings.
|
|
215
|
+
if alt:
|
|
216
|
+
keys = ANSI_SEQUENCES_KEYS.get(sequence)
|
|
217
|
+
if keys is not IGNORE_SEQUENCE and isinstance(keys, tuple):
|
|
218
|
+
character = sequence if len(sequence) == 1 else None
|
|
219
|
+
yield from _emit_alt(keys, character)
|
|
220
|
+
return
|
|
221
|
+
yield from _original(self, sequence, alt=alt)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
XTermParser._sequence_to_key_events = _sequence_to_key_events_with_alt # ty: ignore[invalid-assignment]
|
|
225
|
+
except (AttributeError, TypeError) as exc: # pragma: no cover - defensive
|
|
226
|
+
logger.warning("Textual keyboard parser patch assignment rejected: %s", exc)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _rendered_text(widget: Widget) -> str | None:
|
|
230
|
+
visual = widget._render() # match Textual's get_selection path
|
|
231
|
+
if isinstance(visual, (Content, Text)):
|
|
232
|
+
return str(visual)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _word_bounds(text: str, offset: Offset) -> tuple[Offset, Offset] | None:
|
|
237
|
+
lines = text.splitlines()
|
|
238
|
+
if not lines:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
y = min(max(offset.y, 0), len(lines) - 1)
|
|
242
|
+
line = lines[y]
|
|
243
|
+
if not line:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
x = min(max(offset.x, 0), len(line))
|
|
247
|
+
index = min(x, len(line) - 1)
|
|
248
|
+
if line[index].isspace():
|
|
249
|
+
# A click just past the final character (x == len(line)) lands on the
|
|
250
|
+
# virtual end-of-line position; snap back onto the trailing word so
|
|
251
|
+
# double-clicking after a word still selects it. Genuine whitespace
|
|
252
|
+
# clicks fall through and select nothing.
|
|
253
|
+
if x == len(line) and x > 0 and not line[x - 1].isspace():
|
|
254
|
+
index = x - 1
|
|
255
|
+
else:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
start = index
|
|
259
|
+
while start > 0 and not line[start - 1].isspace():
|
|
260
|
+
start -= 1
|
|
261
|
+
|
|
262
|
+
end = index + 1
|
|
263
|
+
while end < len(line) and not line[end].isspace():
|
|
264
|
+
end += 1
|
|
265
|
+
|
|
266
|
+
return Offset(start, y), Offset(end, y)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _word_selection(widget: Widget, selection: Selection) -> Selection | None:
|
|
270
|
+
if selection.start is None or selection.end is None:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
text = _rendered_text(widget)
|
|
274
|
+
if text is None:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
start, end = selection.start, selection.end
|
|
278
|
+
# `Offset.transpose` is (y, x) — Textual's reading-order key. A backward
|
|
279
|
+
# drag leaves end before start in reading order; normalize so the word
|
|
280
|
+
# bounds below extend outward from the correct endpoints.
|
|
281
|
+
if end.transpose < start.transpose:
|
|
282
|
+
start, end = end, start
|
|
283
|
+
|
|
284
|
+
start_bounds = _word_bounds(text, start)
|
|
285
|
+
end_bounds = _word_bounds(text, end)
|
|
286
|
+
if start_bounds is None and end_bounds is None:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
return Selection(
|
|
290
|
+
start_bounds[0] if start_bounds is not None else start,
|
|
291
|
+
end_bounds[1] if end_bounds is not None else end,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _select_word_at_click(widget: Widget, event: Click) -> bool:
|
|
296
|
+
offset = event.get_content_offset(widget)
|
|
297
|
+
if offset is None:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
text = _rendered_text(widget)
|
|
301
|
+
if text is None:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
bounds = _word_bounds(text, offset)
|
|
305
|
+
if bounds is None:
|
|
306
|
+
widget.screen.clear_selection()
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
widget.screen.selections = {widget: Selection(*bounds)}
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
from textual import events as _events
|
|
315
|
+
from textual.screen import Screen as _Screen
|
|
316
|
+
from textual.widget import Widget as _Widget
|
|
317
|
+
|
|
318
|
+
_original_forward_event = _Screen._forward_event
|
|
319
|
+
_original_watch_select_state = _Screen._watch__select_state
|
|
320
|
+
_original_widget_on_click = _Widget._on_click
|
|
321
|
+
except (ImportError, AttributeError) as exc: # pragma: no cover - defensive
|
|
322
|
+
logger.warning(
|
|
323
|
+
"Textual word-selection patch skipped (textual %s): %s",
|
|
324
|
+
_textual_version,
|
|
325
|
+
exc,
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
|
|
329
|
+
def _is_word_select_start(screen: Screen, event: Event) -> bool:
|
|
330
|
+
# Mirrors Textual's own click-chain detection (App._on_mouse_down),
|
|
331
|
+
# reading its private `_click_chain_last_*` bookkeeping to recognize
|
|
332
|
+
# the second press of a double-click before Textual increments the
|
|
333
|
+
# chain count. Re-verify these attribute names on every Textual bump.
|
|
334
|
+
if not isinstance(event, _events.MouseDown) or screen.app.mouse_captured:
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
last_offset = getattr(screen.app, "_click_chain_last_offset", None)
|
|
338
|
+
last_time = getattr(screen.app, "_click_chain_last_time", None)
|
|
339
|
+
if last_offset != event.screen_offset or last_time is None:
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
if event.time - last_time > screen.app.CLICK_CHAIN_TIME_THRESHOLD:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
select_widget, select_offset = screen.get_widget_and_offset_at(event.x, event.y)
|
|
346
|
+
return (
|
|
347
|
+
select_widget is not None
|
|
348
|
+
and select_widget.allow_select
|
|
349
|
+
and screen.allow_select
|
|
350
|
+
and screen.app.ALLOW_SELECT
|
|
351
|
+
and select_offset is not None
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _forward_event_with_word_select(self: Screen, event: Event) -> None:
|
|
355
|
+
if isinstance(event, _events.MouseDown):
|
|
356
|
+
setattr(
|
|
357
|
+
self,
|
|
358
|
+
_DEEPAGENTS_WORD_SELECT_ACTIVE,
|
|
359
|
+
_is_word_select_start(self, event),
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
_original_forward_event(self, event)
|
|
363
|
+
finally:
|
|
364
|
+
if isinstance(event, _events.MouseUp):
|
|
365
|
+
setattr(self, _DEEPAGENTS_WORD_SELECT_ACTIVE, False)
|
|
366
|
+
|
|
367
|
+
async def _watch_select_state_with_word_select(
|
|
368
|
+
self: Screen,
|
|
369
|
+
select_state: SelectState | None,
|
|
370
|
+
) -> None:
|
|
371
|
+
result = _original_watch_select_state(self, select_state)
|
|
372
|
+
# `_watch__select_state` is synchronous in the pinned Textual; the
|
|
373
|
+
# isawaitable guard tolerates a future release making it a coroutine
|
|
374
|
+
# without forcing a same-day patch update.
|
|
375
|
+
if isawaitable(result):
|
|
376
|
+
await result
|
|
377
|
+
if not getattr(self, _DEEPAGENTS_WORD_SELECT_ACTIVE, False):
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
selections = dict(self.selections)
|
|
381
|
+
changed = False
|
|
382
|
+
for widget, selection in selections.items():
|
|
383
|
+
word_selection = _word_selection(widget, selection)
|
|
384
|
+
if word_selection is None or word_selection == selection:
|
|
385
|
+
continue
|
|
386
|
+
selections[widget] = word_selection
|
|
387
|
+
changed = True
|
|
388
|
+
|
|
389
|
+
if changed:
|
|
390
|
+
self.selections = selections
|
|
391
|
+
|
|
392
|
+
async def _on_click_with_word_select(self: Widget, event: Click) -> None:
|
|
393
|
+
if (
|
|
394
|
+
event.widget is self
|
|
395
|
+
and self.allow_select
|
|
396
|
+
and self.screen.allow_select
|
|
397
|
+
and self.app.ALLOW_SELECT
|
|
398
|
+
):
|
|
399
|
+
if event.chain == _DOUBLE_CLICK_CHAIN and _select_word_at_click(
|
|
400
|
+
self, event
|
|
401
|
+
):
|
|
402
|
+
await self.broker_event("click", event)
|
|
403
|
+
return
|
|
404
|
+
if event.chain == _TRIPLE_CLICK_CHAIN:
|
|
405
|
+
self.text_select_all()
|
|
406
|
+
await self.broker_event("click", event)
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
await _original_widget_on_click(self, event)
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
_Screen._forward_event = _forward_event_with_word_select # ty: ignore[invalid-assignment]
|
|
413
|
+
_Screen._watch__select_state = _watch_select_state_with_word_select # ty: ignore[invalid-assignment]
|
|
414
|
+
_Widget._on_click = _on_click_with_word_select # ty: ignore[invalid-assignment]
|
|
415
|
+
except (AttributeError, TypeError) as exc: # pragma: no cover - defensive
|
|
416
|
+
logger.warning(
|
|
417
|
+
"Textual word-selection patch assignment rejected (textual %s): %s",
|
|
418
|
+
_textual_version,
|
|
419
|
+
exc,
|
|
420
|
+
)
|