klaude-code 2.5.3__py3-none-any.whl → 2.7.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.
- klaude_code/app/runtime.py +1 -1
- klaude_code/auth/__init__.py +10 -0
- klaude_code/auth/env.py +81 -0
- klaude_code/cli/auth_cmd.py +87 -8
- klaude_code/cli/config_cmd.py +5 -5
- klaude_code/cli/cost_cmd.py +159 -60
- klaude_code/cli/main.py +146 -65
- klaude_code/cli/self_update.py +7 -7
- klaude_code/config/builtin_config.py +23 -9
- klaude_code/config/config.py +19 -9
- klaude_code/const.py +10 -1
- klaude_code/core/reminders.py +4 -5
- klaude_code/core/turn.py +8 -9
- klaude_code/llm/google/client.py +12 -0
- klaude_code/llm/openai_compatible/stream.py +5 -1
- klaude_code/llm/openrouter/client.py +1 -0
- klaude_code/protocol/commands.py +0 -1
- klaude_code/protocol/events.py +214 -0
- klaude_code/protocol/sub_agent/image_gen.py +0 -4
- klaude_code/session/session.py +51 -18
- klaude_code/skill/loader.py +12 -13
- klaude_code/skill/manager.py +3 -3
- klaude_code/tui/command/__init__.py +1 -4
- klaude_code/tui/command/copy_cmd.py +1 -1
- klaude_code/tui/command/fork_session_cmd.py +4 -4
- klaude_code/tui/commands.py +0 -5
- klaude_code/tui/components/command_output.py +1 -1
- klaude_code/tui/components/metadata.py +4 -5
- klaude_code/tui/components/rich/markdown.py +60 -0
- klaude_code/tui/components/rich/theme.py +8 -0
- klaude_code/tui/components/sub_agent.py +6 -0
- klaude_code/tui/components/user_input.py +38 -27
- klaude_code/tui/display.py +11 -1
- klaude_code/tui/input/AGENTS.md +44 -0
- klaude_code/tui/input/completers.py +21 -21
- klaude_code/tui/input/drag_drop.py +197 -0
- klaude_code/tui/input/images.py +227 -0
- klaude_code/tui/input/key_bindings.py +173 -19
- klaude_code/tui/input/paste.py +71 -0
- klaude_code/tui/input/prompt_toolkit.py +13 -3
- klaude_code/tui/machine.py +90 -56
- klaude_code/tui/renderer.py +1 -62
- klaude_code/tui/runner.py +1 -1
- klaude_code/tui/terminal/image.py +40 -9
- klaude_code/tui/terminal/selector.py +52 -2
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/METADATA +32 -40
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/RECORD +49 -54
- klaude_code/cli/session_cmd.py +0 -87
- klaude_code/protocol/events/__init__.py +0 -63
- klaude_code/protocol/events/base.py +0 -18
- klaude_code/protocol/events/chat.py +0 -30
- klaude_code/protocol/events/lifecycle.py +0 -23
- klaude_code/protocol/events/metadata.py +0 -16
- klaude_code/protocol/events/streaming.py +0 -43
- klaude_code/protocol/events/system.py +0 -56
- klaude_code/protocol/events/tools.py +0 -27
- klaude_code/tui/command/terminal_setup_cmd.py +0 -248
- klaude_code/tui/input/clipboard.py +0 -152
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/entry_points.txt +0 -0
|
@@ -12,29 +12,52 @@ from klaude_code.tui.components.rich.theme import ThemeKey
|
|
|
12
12
|
# patterns such as foo@bar.com as file references.
|
|
13
13
|
AT_FILE_RENDER_PATTERN = re.compile(r'(?<!\S)@("([^"]+)"|\S+)')
|
|
14
14
|
|
|
15
|
-
# Match $skill or ¥skill pattern at
|
|
16
|
-
SKILL_RENDER_PATTERN = re.compile(r"
|
|
15
|
+
# Match $skill or ¥skill pattern inline (at start of line or after whitespace)
|
|
16
|
+
SKILL_RENDER_PATTERN = re.compile(r"(?<!\S)[$¥](\S+)")
|
|
17
17
|
|
|
18
18
|
USER_MESSAGE_MARK = "❯ "
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
21
|
+
def render_at_and_skill_patterns(
|
|
22
22
|
text: str,
|
|
23
23
|
at_style: str = ThemeKey.USER_INPUT_AT_PATTERN,
|
|
24
|
+
skill_style: str = ThemeKey.USER_INPUT_SKILL,
|
|
24
25
|
other_style: str = ThemeKey.USER_INPUT,
|
|
25
26
|
) -> Text:
|
|
26
|
-
|
|
27
|
+
"""Render text with highlighted @file and $skill patterns."""
|
|
28
|
+
has_at = "@" in text
|
|
29
|
+
has_skill = "$" in text or "\u00a5" in text # $ or ¥
|
|
30
|
+
|
|
31
|
+
if not has_at and not has_skill:
|
|
27
32
|
return Text(text, style=other_style)
|
|
28
33
|
|
|
34
|
+
# Collect all matches with their styles
|
|
35
|
+
matches: list[tuple[int, int, str]] = [] # (start, end, style)
|
|
36
|
+
|
|
37
|
+
if has_at:
|
|
38
|
+
for match in AT_FILE_RENDER_PATTERN.finditer(text):
|
|
39
|
+
matches.append((match.start(), match.end(), at_style))
|
|
40
|
+
|
|
41
|
+
if has_skill:
|
|
42
|
+
for match in SKILL_RENDER_PATTERN.finditer(text):
|
|
43
|
+
skill_name = match.group(1)
|
|
44
|
+
if _is_valid_skill_name(skill_name):
|
|
45
|
+
matches.append((match.start(), match.end(), skill_style))
|
|
46
|
+
|
|
47
|
+
if not matches:
|
|
48
|
+
return Text(text, style=other_style)
|
|
49
|
+
|
|
50
|
+
# Sort by start position
|
|
51
|
+
matches.sort(key=lambda x: x[0])
|
|
52
|
+
|
|
29
53
|
result = Text("")
|
|
30
54
|
last_end = 0
|
|
31
|
-
for
|
|
32
|
-
start
|
|
55
|
+
for start, end, style in matches:
|
|
56
|
+
if start < last_end:
|
|
57
|
+
continue # Skip overlapping matches
|
|
33
58
|
if start > last_end:
|
|
34
|
-
# Text before the @-pattern
|
|
35
59
|
result.append_text(Text(text[last_end:start], other_style))
|
|
36
|
-
|
|
37
|
-
result.append_text(Text(text[start:end], at_style))
|
|
60
|
+
result.append_text(Text(text[start:end], style))
|
|
38
61
|
last_end = end
|
|
39
62
|
|
|
40
63
|
if last_end < len(text):
|
|
@@ -54,37 +77,25 @@ def render_user_input(content: str) -> RenderableType:
|
|
|
54
77
|
"""Render a user message as a group of quoted lines with styles.
|
|
55
78
|
|
|
56
79
|
- Highlights slash command token on the first line
|
|
57
|
-
- Highlights $skill
|
|
58
|
-
- Highlights @file patterns in all lines
|
|
80
|
+
- Highlights @file and $skill patterns in all lines
|
|
59
81
|
"""
|
|
60
82
|
lines = content.strip().split("\n")
|
|
61
83
|
renderables: list[RenderableType] = []
|
|
62
84
|
for i, line in enumerate(lines):
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
# Handle slash command on first line
|
|
65
86
|
if i == 0 and line.startswith("/"):
|
|
66
87
|
splits = line.split(" ", maxsplit=1)
|
|
67
88
|
line_text = Text.assemble(
|
|
68
89
|
(splits[0], ThemeKey.USER_INPUT_SLASH_COMMAND),
|
|
69
90
|
" ",
|
|
70
|
-
|
|
91
|
+
render_at_and_skill_patterns(splits[1]) if len(splits) > 1 else Text(""),
|
|
71
92
|
)
|
|
72
93
|
renderables.append(line_text)
|
|
73
94
|
continue
|
|
74
95
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
skill_token = m.group(0) # e.g. "$skill-name"
|
|
79
|
-
rest = line[len(skill_token) :]
|
|
80
|
-
line_text = Text.assemble(
|
|
81
|
-
(skill_token, ThemeKey.USER_INPUT_SKILL),
|
|
82
|
-
render_at_pattern(rest) if rest else Text(""),
|
|
83
|
-
)
|
|
84
|
-
renderables.append(line_text)
|
|
85
|
-
continue
|
|
86
|
-
|
|
87
|
-
renderables.append(line_text)
|
|
96
|
+
# Render @file and $skill patterns
|
|
97
|
+
renderables.append(render_at_and_skill_patterns(line))
|
|
98
|
+
|
|
88
99
|
grid = create_grid()
|
|
89
100
|
grid.padding = (0, 0)
|
|
90
101
|
mark = Text(USER_MESSAGE_MARK, style=ThemeKey.USER_INPUT_PROMPT)
|
klaude_code/tui/display.py
CHANGED
|
@@ -30,8 +30,18 @@ class TUIDisplay(DisplayABC):
|
|
|
30
30
|
|
|
31
31
|
@override
|
|
32
32
|
async def consume_event(self, event: events.Event) -> None:
|
|
33
|
+
if isinstance(event, events.ReplayHistoryEvent):
|
|
34
|
+
await self._renderer.execute(self._machine.begin_replay())
|
|
35
|
+
for item in event.events:
|
|
36
|
+
commands = self._machine.transition_replay(item)
|
|
37
|
+
if commands:
|
|
38
|
+
await self._renderer.execute(commands)
|
|
39
|
+
await self._renderer.execute(self._machine.end_replay())
|
|
40
|
+
return
|
|
41
|
+
|
|
33
42
|
commands = self._machine.transition(event)
|
|
34
|
-
|
|
43
|
+
if commands:
|
|
44
|
+
await self._renderer.execute(commands)
|
|
35
45
|
|
|
36
46
|
@override
|
|
37
47
|
async def start(self) -> None:
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# tui/input Module
|
|
2
|
+
|
|
3
|
+
REPL input handling: key bindings, paste/drag-drop conversion, and image attachment.
|
|
4
|
+
|
|
5
|
+
## Data Flow
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
User Input (keyboard/paste/drag)
|
|
9
|
+
-> key_bindings.py (event dispatch)
|
|
10
|
+
-> [convert/fold] -> buffer text with markers
|
|
11
|
+
-> Enter submit -> expand markers, write history
|
|
12
|
+
-> iter_inputs() -> extract images -> UserInputPayload
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Marker Syntax
|
|
16
|
+
|
|
17
|
+
| Marker | Purpose |
|
|
18
|
+
|--------|---------|
|
|
19
|
+
| `@<path>` | File/directory reference (triggers ReadTool) |
|
|
20
|
+
| `[image <path>]` | Image attachment (encoded on submit) |
|
|
21
|
+
| `[paste #N ...]` | Folded multi-line paste (expanded on submit) |
|
|
22
|
+
|
|
23
|
+
## Files
|
|
24
|
+
|
|
25
|
+
| File | Responsibility |
|
|
26
|
+
|------|----------------|
|
|
27
|
+
| `key_bindings.py` | Keyboard/paste event handlers; dispatches to converters; `copy_to_clipboard()` |
|
|
28
|
+
| `prompt_toolkit.py` | PromptSession setup; `iter_inputs()` submit flow |
|
|
29
|
+
| `drag_drop.py` | `file://` URI and path list to `@`/`[image]` conversion |
|
|
30
|
+
| `images.py` | Image handling: marker syntax, Ctrl+V capture, `extract_images_from_text()` |
|
|
31
|
+
| `paste.py` | `[paste #N ...]` fold/expand for large pastes |
|
|
32
|
+
| `completers.py` | `@`/`/`/`$` completion providers |
|
|
33
|
+
|
|
34
|
+
## Special Syntax Features (`@file`, `$skill`)
|
|
35
|
+
|
|
36
|
+
These features span three modules that must stay in sync:
|
|
37
|
+
|
|
38
|
+
| Stage | Module | Responsibility |
|
|
39
|
+
|-------|--------|----------------|
|
|
40
|
+
| Input | `completers.py` | Autocomplete paths/skills as user types |
|
|
41
|
+
| Display | `components/user_input.py` | Highlight syntax in rendered messages |
|
|
42
|
+
| Action | `core/reminders.py` | Inject file content / load skill for LLM |
|
|
43
|
+
|
|
44
|
+
When adding or modifying syntax (e.g., new prefix like `#tag`), update all three.
|
|
@@ -38,7 +38,8 @@ from klaude_code.protocol.commands import CommandInfo
|
|
|
38
38
|
AT_TOKEN_PATTERN = re.compile(r'(^|\s)@(?P<frag>"[^"]*"|[^\s]*)$')
|
|
39
39
|
|
|
40
40
|
# Pattern to match $skill or ¥skill token for skill completion (used by key bindings).
|
|
41
|
-
|
|
41
|
+
# Supports inline matching: after whitespace or at start of line.
|
|
42
|
+
SKILL_TOKEN_PATTERN = re.compile(r"(^|\s)[$¥](?P<frag>\S*)$")
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
def create_repl_completer(
|
|
@@ -130,10 +131,10 @@ class _SlashCommandCompleter(Completer):
|
|
|
130
131
|
|
|
131
132
|
|
|
132
133
|
class _SkillCompleter(Completer):
|
|
133
|
-
"""Complete skill names
|
|
134
|
+
"""Complete skill names with $ or ¥ prefix.
|
|
134
135
|
|
|
135
136
|
Behavior:
|
|
136
|
-
-
|
|
137
|
+
- Triggers when cursor is after $ or ¥ (at start of line or after whitespace)
|
|
137
138
|
- Shows available skills with descriptions
|
|
138
139
|
- Inserts trailing space after completion
|
|
139
140
|
"""
|
|
@@ -145,19 +146,16 @@ class _SkillCompleter(Completer):
|
|
|
145
146
|
document: Document,
|
|
146
147
|
complete_event, # type: ignore[override]
|
|
147
148
|
) -> Iterable[Completion]:
|
|
148
|
-
# Only complete on first line
|
|
149
|
-
if document.cursor_position_row != 0:
|
|
150
|
-
return
|
|
151
|
-
|
|
152
149
|
text_before = document.current_line_before_cursor
|
|
153
150
|
m = self._SKILL_TOKEN_RE.search(text_before)
|
|
154
151
|
if not m:
|
|
155
152
|
return
|
|
156
153
|
|
|
157
154
|
frag = m.group("frag").lower()
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
# Calculate token start: the match includes optional leading whitespace
|
|
156
|
+
# The actual token is $frag or ¥frag (1 char prefix + frag)
|
|
157
|
+
token_len = 1 + len(m.group("frag")) # $ or ¥ + frag
|
|
158
|
+
token_start = len(text_before) - token_len
|
|
161
159
|
start_position = token_start - len(text_before) # negative offset
|
|
162
160
|
|
|
163
161
|
# Get available skills from SkillTool
|
|
@@ -204,8 +202,6 @@ class _SkillCompleter(Completer):
|
|
|
204
202
|
|
|
205
203
|
def is_skill_context(self, document: Document) -> bool:
|
|
206
204
|
"""Check if current context is a skill completion."""
|
|
207
|
-
if document.cursor_position_row != 0:
|
|
208
|
-
return False
|
|
209
205
|
text_before = document.current_line_before_cursor
|
|
210
206
|
return bool(self._SKILL_TOKEN_RE.search(text_before))
|
|
211
207
|
|
|
@@ -228,8 +224,8 @@ class _ComboCompleter(Completer):
|
|
|
228
224
|
yield from self._slash_completer.get_completions(document, complete_event)
|
|
229
225
|
return
|
|
230
226
|
|
|
231
|
-
# Try skill completion (
|
|
232
|
-
if
|
|
227
|
+
# Try skill completion (with $ or ¥ prefix)
|
|
228
|
+
if self._skill_completer.is_skill_context(document):
|
|
233
229
|
yield from self._skill_completer.get_completions(document, complete_event)
|
|
234
230
|
return
|
|
235
231
|
|
|
@@ -313,11 +309,13 @@ class _AtFilesCompleter(Completer):
|
|
|
313
309
|
if not suggestions:
|
|
314
310
|
return [] # type: ignore[reportUnknownVariableType]
|
|
315
311
|
start_position = token_start_in_input - len(text_before)
|
|
316
|
-
|
|
312
|
+
suggestions_to_show = suggestions[: self._max_results]
|
|
313
|
+
align_width = self._display_align_width(suggestions_to_show)
|
|
314
|
+
for s in suggestions_to_show:
|
|
317
315
|
yield Completion(
|
|
318
316
|
text=self._format_completion_text(s, is_quoted=is_quoted),
|
|
319
317
|
start_position=start_position,
|
|
320
|
-
display=self._format_display_label(s,
|
|
318
|
+
display=self._format_display_label(s, align_width),
|
|
321
319
|
display_meta=s,
|
|
322
320
|
)
|
|
323
321
|
return [] # type: ignore[reportUnknownVariableType]
|
|
@@ -329,12 +327,14 @@ class _AtFilesCompleter(Completer):
|
|
|
329
327
|
|
|
330
328
|
# Prepare Completion objects. Replace from the '@' character.
|
|
331
329
|
start_position = token_start_in_input - len(text_before) # negative
|
|
332
|
-
|
|
330
|
+
suggestions_to_show = suggestions[: self._max_results]
|
|
331
|
+
align_width = self._display_align_width(suggestions_to_show)
|
|
332
|
+
for s in suggestions_to_show:
|
|
333
333
|
# Insert formatted text (with quoting when needed) so that subsequent typing does not keep triggering
|
|
334
334
|
yield Completion(
|
|
335
335
|
text=self._format_completion_text(s, is_quoted=is_quoted),
|
|
336
336
|
start_position=start_position,
|
|
337
|
-
display=self._format_display_label(s,
|
|
337
|
+
display=self._format_display_label(s, align_width),
|
|
338
338
|
display_meta=s,
|
|
339
339
|
)
|
|
340
340
|
|
|
@@ -543,9 +543,9 @@ class _AtFilesCompleter(Completer):
|
|
|
543
543
|
Keep this unstyled so that the completion menu's selection style can
|
|
544
544
|
fully override the selected row.
|
|
545
545
|
"""
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
return
|
|
546
|
+
name = self._display_name(suggestion)
|
|
547
|
+
# Pad to align_width + extra padding for visual separation from meta
|
|
548
|
+
return name.ljust(align_width + 6)
|
|
549
549
|
|
|
550
550
|
def _display_align_width(self, suggestions: list[str]) -> int:
|
|
551
551
|
"""Calculate alignment width for display labels."""
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Drag-and-drop / paste helpers for the REPL input.
|
|
2
|
+
|
|
3
|
+
Terminals typically implement drag-and-drop as a bracketed paste of either:
|
|
4
|
+
- A file URL (e.g. "file:///Users/me/foo.txt")
|
|
5
|
+
- A shell-escaped path (e.g. "/Users/me/My\\ File.txt")
|
|
6
|
+
|
|
7
|
+
We convert these into the input formats that Klaude already supports:
|
|
8
|
+
- Regular files/dirs -> @path (quoted when needed)
|
|
9
|
+
- Image files -> [image <path>] markers (resolved to ImageURLPart on submit)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import unquote, urlparse
|
|
19
|
+
|
|
20
|
+
from klaude_code.tui.input.images import format_image_marker, is_image_file
|
|
21
|
+
|
|
22
|
+
_FILE_URI_RE = re.compile(r"file://\S+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format_at_token(path_str: str) -> str:
|
|
26
|
+
"""Format a file path for the @ reader, quoting when whitespace exists."""
|
|
27
|
+
|
|
28
|
+
if any(ch.isspace() for ch in path_str):
|
|
29
|
+
return f'@"{path_str}"'
|
|
30
|
+
return f"@{path_str}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_path_for_at(path: Path, *, cwd: Path) -> str:
|
|
34
|
+
"""Return a stable, display-friendly path string for @ references."""
|
|
35
|
+
|
|
36
|
+
# Use absolute() instead of resolve() to avoid expanding symlinks.
|
|
37
|
+
# On macOS, /var -> /private/var, and users expect /var paths to stay as /var.
|
|
38
|
+
try:
|
|
39
|
+
resolved = path.absolute()
|
|
40
|
+
except OSError:
|
|
41
|
+
resolved = path
|
|
42
|
+
|
|
43
|
+
cwd_resolved = cwd
|
|
44
|
+
with contextlib.suppress(OSError):
|
|
45
|
+
cwd_resolved = cwd.absolute()
|
|
46
|
+
|
|
47
|
+
as_dir = False
|
|
48
|
+
try:
|
|
49
|
+
as_dir = resolved.exists() and resolved.is_dir()
|
|
50
|
+
except OSError:
|
|
51
|
+
as_dir = False
|
|
52
|
+
|
|
53
|
+
# Prefer relative paths under CWD to match completer output.
|
|
54
|
+
candidate: str
|
|
55
|
+
try:
|
|
56
|
+
rel = resolved.relative_to(cwd_resolved)
|
|
57
|
+
candidate = rel.as_posix()
|
|
58
|
+
except ValueError:
|
|
59
|
+
candidate = resolved.as_posix()
|
|
60
|
+
|
|
61
|
+
if as_dir and candidate and not candidate.endswith("/"):
|
|
62
|
+
candidate += "/"
|
|
63
|
+
return candidate
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _file_uri_to_path(uri: str) -> Path | None:
|
|
67
|
+
"""Parse a file:// URI to a filesystem path."""
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
parsed = urlparse(uri)
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
if parsed.scheme != "file":
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# Common forms:
|
|
78
|
+
# - file:///Users/me/foo.txt
|
|
79
|
+
# - file://localhost/Users/me/foo.txt
|
|
80
|
+
# - file:///C:/Users/me/foo.txt (Windows)
|
|
81
|
+
raw_path = unquote(parsed.path or "")
|
|
82
|
+
if not raw_path:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
if re.match(r"^/[A-Za-z]:/", raw_path):
|
|
86
|
+
# Windows drive letter URIs often include an extra leading slash.
|
|
87
|
+
raw_path = raw_path[1:]
|
|
88
|
+
|
|
89
|
+
return Path(raw_path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _replace_file_uris(
|
|
93
|
+
text: str,
|
|
94
|
+
*,
|
|
95
|
+
cwd: Path,
|
|
96
|
+
) -> tuple[str, bool]:
|
|
97
|
+
"""Replace all file://... occurrences in text.
|
|
98
|
+
|
|
99
|
+
Returns (new_text, changed).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
changed = False
|
|
103
|
+
|
|
104
|
+
def _replace(match: re.Match[str]) -> str:
|
|
105
|
+
nonlocal changed
|
|
106
|
+
uri = match.group(0)
|
|
107
|
+
|
|
108
|
+
# Strip trailing punctuation that is very likely not part of the URI.
|
|
109
|
+
trail = ""
|
|
110
|
+
while uri and uri[-1] in ")],.;:!?":
|
|
111
|
+
trail = uri[-1] + trail
|
|
112
|
+
uri = uri[:-1]
|
|
113
|
+
|
|
114
|
+
path = _file_uri_to_path(uri)
|
|
115
|
+
if path is None:
|
|
116
|
+
return match.group(0)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
is_image = path.exists() and path.is_file() and is_image_file(path)
|
|
120
|
+
except OSError:
|
|
121
|
+
is_image = False
|
|
122
|
+
|
|
123
|
+
if is_image:
|
|
124
|
+
changed = True
|
|
125
|
+
return format_image_marker(_normalize_path_for_at(path, cwd=cwd)) + trail
|
|
126
|
+
|
|
127
|
+
token = _format_at_token(_normalize_path_for_at(path, cwd=cwd))
|
|
128
|
+
changed = True
|
|
129
|
+
return token + trail
|
|
130
|
+
|
|
131
|
+
out = _FILE_URI_RE.sub(_replace, text)
|
|
132
|
+
return out, changed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _looks_like_path_list(text: str) -> list[str] | None:
|
|
136
|
+
"""Return tokens if text looks like a pure path list, else None."""
|
|
137
|
+
|
|
138
|
+
stripped = text.strip()
|
|
139
|
+
if not stripped:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
# Avoid converting when the paste already contains our input syntax.
|
|
143
|
+
if "@" in stripped or "[image " in stripped:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
tokens = shlex.split(stripped, posix=True)
|
|
148
|
+
except ValueError:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
if not tokens:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
# Heuristic: all tokens must exist on disk.
|
|
155
|
+
for tok in tokens:
|
|
156
|
+
p = Path(tok).expanduser()
|
|
157
|
+
try:
|
|
158
|
+
if not p.exists():
|
|
159
|
+
return None
|
|
160
|
+
except OSError:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
return tokens
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def convert_dropped_text(
|
|
167
|
+
text: str,
|
|
168
|
+
*,
|
|
169
|
+
cwd: Path,
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Convert drag-and-drop text into @ tokens and/or image markers."""
|
|
172
|
+
|
|
173
|
+
out, changed = _replace_file_uris(text, cwd=cwd)
|
|
174
|
+
if changed:
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
tokens = _looks_like_path_list(text)
|
|
178
|
+
if not tokens:
|
|
179
|
+
return text
|
|
180
|
+
|
|
181
|
+
converted: list[str] = []
|
|
182
|
+
for tok in tokens:
|
|
183
|
+
p = Path(tok).expanduser()
|
|
184
|
+
try:
|
|
185
|
+
is_img = p.exists() and p.is_file() and is_image_file(p)
|
|
186
|
+
except OSError:
|
|
187
|
+
is_img = False
|
|
188
|
+
|
|
189
|
+
if is_img:
|
|
190
|
+
converted.append(format_image_marker(_normalize_path_for_at(p, cwd=cwd)))
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
converted.append(_format_at_token(_normalize_path_for_at(p, cwd=cwd)))
|
|
194
|
+
|
|
195
|
+
# Preserve trailing newline if present (common for bracketed paste payloads).
|
|
196
|
+
suffix = "\n" if text.endswith("\n") else ""
|
|
197
|
+
return " ".join(converted) + suffix
|