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.
Files changed (34) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/env.py +19 -15
  3. klaude_code/cli/auth_cmd.py +1 -1
  4. klaude_code/cli/main.py +98 -8
  5. klaude_code/const.py +10 -1
  6. klaude_code/core/reminders.py +4 -5
  7. klaude_code/core/turn.py +1 -1
  8. klaude_code/protocol/commands.py +0 -1
  9. klaude_code/skill/loader.py +12 -13
  10. klaude_code/skill/manager.py +3 -3
  11. klaude_code/tui/command/__init__.py +1 -4
  12. klaude_code/tui/command/copy_cmd.py +1 -1
  13. klaude_code/tui/command/fork_session_cmd.py +4 -4
  14. klaude_code/tui/components/command_output.py +1 -1
  15. klaude_code/tui/components/rich/markdown.py +60 -0
  16. klaude_code/tui/components/rich/theme.py +8 -0
  17. klaude_code/tui/components/user_input.py +38 -27
  18. klaude_code/tui/input/AGENTS.md +44 -0
  19. klaude_code/tui/input/completers.py +10 -14
  20. klaude_code/tui/input/drag_drop.py +197 -0
  21. klaude_code/tui/input/images.py +227 -0
  22. klaude_code/tui/input/key_bindings.py +173 -19
  23. klaude_code/tui/input/paste.py +71 -0
  24. klaude_code/tui/input/prompt_toolkit.py +13 -3
  25. klaude_code/tui/machine.py +1 -1
  26. klaude_code/tui/runner.py +1 -1
  27. klaude_code/tui/terminal/image.py +40 -9
  28. klaude_code/tui/terminal/selector.py +52 -2
  29. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/METADATA +10 -10
  30. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/RECORD +32 -30
  31. klaude_code/tui/command/terminal_setup_cmd.py +0 -248
  32. klaude_code/tui/input/clipboard.py +0 -152
  33. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
  34. {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 the beginning of the first line
16
- SKILL_RENDER_PATTERN = re.compile(r"^[$¥](\S+)")
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 render_at_pattern(
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
- if "@" not in text:
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 match in AT_FILE_RENDER_PATTERN.finditer(text):
32
- start, end = match.span()
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
- # The @-pattern itself (e.g. @path or @"path with spaces")
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 pattern on the first line if recognized
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
- line_text = render_at_pattern(line)
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
- render_at_pattern(splits[1]) if len(splits) > 1 else Text(""),
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
- if i == 0 and (line.startswith("$") or line.startswith("¥")):
76
- m = SKILL_RENDER_PATTERN.match(line)
77
- if m and _is_valid_skill_name(m.group(1)):
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
- SKILL_TOKEN_PATTERN = re.compile(r"^[$¥](?P<frag>\S*)$")
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 at the beginning of the first line.
134
+ """Complete skill names with $ or ¥ prefix.
134
135
 
135
136
  Behavior:
136
- - Only triggers when cursor is on first line and text matches $ or ¥…
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
- # Get the prefix character ($ or ¥)
159
- prefix_char = text_before[0]
160
- token_start = len(text_before) - len(f"{prefix_char}{m.group('frag')}")
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 (only on first line with $ prefix)
232
- if document.cursor_position_row == 0 and self._skill_completer.is_skill_context(document):
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