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,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
+ )