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,1041 @@
1
+ """Input handling utilities including image/video tracking and file mention parsing."""
2
+
3
+ import logging
4
+ import re
5
+ import shlex
6
+ from dataclasses import dataclass, replace
7
+ from difflib import SequenceMatcher
8
+ from pathlib import Path
9
+ from typing import Literal
10
+ from urllib.parse import unquote, urlparse
11
+
12
+ from rich.markup import escape as escape_markup
13
+
14
+ from deepagents_code.config import console
15
+ from deepagents_code.media_utils import ImageData, VideoData
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ PATH_CHAR_CLASS = r"A-Za-z0-9._~/\\:-"
20
+ """Characters allowed in file paths.
21
+
22
+ Includes alphanumeric, period, underscore, tilde (home), forward/back slashes
23
+ (path separators), colon (Windows drive letters), and hyphen.
24
+ """
25
+
26
+ FILE_MENTION_PATTERN = re.compile(r"@(?P<path>(?:\\.|[" + PATH_CHAR_CLASS + r"])+)")
27
+ """Pattern for extracting `@file` mentions from input text.
28
+
29
+ Matches `@` followed by one or more path characters or escaped character
30
+ pairs (backslash + any character, e.g., `\\ ` for spaces in paths).
31
+
32
+ Uses `+` (not `*`) because a bare `@` without a path is not a valid
33
+ file reference.
34
+ """
35
+
36
+ EMAIL_PREFIX_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]$")
37
+ """Pattern to detect email-like text preceding an `@` symbol.
38
+
39
+ If the character immediately before `@` matches this pattern, the `@mention`
40
+ is likely part of an email address (e.g., `user@example.com`) rather than
41
+ a file reference.
42
+ """
43
+
44
+ INPUT_HIGHLIGHT_PATTERN = re.compile(
45
+ r"(^\/[a-zA-Z0-9_-]+|@(?:\\.|[" + PATH_CHAR_CLASS + r"])+)"
46
+ )
47
+ """Pattern for highlighting `@mentions` and `/commands` in rendered
48
+ user messages.
49
+
50
+ Matches either:
51
+ - Slash commands at the start of the string (e.g., `/help`)
52
+ - `@file` mentions anywhere in the text (e.g., `@README.md`)
53
+
54
+ Note: The `^` anchor matches start of string, not start of line. The consumer
55
+ in `UserMessage.compose()` additionally checks `start == 0` before styling
56
+ slash commands, so a `/` mid-string is not highlighted.
57
+ """
58
+
59
+ MediaKind = Literal["image", "video"]
60
+ """Accepted values for the `kind` parameter in `MediaTracker` methods."""
61
+
62
+ IMAGE_PLACEHOLDER_PATTERN = re.compile(r"\[image (?P<id>\d+)\]")
63
+ """Pattern for image placeholders with a named `id` capture group.
64
+
65
+ Used to extract numeric IDs from placeholder tokens so the tracker can prune
66
+ stale entries and compute the next available ID.
67
+ """
68
+
69
+ VIDEO_PLACEHOLDER_PATTERN = re.compile(r"\[video (?P<id>\d+)\]")
70
+ """Pattern for video placeholders with a named `id` capture group.
71
+
72
+ Used to extract numeric IDs from placeholder tokens so the tracker can prune
73
+ stale entries and compute the next available ID.
74
+ """
75
+
76
+ _UNICODE_SPACE_EQUIVALENTS = str.maketrans(
77
+ {
78
+ "\u00a0": " ", # NO-BREAK SPACE
79
+ "\u202f": " ", # NARROW NO-BREAK SPACE
80
+ }
81
+ )
82
+ """Translation table used to normalize Unicode space variants.
83
+
84
+ Some macOS-generated filenames (for example screenshots) may contain non-ASCII
85
+ space code points that look identical to normal spaces when pasted.
86
+ """
87
+
88
+ _WINDOWS_DRIVE_PATH_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
89
+ """Pattern for Windows drive-letter paths like `C:\\Users\\...`."""
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ParsedPastedPathPayload:
94
+ """Unified parse result for dropped-path payload detection.
95
+
96
+ Attributes:
97
+ paths: Resolved file paths parsed from the input payload.
98
+ token_end: End index (exclusive) of the parsed leading token when the
99
+ payload starts with a path followed by trailing text.
100
+
101
+ `None` means the entire payload was parsed as path-only content.
102
+ """
103
+
104
+ paths: list[Path]
105
+ token_end: int | None = None
106
+
107
+
108
+ class MediaTracker:
109
+ """Track pasted images and videos in the current conversation."""
110
+
111
+ def __init__(self) -> None:
112
+ """Initialize an empty media tracker.
113
+
114
+ Sets up empty lists to store images and videos, and initializes the
115
+ ID counters to 1 for generating unique placeholder identifiers.
116
+ """
117
+ self.images: list[ImageData] = []
118
+ self.videos: list[VideoData] = []
119
+ self.next_image_id: int = 1
120
+ self.next_video_id: int = 1
121
+
122
+ def add_media(
123
+ self,
124
+ data: ImageData | VideoData,
125
+ kind: MediaKind,
126
+ *,
127
+ existing_text: str = "",
128
+ ) -> str:
129
+ """Add a media item and return its placeholder text.
130
+
131
+ Args:
132
+ data: The image or video data to track.
133
+ kind: Media type key.
134
+ existing_text: Current draft text. Placeholder IDs already present
135
+ here are skipped so literal user text is not bound to new media.
136
+
137
+ Returns:
138
+ Placeholder string like "[image 1]" or "[video 1]".
139
+ """
140
+ if kind == "image":
141
+ while f"[image {self.next_image_id}]" in existing_text:
142
+ self.next_image_id += 1
143
+ placeholder = f"[image {self.next_image_id}]"
144
+ data.placeholder = placeholder
145
+ self.images.append(data) # ty: ignore[invalid-argument-type]
146
+ self.next_image_id += 1
147
+ else:
148
+ while f"[video {self.next_video_id}]" in existing_text:
149
+ self.next_video_id += 1
150
+ placeholder = f"[video {self.next_video_id}]"
151
+ data.placeholder = placeholder
152
+ self.videos.append(data) # ty: ignore[invalid-argument-type]
153
+ self.next_video_id += 1
154
+ return placeholder
155
+
156
+ def add_image(self, image_data: ImageData, *, existing_text: str = "") -> str:
157
+ """Add an image and return its placeholder text.
158
+
159
+ Args:
160
+ image_data: The image data to track.
161
+ existing_text: Current draft text. Placeholder IDs already present
162
+ here are skipped so literal user text is not bound to new media.
163
+
164
+ Returns:
165
+ Placeholder string like "[image 1]".
166
+ """
167
+ return self.add_media(image_data, "image", existing_text=existing_text)
168
+
169
+ def add_video(self, video_data: VideoData, *, existing_text: str = "") -> str:
170
+ """Add a video and return its placeholder text.
171
+
172
+ Args:
173
+ video_data: The video data to track.
174
+ existing_text: Current draft text. Placeholder IDs already present
175
+ here are skipped so literal user text is not bound to new media.
176
+
177
+ Returns:
178
+ Placeholder string like "[video 1]".
179
+ """
180
+ return self.add_media(video_data, "video", existing_text=existing_text)
181
+
182
+ def get_images(self) -> list[ImageData]:
183
+ """Get all tracked images.
184
+
185
+ Returns:
186
+ Copy of the list of tracked images.
187
+ """
188
+ return list(self.images)
189
+
190
+ def get_videos(self) -> list[VideoData]:
191
+ """Get all tracked videos.
192
+
193
+ Returns:
194
+ Copy of the list of tracked videos.
195
+ """
196
+ return list(self.videos)
197
+
198
+ def clear(self) -> None:
199
+ """Clear all tracked media and reset counters."""
200
+ self.images.clear()
201
+ self.videos.clear()
202
+ self.next_image_id = 1
203
+ self.next_video_id = 1
204
+
205
+ def snapshot(self) -> "MediaTracker":
206
+ """Return an independent copy of the currently tracked media."""
207
+ tracker = MediaTracker()
208
+ tracker.images = [replace(img) for img in self.images]
209
+ tracker.videos = [replace(vid) for vid in self.videos]
210
+ tracker.next_image_id = self.next_image_id
211
+ tracker.next_video_id = self.next_video_id
212
+ return tracker
213
+
214
+ def restore(self, snapshot: "MediaTracker") -> None:
215
+ """Replace current media state with an independent snapshot copy.
216
+
217
+ Args:
218
+ snapshot: Previously captured media state to restore.
219
+ """
220
+ self.images = [replace(img) for img in snapshot.images]
221
+ self.videos = [replace(vid) for vid in snapshot.videos]
222
+ self.next_image_id = snapshot.next_image_id
223
+ self.next_video_id = snapshot.next_video_id
224
+
225
+ def sync_to_text(
226
+ self,
227
+ text: str,
228
+ *,
229
+ previous_text: str | None = None,
230
+ cursor_offset: int | None = None,
231
+ ) -> None:
232
+ """Retain only media still referenced by placeholders in current text.
233
+
234
+ Args:
235
+ text: Current input text shown to the user.
236
+ previous_text: Previous input text, used to keep tracking the same
237
+ placeholder occurrence when duplicate literal tokens are added.
238
+ cursor_offset: Current cursor offset, used to disambiguate whole-paste
239
+ edits that create duplicate placeholder tokens.
240
+ """
241
+ img_found = self._sync_kind_images(
242
+ text, previous_text=previous_text, cursor_offset=cursor_offset
243
+ )
244
+ vid_found = self._sync_kind_videos(
245
+ text, previous_text=previous_text, cursor_offset=cursor_offset
246
+ )
247
+ if not img_found and not vid_found:
248
+ self.clear()
249
+
250
+ def remap_spans_to_text(self, text: str, *, previous_text: str) -> None:
251
+ """Re-map tracked placeholder spans onto a transformed copy of the text.
252
+
253
+ Submission rewrites the draft before the display placeholders are
254
+ stripped from the model-facing text: whitespace is trimmed, collapsed
255
+ pastes expand back to full content, dropped paths become placeholders,
256
+ and a mode prefix may be prepended. Every one of those shifts character
257
+ offsets, so a `placeholder_span` captured against the draft would be
258
+ stale by the time `strip_media_placeholders` consumes it — silently
259
+ stripping the wrong occurrence when a user-typed duplicate is present.
260
+
261
+ Re-mapping each span through the same before/after diff keeps it pointing
262
+ at its own display token in `text`. Spans that cannot be cleanly mapped
263
+ become `None`, degrading to the token-count fallback rather than a wrong
264
+ strip.
265
+
266
+ Args:
267
+ text: The transformed text the spans must line up with.
268
+ previous_text: The draft text the current spans were captured
269
+ against.
270
+ """
271
+ for item in (*self.images, *self.videos):
272
+ if item.placeholder_span is None:
273
+ continue
274
+ item.placeholder_span = self._map_placeholder_span(
275
+ item.placeholder_span, previous_text, text
276
+ )
277
+
278
+ def _sync_kind_images(
279
+ self,
280
+ text: str,
281
+ *,
282
+ previous_text: str | None = None,
283
+ cursor_offset: int | None = None,
284
+ ) -> bool:
285
+ """Sync image list to surviving placeholders in text.
286
+
287
+ Args:
288
+ text: Current input text.
289
+ previous_text: Previous input text, used to map existing spans.
290
+ cursor_offset: Current cursor offset for duplicate disambiguation.
291
+
292
+ Returns:
293
+ Whether any image placeholders were found.
294
+ """
295
+ matches = list(IMAGE_PLACEHOLDER_PATTERN.finditer(text))
296
+ placeholders = {m.group(0) for m in matches}
297
+ self.images = [img for img in self.images if img.placeholder in placeholders]
298
+ self._update_placeholder_spans(
299
+ self.images, matches, text, previous_text, cursor_offset
300
+ )
301
+ if not self.images:
302
+ self.next_image_id = 1
303
+ else:
304
+ self.next_image_id = self._max_placeholder_id(
305
+ self.images, IMAGE_PLACEHOLDER_PATTERN, len(self.images)
306
+ )
307
+ return bool(placeholders)
308
+
309
+ def _sync_kind_videos(
310
+ self,
311
+ text: str,
312
+ *,
313
+ previous_text: str | None = None,
314
+ cursor_offset: int | None = None,
315
+ ) -> bool:
316
+ """Sync video list to surviving placeholders in text.
317
+
318
+ Args:
319
+ text: Current input text.
320
+ previous_text: Previous input text, used to map existing spans.
321
+ cursor_offset: Current cursor offset for duplicate disambiguation.
322
+
323
+ Returns:
324
+ Whether any video placeholders were found.
325
+ """
326
+ matches = list(VIDEO_PLACEHOLDER_PATTERN.finditer(text))
327
+ placeholders = {m.group(0) for m in matches}
328
+ self.videos = [vid for vid in self.videos if vid.placeholder in placeholders]
329
+ self._update_placeholder_spans(
330
+ self.videos, matches, text, previous_text, cursor_offset
331
+ )
332
+ if not self.videos:
333
+ self.next_video_id = 1
334
+ else:
335
+ self.next_video_id = self._max_placeholder_id(
336
+ self.videos, VIDEO_PLACEHOLDER_PATTERN, len(self.videos)
337
+ )
338
+ return bool(placeholders)
339
+
340
+ def _update_placeholder_spans(
341
+ self,
342
+ items: list[ImageData] | list[VideoData],
343
+ matches: list[re.Match[str]],
344
+ text: str,
345
+ previous_text: str | None,
346
+ cursor_offset: int | None,
347
+ ) -> None:
348
+ """Refresh tracked placeholder spans for surviving media items.
349
+
350
+ Args:
351
+ items: Surviving tracked media items.
352
+ matches: Placeholder regex matches in the current text.
353
+ text: Current input text.
354
+ previous_text: Previous input text, used to map existing spans.
355
+ cursor_offset: Current cursor offset for duplicate disambiguation.
356
+ """
357
+ spans_by_token: dict[str, list[tuple[int, int]]] = {}
358
+ for match in matches:
359
+ spans_by_token.setdefault(match.group(0), []).append(match.span())
360
+
361
+ for item in items:
362
+ spans = spans_by_token.get(item.placeholder, [])
363
+ cursor_span = self._placeholder_span_after_cursor(spans, cursor_offset)
364
+ mapped = self._map_placeholder_span(
365
+ item.placeholder_span, previous_text, text
366
+ )
367
+ had_duplicate = (
368
+ previous_text is not None and previous_text.count(item.placeholder) > 1
369
+ )
370
+ if had_duplicate and mapped is not None and mapped in spans:
371
+ item.placeholder_span = mapped
372
+ elif cursor_span is not None and (
373
+ item.placeholder_span is None or mapped != item.placeholder_span
374
+ ):
375
+ item.placeholder_span = cursor_span
376
+ elif len(spans) == 1:
377
+ item.placeholder_span = spans[0]
378
+ elif mapped is not None and mapped in spans:
379
+ item.placeholder_span = mapped
380
+ elif item.placeholder_span not in spans:
381
+ item.placeholder_span = None
382
+
383
+ @staticmethod
384
+ def _placeholder_span_after_cursor(
385
+ spans: list[tuple[int, int]], cursor_offset: int | None
386
+ ) -> tuple[int, int] | None:
387
+ """Return the first duplicate placeholder span at or after the cursor.
388
+
389
+ Only meaningful when the token is duplicated (`len(spans) > 1`); returns
390
+ `None` otherwise, or when no span starts at/after the cursor.
391
+
392
+ Args:
393
+ spans: Placeholder spans for one token in current text.
394
+ cursor_offset: Current cursor offset, or `None` when unknown.
395
+ """
396
+ if cursor_offset is None or len(spans) <= 1:
397
+ return None
398
+ for span in spans:
399
+ start, _end = span
400
+ if start >= cursor_offset:
401
+ return span
402
+ return None
403
+
404
+ @staticmethod
405
+ def _map_placeholder_span(
406
+ span: tuple[int, int] | None,
407
+ previous_text: str | None,
408
+ text: str,
409
+ ) -> tuple[int, int] | None:
410
+ """Map a placeholder span from the previous text into current text.
411
+
412
+ Uses a `SequenceMatcher` diff so the span survives edits elsewhere in the
413
+ text: it returns the shifted span only when its whole range falls inside
414
+ an unchanged (`equal`) block, and `None` when the token's own characters
415
+ were edited or the span cannot be located.
416
+
417
+ Args:
418
+ span: The placeholder span in `previous_text`, or `None`.
419
+ previous_text: Text the span was captured against, or `None`.
420
+ text: Text to map the span into.
421
+
422
+ Returns:
423
+ The mapped span when the same placeholder occurrence survives,
424
+ otherwise `None`.
425
+ """
426
+ if span is None or previous_text is None:
427
+ return None
428
+ start, end = span
429
+ if not (0 <= start < end <= len(previous_text)):
430
+ return None
431
+
432
+ matcher = SequenceMatcher(a=previous_text, b=text, autojunk=False)
433
+ for tag, old_start, old_end, new_start, _new_end in matcher.get_opcodes():
434
+ if tag == "equal" and old_start <= start and end <= old_end:
435
+ offset = new_start - old_start
436
+ return start + offset, end + offset
437
+ if old_start <= start and end <= old_end:
438
+ return None
439
+ if old_start > end:
440
+ break
441
+ return None
442
+
443
+ @staticmethod
444
+ def _max_placeholder_id(
445
+ items: list[ImageData] | list[VideoData],
446
+ pattern: re.Pattern[str],
447
+ fallback_count: int,
448
+ ) -> int:
449
+ """Compute next ID from the highest surviving placeholder.
450
+
451
+ Args:
452
+ items: Surviving media items.
453
+ pattern: Placeholder regex with an `id` group.
454
+ fallback_count: Fallback when no IDs can be parsed.
455
+
456
+ Returns:
457
+ Next ID value (max_id + 1).
458
+ """
459
+ max_id = 0
460
+ for item in items:
461
+ match = pattern.fullmatch(item.placeholder)
462
+ if match is not None:
463
+ max_id = max(max_id, int(match.group("id")))
464
+ return max_id + 1 if max_id else fallback_count + 1
465
+
466
+
467
+ def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
468
+ r"""Extract `@file` mentions and return the text with resolved file paths.
469
+
470
+ Parses `@file` mentions from the input text and resolves them to absolute
471
+ file paths. Files that do not exist or cannot be resolved are excluded with
472
+ a warning printed to the console.
473
+
474
+ Email addresses (e.g., `user@example.com`) are automatically excluded by
475
+ detecting email-like characters before the `@` symbol.
476
+
477
+ Backslash-escaped spaces in paths (e.g., `@my\ folder/file.txt`) are
478
+ unescaped before resolution. Tilde paths (e.g., `@~/file.txt`) are expanded
479
+ via `Path.expanduser()`. Only regular files are returned; directories are
480
+ excluded.
481
+
482
+ This function does not raise exceptions; invalid paths are handled
483
+ internally with a console warning.
484
+
485
+ Args:
486
+ text: Input text potentially containing `@file` mentions.
487
+
488
+ Returns:
489
+ Tuple of (original text unchanged, list of resolved file paths that exist).
490
+ """
491
+ matches = FILE_MENTION_PATTERN.finditer(text)
492
+
493
+ files = []
494
+ for match in matches:
495
+ # Skip if this looks like an email address
496
+ text_before = text[: match.start()]
497
+ if text_before and EMAIL_PREFIX_PATTERN.search(text_before):
498
+ continue
499
+
500
+ raw_path = match.group("path")
501
+ clean_path = raw_path.replace("\\ ", " ")
502
+
503
+ try:
504
+ path = Path(clean_path).expanduser()
505
+
506
+ if not path.is_absolute():
507
+ path = Path.cwd() / path
508
+
509
+ resolved = path.resolve()
510
+ if resolved.exists() and resolved.is_file():
511
+ files.append(resolved)
512
+ else:
513
+ console.print(
514
+ f"[yellow]Warning: File not found: "
515
+ f"{escape_markup(raw_path)}[/yellow]"
516
+ )
517
+ except (OSError, RuntimeError) as e:
518
+ console.print(
519
+ f"[yellow]Warning: Invalid path "
520
+ f"{escape_markup(raw_path)}: "
521
+ f"{escape_markup(str(e))}[/yellow]"
522
+ )
523
+
524
+ return text, files
525
+
526
+
527
+ def parse_pasted_file_paths(text: str) -> list[Path]:
528
+ r"""Parse a paste payload that may contain dragged-and-dropped file paths.
529
+
530
+ The parser is strict on purpose: it only returns paths when the entire paste
531
+ payload can be interpreted as one or more existing files. Any invalid token
532
+ falls back to normal text paste behavior by returning an empty list.
533
+
534
+ Supports common dropped-path formats:
535
+
536
+ - Absolute/relative paths
537
+ - POSIX shell quoting and escaping
538
+ - `file://` URLs
539
+
540
+ Args:
541
+ text: Raw paste payload from the terminal.
542
+
543
+ Returns:
544
+ List of resolved file paths, or an empty list when parsing fails.
545
+ """
546
+ payload = text.strip()
547
+ if not payload:
548
+ return []
549
+
550
+ tokens: list[str] = []
551
+ for raw_line in payload.splitlines():
552
+ line = raw_line.strip()
553
+ if not line:
554
+ continue
555
+ line_tokens = _split_paste_line(line)
556
+ if not line_tokens:
557
+ return []
558
+ tokens.extend(line_tokens)
559
+
560
+ if not tokens:
561
+ return []
562
+
563
+ paths: list[Path] = []
564
+ for token in tokens:
565
+ path = _token_to_path(token)
566
+ if path is None:
567
+ return []
568
+ resolved = _resolve_existing_pasted_path(path)
569
+ if resolved is None:
570
+ return []
571
+ paths.append(resolved)
572
+
573
+ return paths
574
+
575
+
576
+ def parse_pasted_path_payload(
577
+ text: str, *, allow_leading_path: bool = False
578
+ ) -> ParsedPastedPathPayload | None:
579
+ """Parse dropped-path payload variants through one entrypoint.
580
+
581
+ Parsing order is:
582
+ 1. strict multi-path payload parsing (`parse_pasted_file_paths`)
583
+ 2. single-path normalization/parsing (`parse_single_pasted_file_path`)
584
+ 3. optional leading-path extraction (`extract_leading_pasted_file_path`)
585
+
586
+ Args:
587
+ text: Input payload to parse.
588
+ allow_leading_path: Whether to parse a leading path token followed by
589
+ trailing prompt text.
590
+
591
+ Returns:
592
+ Parsed payload details, otherwise `None`.
593
+ """
594
+ paths = parse_pasted_file_paths(text)
595
+ if paths:
596
+ return ParsedPastedPathPayload(paths=paths)
597
+
598
+ single_path = parse_single_pasted_file_path(text)
599
+ if single_path is not None:
600
+ return ParsedPastedPathPayload(paths=[single_path])
601
+
602
+ if not allow_leading_path:
603
+ return None
604
+
605
+ leading = extract_leading_pasted_file_path(text)
606
+ if leading is None:
607
+ return None
608
+
609
+ path, token_end = leading
610
+ return ParsedPastedPathPayload(paths=[path], token_end=token_end)
611
+
612
+
613
+ def parse_single_pasted_file_path(text: str) -> Path | None:
614
+ """Parse and resolve a single pasted path payload.
615
+
616
+ Unlike `parse_pasted_file_paths`, this helper only accepts one path token
617
+ and is intended for fallback handling when a paste event carries a
618
+ single path representation.
619
+
620
+ Args:
621
+ text: Raw pasted text payload.
622
+
623
+ Returns:
624
+ Resolved path when payload is a single existing file, otherwise `None`.
625
+ """
626
+ candidate = normalize_pasted_path(text)
627
+ if candidate is None:
628
+ return None
629
+ return _resolve_existing_pasted_path(candidate)
630
+
631
+
632
+ def extract_leading_pasted_file_path(text: str) -> tuple[Path, int] | None:
633
+ """Extract and resolve a leading pasted path token from input text.
634
+
635
+ This is used for submit-time recovery when a user message starts with a
636
+ path token followed by additional prompt text.
637
+
638
+ Args:
639
+ text: Input text to inspect.
640
+
641
+ Returns:
642
+ Tuple of `(resolved_path, token_end_index)` or `None` when no valid
643
+ leading file path token exists.
644
+ """
645
+ if not text:
646
+ return None
647
+
648
+ start = len(text) - len(text.lstrip())
649
+ payload = text[start:]
650
+ token_end = _leading_token_end(payload)
651
+ if token_end is None:
652
+ return None
653
+
654
+ token_text = payload[:token_end]
655
+ path = parse_single_pasted_file_path(token_text)
656
+ if path is None:
657
+ spaced = _extract_unquoted_leading_path_with_spaces(payload)
658
+ if spaced is None:
659
+ return None
660
+ spaced_path, spaced_end = spaced
661
+ return spaced_path, start + spaced_end
662
+
663
+ return path, start + token_end
664
+
665
+
666
+ def normalize_pasted_path(text: str) -> Path | None:
667
+ """Normalize pasted text that may represent a single filesystem path.
668
+
669
+ Supports:
670
+
671
+ - quoted and shell-escaped single paths
672
+ - `file://` URLs
673
+ - Windows drive-letter and UNC paths
674
+
675
+ Args:
676
+ text: Raw pasted text payload.
677
+
678
+ Returns:
679
+ Parsed `Path` if payload is a single path token, otherwise `None`.
680
+ """
681
+ payload = text.strip()
682
+ if not payload:
683
+ return None
684
+
685
+ unquoted = (
686
+ payload.removeprefix('"').removesuffix('"')
687
+ if payload.startswith('"') and payload.endswith('"')
688
+ else payload
689
+ )
690
+ unquoted = (
691
+ unquoted.removeprefix("'").removesuffix("'")
692
+ if unquoted.startswith("'") and unquoted.endswith("'")
693
+ else unquoted
694
+ )
695
+
696
+ if unquoted.startswith("file://"):
697
+ return _token_to_path(unquoted)
698
+
699
+ windows_path = _normalize_windows_pasted_path(unquoted)
700
+ if windows_path is not None:
701
+ return windows_path
702
+
703
+ posix_path = _normalize_posix_pasted_path(unquoted)
704
+ if posix_path is not None:
705
+ return posix_path
706
+
707
+ parts = _split_paste_line(payload)
708
+ if len(parts) != 1:
709
+ return None
710
+ token = parts[0]
711
+ path = _token_to_path(token)
712
+ if path is None:
713
+ return None
714
+ windows_token_path = _normalize_windows_pasted_path(str(path))
715
+ if windows_token_path is not None:
716
+ return windows_token_path
717
+ return path
718
+
719
+
720
+ def _split_paste_line(line: str) -> list[str]:
721
+ """Split a single pasted line into path-like tokens.
722
+
723
+ Args:
724
+ line: A single line from the paste payload.
725
+
726
+ Returns:
727
+ Parsed shell-like tokens, or an empty list when parsing fails.
728
+ """
729
+ try:
730
+ return shlex.split(line, posix=True)
731
+ except ValueError:
732
+ # Unbalanced quotes or other tokenization errors: treat as plain text.
733
+ return []
734
+
735
+
736
+ def _token_to_path(token: str) -> Path | None:
737
+ """Convert a pasted token into a path candidate.
738
+
739
+ Args:
740
+ token: A single shell-split token from the paste payload.
741
+
742
+ Returns:
743
+ A parsed path candidate, or `None` when token parsing fails.
744
+ """
745
+ value = token.strip()
746
+ if not value:
747
+ return None
748
+
749
+ if value.startswith("<") and value.endswith(">"):
750
+ value = value[1:-1].strip()
751
+ if not value:
752
+ return None
753
+
754
+ if value.startswith("file://"):
755
+ parsed = urlparse(value)
756
+ path_text = unquote(parsed.path or "")
757
+ if parsed.netloc and parsed.netloc != "localhost":
758
+ path_text = f"//{parsed.netloc}{path_text}"
759
+ if (
760
+ path_text.startswith("/")
761
+ and len(path_text) > 2 # noqa: PLR2004 # '/C:' minimum for Windows file URI
762
+ and path_text[2] == ":"
763
+ and path_text[1].isalpha()
764
+ ):
765
+ # `file:///C:/...` on Windows includes an extra leading slash.
766
+ path_text = path_text[1:]
767
+ if not path_text:
768
+ return None
769
+ return Path(path_text)
770
+
771
+ return Path(value)
772
+
773
+
774
+ def _leading_token_end(text: str) -> int | None:
775
+ """Return the end index of the first shell-like token.
776
+
777
+ Args:
778
+ text: Input text beginning with a token.
779
+
780
+ Returns:
781
+ End index (exclusive), or `None` when token parsing fails.
782
+ """
783
+ if not text:
784
+ return None
785
+
786
+ if text[0] in {'"', "'"}:
787
+ quote = text[0]
788
+ escaped = False
789
+ for index in range(1, len(text)):
790
+ char = text[index]
791
+ if char == "\\" and not escaped:
792
+ escaped = True
793
+ continue
794
+ if char == quote and not escaped:
795
+ return index + 1
796
+ escaped = False
797
+ return None
798
+
799
+ escaped = False
800
+ for index, char in enumerate(text):
801
+ if char == "\\" and not escaped:
802
+ escaped = True
803
+ continue
804
+ if char.isspace() and not escaped:
805
+ return index
806
+ escaped = False
807
+ return len(text)
808
+
809
+
810
+ def _extract_unquoted_leading_path_with_spaces(text: str) -> tuple[Path, int] | None:
811
+ """Extract a leading unquoted path that may contain spaces.
812
+
813
+ This fallback is intentionally POSIX-oriented (`/` and `~/`) because the
814
+ slash-command conflict it addresses is specific to inputs that begin with
815
+ `/`.
816
+
817
+ Args:
818
+ text: Input text beginning with a potential path.
819
+
820
+ Returns:
821
+ Tuple of `(resolved_path, token_end_index)` or `None` when no matching
822
+ leading path prefix resolves to an existing file.
823
+ """
824
+ if not text or ("\n" in text or "\r" in text):
825
+ return None
826
+ if not text.startswith(("/", "~/")):
827
+ return None
828
+ if " " not in text and "\u00a0" not in text and "\u202f" not in text:
829
+ return None
830
+
831
+ boundaries = [index for index, char in enumerate(text) if char.isspace()]
832
+ boundaries.append(len(text))
833
+ for end in reversed(boundaries):
834
+ candidate = text[:end].rstrip()
835
+ if not candidate:
836
+ continue
837
+ path = parse_single_pasted_file_path(candidate)
838
+ if path is not None:
839
+ return path, len(candidate)
840
+ return None
841
+
842
+
843
+ def _normalize_windows_pasted_path(text: str) -> Path | None:
844
+ """Return a `Path` for unquoted Windows drive/UNC path inputs.
845
+
846
+ Args:
847
+ text: Potential Windows path input.
848
+
849
+ Returns:
850
+ Parsed `Path` when `text` is Windows drive-letter or UNC style,
851
+ otherwise `None`.
852
+ """
853
+ if _WINDOWS_DRIVE_PATH_PATTERN.match(text) or text.startswith("\\\\"):
854
+ return Path(text)
855
+ return None
856
+
857
+
858
+ def _normalize_posix_pasted_path(text: str) -> Path | None:
859
+ """Return a `Path` for likely POSIX absolute/home path payloads.
860
+
861
+ Some terminals paste dropped absolute paths with spaces as raw text without
862
+ quoting/escaping. In that case shell tokenization splits on spaces even
863
+ though the full payload is intended to be a single path.
864
+
865
+ Args:
866
+ text: Potential POSIX path input.
867
+
868
+ Returns:
869
+ Parsed `Path` when `text` looks like a raw POSIX absolute/home path,
870
+ otherwise `None`.
871
+ """
872
+ if "\n" in text or "\r" in text:
873
+ return None
874
+ if text.startswith("~/"):
875
+ return Path(text)
876
+ if text.startswith("/") and "/" in text[1:]:
877
+ return Path(text)
878
+ return None
879
+
880
+
881
+ def _safe_exists(path: Path) -> bool:
882
+ """Return whether `path` exists, treating OS rejections as non-existent.
883
+
884
+ Filesystem probes (`exists`/`is_file`/`is_dir`) issue an `os.stat` that can
885
+ raise `OSError` for inputs the OS refuses outright — notably `ENAMETOOLONG`
886
+ when a path component exceeds the filesystem limit. Whether `pathlib`
887
+ swallows such an error is version-dependent (Python <=3.13 ignores only a
888
+ small set of errnos and lets `ENAMETOOLONG` propagate; 3.14 routes these
889
+ through `os.path.*`, which swallows more), so we guard unconditionally for
890
+ uniform behavior. Callers here only care whether the path is usable, so a
891
+ failed probe is equivalent to "not there".
892
+
893
+ Args:
894
+ path: Path candidate to probe.
895
+
896
+ Returns:
897
+ `True` if the path exists, `False` if it does not or cannot be probed.
898
+ """
899
+ try:
900
+ return path.exists()
901
+ except OSError as e:
902
+ logger.debug("exists() check failed for %r: %s", path, e)
903
+ return False
904
+
905
+
906
+ def _safe_is_file(path: Path) -> bool:
907
+ """Return whether `path` is an existing file, ignoring stat failures.
908
+
909
+ See `_safe_exists` for why probes are guarded.
910
+
911
+ Args:
912
+ path: Path candidate to probe.
913
+
914
+ Returns:
915
+ `True` if the path is a regular file, `False` otherwise or on failure.
916
+ """
917
+ try:
918
+ return path.is_file()
919
+ except OSError as e:
920
+ logger.debug("is_file() check failed for %r: %s", path, e)
921
+ return False
922
+
923
+
924
+ def _safe_is_dir(path: Path) -> bool:
925
+ """Return whether `path` is an existing directory, ignoring stat failures.
926
+
927
+ See `_safe_exists` for why probes are guarded.
928
+
929
+ Args:
930
+ path: Path candidate to probe.
931
+
932
+ Returns:
933
+ `True` if the path is a directory, `False` otherwise or on failure.
934
+ """
935
+ try:
936
+ return path.is_dir()
937
+ except OSError as e:
938
+ logger.debug("is_dir() check failed for %r: %s", path, e)
939
+ return False
940
+
941
+
942
+ def _resolve_existing_pasted_path(path: Path) -> Path | None:
943
+ """Resolve a pasted path candidate to an existing file.
944
+
945
+ Performs an exact resolution first, then a Unicode-space-tolerant lookup.
946
+
947
+ Args:
948
+ path: Parsed path candidate.
949
+
950
+ Returns:
951
+ Resolved existing file path, otherwise `None`.
952
+ """
953
+ try:
954
+ resolved = path.expanduser().resolve()
955
+ except (OSError, RuntimeError) as e:
956
+ logger.debug("Path resolution failed for %r: %s", path, e)
957
+ return None
958
+ if _safe_is_file(resolved):
959
+ return resolved
960
+
961
+ fuzzy = _resolve_with_unicode_space_variants(path)
962
+ if fuzzy is None:
963
+ return None
964
+ try:
965
+ resolved_fuzzy = fuzzy.resolve()
966
+ except (OSError, RuntimeError) as e:
967
+ logger.debug("Unicode-space resolution failed for %r: %s", fuzzy, e)
968
+ return None
969
+ if _safe_is_file(resolved_fuzzy):
970
+ return resolved_fuzzy
971
+ return None
972
+
973
+
974
+ def _normalize_unicode_spaces(text: str) -> str:
975
+ """Normalize Unicode lookalike spaces to ASCII spaces.
976
+
977
+ Args:
978
+ text: Text to normalize.
979
+
980
+ Returns:
981
+ Normalized text with Unicode-space variants converted to ASCII spaces.
982
+ """
983
+ return text.translate(_UNICODE_SPACE_EQUIVALENTS)
984
+
985
+
986
+ def _resolve_with_unicode_space_variants(path: Path) -> Path | None:
987
+ """Resolve path by matching filename segments with Unicode space variants.
988
+
989
+ Args:
990
+ path: Path candidate that may differ from disk by space code points.
991
+
992
+ Returns:
993
+ Matching filesystem path, or `None` when no variant match exists.
994
+ """
995
+ expanded = path.expanduser()
996
+ if expanded.is_absolute():
997
+ current = Path(expanded.anchor)
998
+ parts = expanded.parts[1:]
999
+ else:
1000
+ current = Path.cwd()
1001
+ parts = expanded.parts
1002
+
1003
+ for index, part in enumerate(parts):
1004
+ candidate = current / part
1005
+ if _safe_exists(candidate):
1006
+ current = candidate
1007
+ continue
1008
+
1009
+ if not _safe_is_dir(current):
1010
+ return None
1011
+ if " " not in part and "\u00a0" not in part and "\u202f" not in part:
1012
+ return None
1013
+
1014
+ normalized_part = _normalize_unicode_spaces(part)
1015
+ try:
1016
+ matches = [
1017
+ entry
1018
+ for entry in current.iterdir()
1019
+ if _normalize_unicode_spaces(entry.name) == normalized_part
1020
+ ]
1021
+ except OSError as e:
1022
+ logger.debug("Failed listing %s for Unicode-space lookup: %s", current, e)
1023
+ return None
1024
+
1025
+ if not matches:
1026
+ return None
1027
+
1028
+ is_last = index == len(parts) - 1
1029
+ if is_last:
1030
+ file_matches = [entry for entry in matches if _safe_is_file(entry)]
1031
+ if file_matches:
1032
+ matches = file_matches
1033
+ else:
1034
+ dir_matches = [entry for entry in matches if _safe_is_dir(entry)]
1035
+ if dir_matches:
1036
+ matches = dir_matches
1037
+
1038
+ matches.sort(key=lambda entry: entry.name)
1039
+ current = matches[0]
1040
+
1041
+ return current