soothe-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,782 @@
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
7
+ from pathlib import Path
8
+ from typing import Literal
9
+ from urllib.parse import unquote, urlparse
10
+
11
+ from rich.markup import escape as escape_markup
12
+
13
+ from soothe_cli.tui.config import console
14
+ from soothe_cli.tui.media_utils import ImageData, VideoData
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ PATH_CHAR_CLASS = r"A-Za-z0-9._~/\\:-"
19
+ """Characters allowed in file paths.
20
+
21
+ Includes alphanumeric, period, underscore, tilde (home), forward/back slashes
22
+ (path separators), colon (Windows drive letters), and hyphen.
23
+ """
24
+
25
+ FILE_MENTION_PATTERN = re.compile(r"@(?P<path>(?:\\.|[" + PATH_CHAR_CLASS + r"])+)")
26
+ """Pattern for extracting `@file` mentions from input text.
27
+
28
+ Matches `@` followed by one or more path characters or escaped character
29
+ pairs (backslash + any character, e.g., `\\ ` for spaces in paths).
30
+
31
+ Uses `+` (not `*`) because a bare `@` without a path is not a valid
32
+ file reference.
33
+ """
34
+
35
+ EMAIL_PREFIX_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]$")
36
+ """Pattern to detect email-like text preceding an `@` symbol.
37
+
38
+ If the character immediately before `@` matches this pattern, the `@mention`
39
+ is likely part of an email address (e.g., `user@example.com`) rather than
40
+ a file reference.
41
+ """
42
+
43
+ INPUT_HIGHLIGHT_PATTERN = re.compile(r"(^\/[a-zA-Z0-9_-]+|@(?:\\.|[" + PATH_CHAR_CLASS + r"])+)")
44
+ """Pattern for highlighting `@mentions` and `/commands` in rendered
45
+ user messages.
46
+
47
+ Matches either:
48
+ - Slash commands at the start of the string (e.g., `/help`)
49
+ - `@file` mentions anywhere in the text (e.g., `@README.md`)
50
+
51
+ Note: The `^` anchor matches start of string, not start of line. The consumer
52
+ in `UserMessage.compose()` additionally checks `start == 0` before styling
53
+ slash commands, so a `/` mid-string is not highlighted.
54
+ """
55
+
56
+ MediaKind = Literal["image", "video"]
57
+ """Accepted values for the `kind` parameter in `MediaTracker` methods."""
58
+
59
+ IMAGE_PLACEHOLDER_PATTERN = re.compile(r"\[image (?P<id>\d+)\]")
60
+ """Pattern for image placeholders with a named `id` capture group.
61
+
62
+ Used to extract numeric IDs from placeholder tokens so the tracker can prune
63
+ stale entries and compute the next available ID.
64
+ """
65
+
66
+ VIDEO_PLACEHOLDER_PATTERN = re.compile(r"\[video (?P<id>\d+)\]")
67
+ """Pattern for video placeholders with a named `id` capture group.
68
+
69
+ Used to extract numeric IDs from placeholder tokens so the tracker can prune
70
+ stale entries and compute the next available ID.
71
+ """
72
+
73
+ _UNICODE_SPACE_EQUIVALENTS = str.maketrans(
74
+ {
75
+ "\u00a0": " ", # NO-BREAK SPACE
76
+ "\u202f": " ", # NARROW NO-BREAK SPACE
77
+ }
78
+ )
79
+ """Translation table used to normalize Unicode space variants.
80
+
81
+ Some macOS-generated filenames (for example screenshots) may contain non-ASCII
82
+ space code points that look identical to normal spaces when pasted.
83
+ """
84
+
85
+ _WINDOWS_DRIVE_PATH_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
86
+ """Pattern for Windows drive-letter paths like `C:\\Users\\...`."""
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ParsedPastedPathPayload:
91
+ """Unified parse result for dropped-path payload detection.
92
+
93
+ Attributes:
94
+ paths: Resolved file paths parsed from the input payload.
95
+ token_end: End index (exclusive) of the parsed leading token when the
96
+ payload starts with a path followed by trailing text.
97
+
98
+ `None` means the entire payload was parsed as path-only content.
99
+ """
100
+
101
+ paths: list[Path]
102
+ token_end: int | None = None
103
+
104
+
105
+ class MediaTracker:
106
+ """Track pasted images and videos in the current conversation."""
107
+
108
+ def __init__(self) -> None:
109
+ """Initialize an empty media tracker.
110
+
111
+ Sets up empty lists to store images and videos, and initializes the
112
+ ID counters to 1 for generating unique placeholder identifiers.
113
+ """
114
+ self.images: list[ImageData] = []
115
+ self.videos: list[VideoData] = []
116
+ self.next_image_id: int = 1
117
+ self.next_video_id: int = 1
118
+
119
+ def add_media(self, data: ImageData | VideoData, kind: MediaKind) -> str:
120
+ """Add a media item and return its placeholder text.
121
+
122
+ Args:
123
+ data: The image or video data to track.
124
+ kind: Media type key.
125
+
126
+ Returns:
127
+ Placeholder string like "[image 1]" or "[video 1]".
128
+ """
129
+ if kind == "image":
130
+ placeholder = f"[image {self.next_image_id}]"
131
+ data.placeholder = placeholder
132
+ self.images.append(data) # type: ignore[arg-type]
133
+ self.next_image_id += 1
134
+ else:
135
+ placeholder = f"[video {self.next_video_id}]"
136
+ data.placeholder = placeholder
137
+ self.videos.append(data) # type: ignore[arg-type]
138
+ self.next_video_id += 1
139
+ return placeholder
140
+
141
+ def add_image(self, image_data: ImageData) -> str:
142
+ """Add an image and return its placeholder text.
143
+
144
+ Args:
145
+ image_data: The image data to track.
146
+
147
+ Returns:
148
+ Placeholder string like "[image 1]".
149
+ """
150
+ return self.add_media(image_data, "image")
151
+
152
+ def add_video(self, video_data: VideoData) -> str:
153
+ """Add a video and return its placeholder text.
154
+
155
+ Args:
156
+ video_data: The video data to track.
157
+
158
+ Returns:
159
+ Placeholder string like "[video 1]".
160
+ """
161
+ return self.add_media(video_data, "video")
162
+
163
+ def get_media(self, kind: MediaKind) -> list[ImageData] | list[VideoData]:
164
+ """Get all tracked media of a given type.
165
+
166
+ Args:
167
+ kind: Media type key.
168
+
169
+ Returns:
170
+ Copy of the list of tracked media items.
171
+ """
172
+ if kind == "image":
173
+ return list(self.images)
174
+ return list(self.videos)
175
+
176
+ def get_images(self) -> list[ImageData]:
177
+ """Get all tracked images.
178
+
179
+ Returns:
180
+ Copy of the list of tracked images.
181
+ """
182
+ return list(self.images)
183
+
184
+ def get_videos(self) -> list[VideoData]:
185
+ """Get all tracked videos.
186
+
187
+ Returns:
188
+ Copy of the list of tracked videos.
189
+ """
190
+ return list(self.videos)
191
+
192
+ def clear(self) -> None:
193
+ """Clear all tracked media and reset counters."""
194
+ self.images.clear()
195
+ self.videos.clear()
196
+ self.next_image_id = 1
197
+ self.next_video_id = 1
198
+
199
+ def sync_to_text(self, text: str) -> None:
200
+ """Retain only media still referenced by placeholders in current text.
201
+
202
+ Args:
203
+ text: Current input text shown to the user.
204
+ """
205
+ img_found = self._sync_kind_images(text)
206
+ vid_found = self._sync_kind_videos(text)
207
+ if not img_found and not vid_found:
208
+ self.clear()
209
+
210
+ def _sync_kind_images(self, text: str) -> bool:
211
+ """Sync image list to surviving placeholders in text.
212
+
213
+ Args:
214
+ text: Current input text.
215
+
216
+ Returns:
217
+ Whether any image placeholders were found.
218
+ """
219
+ placeholders = {m.group(0) for m in IMAGE_PLACEHOLDER_PATTERN.finditer(text)}
220
+ self.images = [img for img in self.images if img.placeholder in placeholders]
221
+ if not self.images:
222
+ self.next_image_id = 1
223
+ else:
224
+ self.next_image_id = self._max_placeholder_id(
225
+ self.images, IMAGE_PLACEHOLDER_PATTERN, len(self.images)
226
+ )
227
+ return bool(placeholders)
228
+
229
+ def _sync_kind_videos(self, text: str) -> bool:
230
+ """Sync video list to surviving placeholders in text.
231
+
232
+ Args:
233
+ text: Current input text.
234
+
235
+ Returns:
236
+ Whether any video placeholders were found.
237
+ """
238
+ placeholders = {m.group(0) for m in VIDEO_PLACEHOLDER_PATTERN.finditer(text)}
239
+ self.videos = [vid for vid in self.videos if vid.placeholder in placeholders]
240
+ if not self.videos:
241
+ self.next_video_id = 1
242
+ else:
243
+ self.next_video_id = self._max_placeholder_id(
244
+ self.videos, VIDEO_PLACEHOLDER_PATTERN, len(self.videos)
245
+ )
246
+ return bool(placeholders)
247
+
248
+ @staticmethod
249
+ def _max_placeholder_id(
250
+ items: list[ImageData] | list[VideoData],
251
+ pattern: re.Pattern[str],
252
+ fallback_count: int,
253
+ ) -> int:
254
+ """Compute next ID from the highest surviving placeholder.
255
+
256
+ Args:
257
+ items: Surviving media items.
258
+ pattern: Placeholder regex with an `id` group.
259
+ fallback_count: Fallback when no IDs can be parsed.
260
+
261
+ Returns:
262
+ Next ID value (max_id + 1).
263
+ """
264
+ max_id = 0
265
+ for item in items:
266
+ match = pattern.fullmatch(item.placeholder)
267
+ if match is not None:
268
+ max_id = max(max_id, int(match.group("id")))
269
+ return max_id + 1 if max_id else fallback_count + 1
270
+
271
+
272
+ def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
273
+ r"""Extract `@file` mentions and return the text with resolved file paths.
274
+
275
+ Parses `@file` mentions from the input text and resolves them to absolute
276
+ file paths. Files that do not exist or cannot be resolved are excluded with
277
+ a warning printed to the console.
278
+
279
+ Email addresses (e.g., `user@example.com`) are automatically excluded by
280
+ detecting email-like characters before the `@` symbol.
281
+
282
+ Backslash-escaped spaces in paths (e.g., `@my\ folder/file.txt`) are
283
+ unescaped before resolution. Tilde paths (e.g., `@~/file.txt`) are expanded
284
+ via `Path.expanduser()`. Only regular files are returned; directories are
285
+ excluded.
286
+
287
+ This function does not raise exceptions; invalid paths are handled
288
+ internally with a console warning.
289
+
290
+ Args:
291
+ text: Input text potentially containing `@file` mentions.
292
+
293
+ Returns:
294
+ Tuple of (original text unchanged, list of resolved file paths that exist).
295
+ """
296
+ matches = FILE_MENTION_PATTERN.finditer(text)
297
+
298
+ files = []
299
+ for match in matches:
300
+ # Skip if this looks like an email address
301
+ text_before = text[: match.start()]
302
+ if text_before and EMAIL_PREFIX_PATTERN.search(text_before):
303
+ continue
304
+
305
+ raw_path = match.group("path")
306
+ clean_path = raw_path.replace("\\ ", " ")
307
+
308
+ try:
309
+ path = Path(clean_path).expanduser()
310
+
311
+ if not path.is_absolute():
312
+ path = Path.cwd() / path
313
+
314
+ resolved = path.resolve()
315
+ if resolved.exists() and resolved.is_file():
316
+ files.append(resolved)
317
+ else:
318
+ console.print(
319
+ f"[yellow]Warning: File not found: {escape_markup(raw_path)}[/yellow]"
320
+ )
321
+ except (OSError, RuntimeError) as e:
322
+ console.print(
323
+ f"[yellow]Warning: Invalid path {escape_markup(raw_path)}: {escape_markup(str(e))}[/yellow]"
324
+ )
325
+
326
+ return text, files
327
+
328
+
329
+ def parse_pasted_file_paths(text: str) -> list[Path]:
330
+ r"""Parse a paste payload that may contain dragged-and-dropped file paths.
331
+
332
+ The parser is strict on purpose: it only returns paths when the entire paste
333
+ payload can be interpreted as one or more existing files. Any invalid token
334
+ falls back to normal text paste behavior by returning an empty list.
335
+
336
+ Supports common dropped-path formats:
337
+
338
+ - Absolute/relative paths
339
+ - POSIX shell quoting and escaping
340
+ - `file://` URLs
341
+
342
+ Args:
343
+ text: Raw paste payload from the terminal.
344
+
345
+ Returns:
346
+ List of resolved file paths, or an empty list when parsing fails.
347
+ """
348
+ payload = text.strip()
349
+ if not payload:
350
+ return []
351
+
352
+ tokens: list[str] = []
353
+ for raw_line in payload.splitlines():
354
+ line = raw_line.strip()
355
+ if not line:
356
+ continue
357
+ line_tokens = _split_paste_line(line)
358
+ if not line_tokens:
359
+ return []
360
+ tokens.extend(line_tokens)
361
+
362
+ if not tokens:
363
+ return []
364
+
365
+ paths: list[Path] = []
366
+ for token in tokens:
367
+ path = _token_to_path(token)
368
+ if path is None:
369
+ return []
370
+ resolved = _resolve_existing_pasted_path(path)
371
+ if resolved is None:
372
+ return []
373
+ paths.append(resolved)
374
+
375
+ return paths
376
+
377
+
378
+ def parse_pasted_path_payload(
379
+ text: str, *, allow_leading_path: bool = False
380
+ ) -> ParsedPastedPathPayload | None:
381
+ """Parse dropped-path payload variants through one entrypoint.
382
+
383
+ Parsing order is:
384
+ 1. strict multi-path payload parsing (`parse_pasted_file_paths`)
385
+ 2. single-path normalization/parsing (`parse_single_pasted_file_path`)
386
+ 3. optional leading-path extraction (`extract_leading_pasted_file_path`)
387
+
388
+ Args:
389
+ text: Input payload to parse.
390
+ allow_leading_path: Whether to parse a leading path token followed by
391
+ trailing prompt text.
392
+
393
+ Returns:
394
+ Parsed payload details, otherwise `None`.
395
+ """
396
+ paths = parse_pasted_file_paths(text)
397
+ if paths:
398
+ return ParsedPastedPathPayload(paths=paths)
399
+
400
+ single_path = parse_single_pasted_file_path(text)
401
+ if single_path is not None:
402
+ return ParsedPastedPathPayload(paths=[single_path])
403
+
404
+ if not allow_leading_path:
405
+ return None
406
+
407
+ leading = extract_leading_pasted_file_path(text)
408
+ if leading is None:
409
+ return None
410
+
411
+ path, token_end = leading
412
+ return ParsedPastedPathPayload(paths=[path], token_end=token_end)
413
+
414
+
415
+ def parse_single_pasted_file_path(text: str) -> Path | None:
416
+ """Parse and resolve a single pasted path payload.
417
+
418
+ Unlike `parse_pasted_file_paths`, this helper only accepts one path token
419
+ and is intended for fallback handling when a paste event carries a
420
+ single path representation.
421
+
422
+ Args:
423
+ text: Raw pasted text payload.
424
+
425
+ Returns:
426
+ Resolved path when payload is a single existing file, otherwise `None`.
427
+ """
428
+ candidate = normalize_pasted_path(text)
429
+ if candidate is None:
430
+ return None
431
+ return _resolve_existing_pasted_path(candidate)
432
+
433
+
434
+ def extract_leading_pasted_file_path(text: str) -> tuple[Path, int] | None:
435
+ """Extract and resolve a leading pasted path token from input text.
436
+
437
+ This is used for submit-time recovery when a user message starts with a
438
+ path token followed by additional prompt text.
439
+
440
+ Args:
441
+ text: Input text to inspect.
442
+
443
+ Returns:
444
+ Tuple of `(resolved_path, token_end_index)` or `None` when no valid
445
+ leading file path token exists.
446
+ """
447
+ if not text:
448
+ return None
449
+
450
+ start = len(text) - len(text.lstrip())
451
+ payload = text[start:]
452
+ token_end = _leading_token_end(payload)
453
+ if token_end is None:
454
+ return None
455
+
456
+ token_text = payload[:token_end]
457
+ path = parse_single_pasted_file_path(token_text)
458
+ if path is None:
459
+ spaced = _extract_unquoted_leading_path_with_spaces(payload)
460
+ if spaced is None:
461
+ return None
462
+ spaced_path, spaced_end = spaced
463
+ return spaced_path, start + spaced_end
464
+
465
+ return path, start + token_end
466
+
467
+
468
+ def normalize_pasted_path(text: str) -> Path | None:
469
+ """Normalize pasted text that may represent a single filesystem path.
470
+
471
+ Supports:
472
+
473
+ - quoted and shell-escaped single paths
474
+ - `file://` URLs
475
+ - Windows drive-letter and UNC paths
476
+
477
+ Args:
478
+ text: Raw pasted text payload.
479
+
480
+ Returns:
481
+ Parsed `Path` if payload is a single path token, otherwise `None`.
482
+ """
483
+ payload = text.strip()
484
+ if not payload:
485
+ return None
486
+
487
+ unquoted = (
488
+ payload.removeprefix('"').removesuffix('"')
489
+ if payload.startswith('"') and payload.endswith('"')
490
+ else payload
491
+ )
492
+ unquoted = (
493
+ unquoted.removeprefix("'").removesuffix("'")
494
+ if unquoted.startswith("'") and unquoted.endswith("'")
495
+ else unquoted
496
+ )
497
+
498
+ if unquoted.startswith("file://"):
499
+ return _token_to_path(unquoted)
500
+
501
+ windows_path = _normalize_windows_pasted_path(unquoted)
502
+ if windows_path is not None:
503
+ return windows_path
504
+
505
+ posix_path = _normalize_posix_pasted_path(unquoted)
506
+ if posix_path is not None:
507
+ return posix_path
508
+
509
+ parts = _split_paste_line(payload)
510
+ if len(parts) != 1:
511
+ return None
512
+ token = parts[0]
513
+ path = _token_to_path(token)
514
+ if path is None:
515
+ return None
516
+ windows_token_path = _normalize_windows_pasted_path(str(path))
517
+ if windows_token_path is not None:
518
+ return windows_token_path
519
+ return path
520
+
521
+
522
+ def _split_paste_line(line: str) -> list[str]:
523
+ """Split a single pasted line into path-like tokens.
524
+
525
+ Args:
526
+ line: A single line from the paste payload.
527
+
528
+ Returns:
529
+ Parsed shell-like tokens, or an empty list when parsing fails.
530
+ """
531
+ try:
532
+ return shlex.split(line, posix=True)
533
+ except ValueError:
534
+ # Unbalanced quotes or other tokenization errors: treat as plain text.
535
+ return []
536
+
537
+
538
+ def _token_to_path(token: str) -> Path | None:
539
+ """Convert a pasted token into a path candidate.
540
+
541
+ Args:
542
+ token: A single shell-split token from the paste payload.
543
+
544
+ Returns:
545
+ A parsed path candidate, or `None` when token parsing fails.
546
+ """
547
+ value = token.strip()
548
+ if not value:
549
+ return None
550
+
551
+ if value.startswith("<") and value.endswith(">"):
552
+ value = value[1:-1].strip()
553
+ if not value:
554
+ return None
555
+
556
+ if value.startswith("file://"):
557
+ parsed = urlparse(value)
558
+ path_text = unquote(parsed.path or "")
559
+ if parsed.netloc and parsed.netloc != "localhost":
560
+ path_text = f"//{parsed.netloc}{path_text}"
561
+ if (
562
+ path_text.startswith("/")
563
+ and len(path_text) > 2 # noqa: PLR2004 # '/C:' minimum for Windows file URI
564
+ and path_text[2] == ":"
565
+ and path_text[1].isalpha()
566
+ ):
567
+ # `file:///C:/...` on Windows includes an extra leading slash.
568
+ path_text = path_text[1:]
569
+ if not path_text:
570
+ return None
571
+ return Path(path_text)
572
+
573
+ return Path(value)
574
+
575
+
576
+ def _leading_token_end(text: str) -> int | None:
577
+ """Return the end index of the first shell-like token.
578
+
579
+ Args:
580
+ text: Input text beginning with a token.
581
+
582
+ Returns:
583
+ End index (exclusive), or `None` when token parsing fails.
584
+ """
585
+ if not text:
586
+ return None
587
+
588
+ if text[0] in {'"', "'"}:
589
+ quote = text[0]
590
+ escaped = False
591
+ for index in range(1, len(text)):
592
+ char = text[index]
593
+ if char == "\\" and not escaped:
594
+ escaped = True
595
+ continue
596
+ if char == quote and not escaped:
597
+ return index + 1
598
+ escaped = False
599
+ return None
600
+
601
+ escaped = False
602
+ for index, char in enumerate(text):
603
+ if char == "\\" and not escaped:
604
+ escaped = True
605
+ continue
606
+ if char.isspace() and not escaped:
607
+ return index
608
+ escaped = False
609
+ return len(text)
610
+
611
+
612
+ def _extract_unquoted_leading_path_with_spaces(text: str) -> tuple[Path, int] | None:
613
+ """Extract a leading unquoted path that may contain spaces.
614
+
615
+ This fallback is intentionally POSIX-oriented (`/` and `~/`) because the
616
+ slash-command conflict it addresses is specific to inputs that begin with
617
+ `/`.
618
+
619
+ Args:
620
+ text: Input text beginning with a potential path.
621
+
622
+ Returns:
623
+ Tuple of `(resolved_path, token_end_index)` or `None` when no matching
624
+ leading path prefix resolves to an existing file.
625
+ """
626
+ if not text or ("\n" in text or "\r" in text):
627
+ return None
628
+ if not text.startswith(("/", "~/")):
629
+ return None
630
+ if " " not in text and "\u00a0" not in text and "\u202f" not in text:
631
+ return None
632
+
633
+ boundaries = [index for index, char in enumerate(text) if char.isspace()]
634
+ boundaries.append(len(text))
635
+ for end in reversed(boundaries):
636
+ candidate = text[:end].rstrip()
637
+ if not candidate:
638
+ continue
639
+ path = parse_single_pasted_file_path(candidate)
640
+ if path is not None:
641
+ return path, len(candidate)
642
+ return None
643
+
644
+
645
+ def _normalize_windows_pasted_path(text: str) -> Path | None:
646
+ """Return a `Path` for unquoted Windows drive/UNC path inputs.
647
+
648
+ Args:
649
+ text: Potential Windows path input.
650
+
651
+ Returns:
652
+ Parsed `Path` when `text` is Windows drive-letter or UNC style,
653
+ otherwise `None`.
654
+ """
655
+ if _WINDOWS_DRIVE_PATH_PATTERN.match(text) or text.startswith("\\\\"):
656
+ return Path(text)
657
+ return None
658
+
659
+
660
+ def _normalize_posix_pasted_path(text: str) -> Path | None:
661
+ """Return a `Path` for likely POSIX absolute/home path payloads.
662
+
663
+ Some terminals paste dropped absolute paths with spaces as raw text without
664
+ quoting/escaping. In that case shell tokenization splits on spaces even
665
+ though the full payload is intended to be a single path.
666
+
667
+ Args:
668
+ text: Potential POSIX path input.
669
+
670
+ Returns:
671
+ Parsed `Path` when `text` looks like a raw POSIX absolute/home path,
672
+ otherwise `None`.
673
+ """
674
+ if "\n" in text or "\r" in text:
675
+ return None
676
+ if text.startswith("~/"):
677
+ return Path(text)
678
+ if text.startswith("/") and "/" in text[1:]:
679
+ return Path(text)
680
+ return None
681
+
682
+
683
+ def _resolve_existing_pasted_path(path: Path) -> Path | None:
684
+ """Resolve a pasted path candidate to an existing file.
685
+
686
+ Performs an exact resolution first, then a Unicode-space-tolerant lookup.
687
+
688
+ Args:
689
+ path: Parsed path candidate.
690
+
691
+ Returns:
692
+ Resolved existing file path, otherwise `None`.
693
+ """
694
+ try:
695
+ resolved = path.expanduser().resolve()
696
+ except (OSError, RuntimeError) as e:
697
+ logger.debug("Path resolution failed for %r: %s", path, e)
698
+ return None
699
+ if resolved.exists() and resolved.is_file():
700
+ return resolved
701
+
702
+ fuzzy = _resolve_with_unicode_space_variants(path)
703
+ if fuzzy is None:
704
+ return None
705
+ try:
706
+ resolved_fuzzy = fuzzy.resolve()
707
+ except (OSError, RuntimeError) as e:
708
+ logger.debug("Unicode-space resolution failed for %r: %s", fuzzy, e)
709
+ return None
710
+ if resolved_fuzzy.exists() and resolved_fuzzy.is_file():
711
+ return resolved_fuzzy
712
+ return None
713
+
714
+
715
+ def _normalize_unicode_spaces(text: str) -> str:
716
+ """Normalize Unicode lookalike spaces to ASCII spaces.
717
+
718
+ Args:
719
+ text: Text to normalize.
720
+
721
+ Returns:
722
+ Normalized text with Unicode-space variants converted to ASCII spaces.
723
+ """
724
+ return text.translate(_UNICODE_SPACE_EQUIVALENTS)
725
+
726
+
727
+ def _resolve_with_unicode_space_variants(path: Path) -> Path | None:
728
+ """Resolve path by matching filename segments with Unicode space variants.
729
+
730
+ Args:
731
+ path: Path candidate that may differ from disk by space code points.
732
+
733
+ Returns:
734
+ Matching filesystem path, or `None` when no variant match exists.
735
+ """
736
+ expanded = path.expanduser()
737
+ if expanded.is_absolute():
738
+ current = Path(expanded.anchor)
739
+ parts = expanded.parts[1:]
740
+ else:
741
+ current = Path.cwd()
742
+ parts = expanded.parts
743
+
744
+ for index, part in enumerate(parts):
745
+ candidate = current / part
746
+ if candidate.exists():
747
+ current = candidate
748
+ continue
749
+
750
+ if not current.exists() or not current.is_dir():
751
+ return None
752
+ if " " not in part and "\u00a0" not in part and "\u202f" not in part:
753
+ return None
754
+
755
+ normalized_part = _normalize_unicode_spaces(part)
756
+ try:
757
+ matches = [
758
+ entry
759
+ for entry in current.iterdir()
760
+ if _normalize_unicode_spaces(entry.name) == normalized_part
761
+ ]
762
+ except OSError as e:
763
+ logger.debug("Failed listing %s for Unicode-space lookup: %s", current, e)
764
+ return None
765
+
766
+ if not matches:
767
+ return None
768
+
769
+ is_last = index == len(parts) - 1
770
+ if is_last:
771
+ file_matches = [entry for entry in matches if entry.is_file()]
772
+ if file_matches:
773
+ matches = file_matches
774
+ else:
775
+ dir_matches = [entry for entry in matches if entry.is_dir()]
776
+ if dir_matches:
777
+ matches = dir_matches
778
+
779
+ matches.sort(key=lambda entry: entry.name)
780
+ current = matches[0]
781
+
782
+ return current