klaude-code 2.6.0__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/env.py +19 -15
- klaude_code/cli/auth_cmd.py +1 -1
- klaude_code/cli/main.py +98 -8
- klaude_code/const.py +10 -1
- klaude_code/core/reminders.py +4 -5
- klaude_code/core/turn.py +1 -1
- klaude_code/protocol/commands.py +0 -1
- 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/components/command_output.py +1 -1
- klaude_code/tui/components/rich/markdown.py +60 -0
- klaude_code/tui/components/rich/theme.py +8 -0
- klaude_code/tui/components/user_input.py +38 -27
- klaude_code/tui/input/AGENTS.md +44 -0
- klaude_code/tui/input/completers.py +10 -14
- 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 +1 -1
- 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.6.0.dist-info → klaude_code-2.7.0.dist-info}/METADATA +10 -10
- {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/RECORD +32 -30
- klaude_code/tui/command/terminal_setup_cmd.py +0 -248
- klaude_code/tui/input/clipboard.py +0 -152
- {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.6.0.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)
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Image handling for REPL input.
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- IMAGE_SUFFIXES: Supported image file extensions
|
|
5
|
+
- IMAGE_MARKER_RE: Regex for [image ...] markers
|
|
6
|
+
- is_image_file(): Check if a path is an image file
|
|
7
|
+
- format_image_marker(): Generate [image path] string
|
|
8
|
+
- parse_image_marker_path(): Parse path from marker
|
|
9
|
+
- capture_clipboard_tag(): Capture clipboard image and return an [image ...] marker
|
|
10
|
+
- extract_images_from_text(): Parse [image ...] markers and return ImageURLPart list
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import uuid
|
|
20
|
+
from base64 import b64encode
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from klaude_code.const import get_system_temp
|
|
24
|
+
from klaude_code.protocol.message import ImageURLPart
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Constants and marker syntax
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
|
|
31
|
+
|
|
32
|
+
IMAGE_MARKER_RE = re.compile(r'\[image (?P<path>"[^"]+"|[^\]]+)\]')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_image_file(path: Path) -> bool:
|
|
36
|
+
"""Check if a path points to an image file based on extension."""
|
|
37
|
+
return path.suffix.lower() in IMAGE_SUFFIXES
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_image_marker(path: str) -> str:
|
|
41
|
+
"""Format a path as an [image ...] marker.
|
|
42
|
+
|
|
43
|
+
Paths with whitespace are quoted.
|
|
44
|
+
"""
|
|
45
|
+
path_str = path.strip()
|
|
46
|
+
if any(ch.isspace() for ch in path_str):
|
|
47
|
+
return f'[image "{path_str}"]'
|
|
48
|
+
return f"[image {path_str}]"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_image_marker_path(raw: str) -> str:
|
|
52
|
+
"""Parse the path from an [image ...] marker, removing quotes if present."""
|
|
53
|
+
s = raw.strip()
|
|
54
|
+
if len(s) >= 2 and s.startswith('"') and s.endswith('"'):
|
|
55
|
+
return s[1:-1]
|
|
56
|
+
return s
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Clipboard image capture
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _clipboard_images_dir() -> Path:
|
|
65
|
+
return Path(get_system_temp())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _grab_clipboard_image_macos(dest_path: Path) -> bool:
|
|
69
|
+
"""Grab image from clipboard on macOS using pngpaste or osascript (JXA)."""
|
|
70
|
+
# Try pngpaste first (faster, if installed)
|
|
71
|
+
if shutil.which("pngpaste"):
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["pngpaste", str(dest_path)],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
)
|
|
77
|
+
return result.returncode == 0 and dest_path.exists() and dest_path.stat().st_size > 0
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# Fallback to osascript with JXA (JavaScript for Automation)
|
|
82
|
+
script = f'''
|
|
83
|
+
ObjC.import("AppKit");
|
|
84
|
+
var pb = $.NSPasteboard.generalPasteboard;
|
|
85
|
+
var pngData = pb.dataForType($.NSPasteboardTypePNG);
|
|
86
|
+
if (pngData.isNil()) {{
|
|
87
|
+
var tiffData = pb.dataForType($.NSPasteboardTypeTIFF);
|
|
88
|
+
if (tiffData.isNil()) {{
|
|
89
|
+
"false";
|
|
90
|
+
}} else {{
|
|
91
|
+
var bitmapRep = $.NSBitmapImageRep.imageRepWithData(tiffData);
|
|
92
|
+
pngData = bitmapRep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $());
|
|
93
|
+
}}
|
|
94
|
+
}}
|
|
95
|
+
if (!pngData.isNil()) {{
|
|
96
|
+
pngData.writeToFileAtomically("{dest_path}", true);
|
|
97
|
+
"true";
|
|
98
|
+
}} else {{
|
|
99
|
+
"false";
|
|
100
|
+
}}
|
|
101
|
+
'''
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run(
|
|
104
|
+
["osascript", "-l", "JavaScript", "-e", script],
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
)
|
|
108
|
+
return (
|
|
109
|
+
result.returncode == 0 and "true" in result.stdout and dest_path.exists() and dest_path.stat().st_size > 0
|
|
110
|
+
)
|
|
111
|
+
except OSError:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _grab_clipboard_image_linux(dest_path: Path) -> bool:
|
|
116
|
+
"""Grab image from clipboard on Linux using xclip."""
|
|
117
|
+
if not shutil.which("xclip"):
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
122
|
+
capture_output=True,
|
|
123
|
+
)
|
|
124
|
+
if result.returncode == 0 and result.stdout:
|
|
125
|
+
dest_path.write_bytes(result.stdout)
|
|
126
|
+
return True
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _grab_clipboard_image_windows(dest_path: Path) -> bool:
|
|
133
|
+
"""Grab image from clipboard on Windows using PowerShell."""
|
|
134
|
+
script = f'''
|
|
135
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
136
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
137
|
+
if ($img -ne $null) {{
|
|
138
|
+
$img.Save("{dest_path}", [System.Drawing.Imaging.ImageFormat]::Png)
|
|
139
|
+
Write-Output "ok"
|
|
140
|
+
}}
|
|
141
|
+
'''
|
|
142
|
+
try:
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
["powershell", "-Command", script],
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
)
|
|
148
|
+
return result.returncode == 0 and "ok" in result.stdout and dest_path.exists()
|
|
149
|
+
except OSError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _grab_clipboard_image(dest_path: Path) -> bool:
|
|
154
|
+
"""Grab image from clipboard and save to dest_path. Returns True on success."""
|
|
155
|
+
if sys.platform == "darwin":
|
|
156
|
+
return _grab_clipboard_image_macos(dest_path)
|
|
157
|
+
elif sys.platform == "win32":
|
|
158
|
+
return _grab_clipboard_image_windows(dest_path)
|
|
159
|
+
else:
|
|
160
|
+
return _grab_clipboard_image_linux(dest_path)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def capture_clipboard_tag() -> str | None:
|
|
164
|
+
"""Capture an image from clipboard and return an [image ...] marker."""
|
|
165
|
+
|
|
166
|
+
images_dir = _clipboard_images_dir()
|
|
167
|
+
try:
|
|
168
|
+
images_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
except OSError:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
filename = f"klaude-image-{uuid.uuid4().hex}.png"
|
|
173
|
+
path = images_dir / filename
|
|
174
|
+
|
|
175
|
+
if not _grab_clipboard_image(path):
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
return format_image_marker(str(path))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Image extraction from text
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _encode_image_file(file_path: str) -> ImageURLPart | None:
|
|
187
|
+
"""Encode an image file as base64 data URL and create ImageURLPart."""
|
|
188
|
+
try:
|
|
189
|
+
path = Path(file_path)
|
|
190
|
+
if not path.exists():
|
|
191
|
+
return None
|
|
192
|
+
with open(path, "rb") as f:
|
|
193
|
+
encoded = b64encode(f.read()).decode("ascii")
|
|
194
|
+
|
|
195
|
+
suffix = path.suffix.lower()
|
|
196
|
+
mime = {
|
|
197
|
+
".png": "image/png",
|
|
198
|
+
".jpg": "image/jpeg",
|
|
199
|
+
".jpeg": "image/jpeg",
|
|
200
|
+
".gif": "image/gif",
|
|
201
|
+
".webp": "image/webp",
|
|
202
|
+
}.get(suffix)
|
|
203
|
+
if mime is None:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
data_url = f"data:{mime};base64,{encoded}"
|
|
207
|
+
return ImageURLPart(url=data_url, id=None)
|
|
208
|
+
except OSError:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def extract_images_from_text(text: str) -> list[ImageURLPart]:
|
|
213
|
+
"""Extract images referenced by [image ...] markers in text."""
|
|
214
|
+
|
|
215
|
+
images: list[ImageURLPart] = []
|
|
216
|
+
for m in IMAGE_MARKER_RE.finditer(text):
|
|
217
|
+
raw = m.group("path")
|
|
218
|
+
path_str = parse_image_marker_path(raw)
|
|
219
|
+
if not path_str:
|
|
220
|
+
continue
|
|
221
|
+
p = Path(path_str).expanduser()
|
|
222
|
+
if not p.is_absolute():
|
|
223
|
+
p = (Path.cwd() / p).resolve()
|
|
224
|
+
image_part = _encode_image_file(str(p))
|
|
225
|
+
if image_part:
|
|
226
|
+
images.append(image_part)
|
|
227
|
+
return images
|