regcode 0.7.0__tar.gz → 0.8.0__tar.gz

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 (46) hide show
  1. {regcode-0.7.0 → regcode-0.8.0}/PKG-INFO +2 -1
  2. {regcode-0.7.0 → regcode-0.8.0}/pyproject.toml +2 -1
  3. {regcode-0.7.0 → regcode-0.8.0}/regcode/__init__.py +1 -1
  4. regcode-0.8.0/regcode/clipboard.py +378 -0
  5. {regcode-0.7.0 → regcode-0.8.0}/regcode/main.py +30 -8
  6. regcode-0.8.0/regcode/paste.py +188 -0
  7. {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/builtins.py +157 -45
  8. {regcode-0.7.0 → regcode-0.8.0}/regcode/tui.py +334 -21
  9. regcode-0.8.0/tests/test_clipboard.py +502 -0
  10. {regcode-0.7.0 → regcode-0.8.0}/uv.lock +12 -1
  11. {regcode-0.7.0 → regcode-0.8.0}/.gitignore +0 -0
  12. {regcode-0.7.0 → regcode-0.8.0}/.gitlab-ci.yml +0 -0
  13. {regcode-0.7.0 → regcode-0.8.0}/.python-version +0 -0
  14. {regcode-0.7.0 → regcode-0.8.0}/.qwen/settings.json +0 -0
  15. {regcode-0.7.0 → regcode-0.8.0}/AGENTS.md +0 -0
  16. {regcode-0.7.0 → regcode-0.8.0}/GOALS.md +0 -0
  17. {regcode-0.7.0 → regcode-0.8.0}/README.md +0 -0
  18. {regcode-0.7.0 → regcode-0.8.0}/config.yaml.template +0 -0
  19. {regcode-0.7.0 → regcode-0.8.0}/main.py +0 -0
  20. {regcode-0.7.0 → regcode-0.8.0}/regcode/cli.py +0 -0
  21. {regcode-0.7.0 → regcode-0.8.0}/regcode/config.py +0 -0
  22. {regcode-0.7.0 → regcode-0.8.0}/regcode/conversation_manager.py +0 -0
  23. {regcode-0.7.0 → regcode-0.8.0}/regcode/dependencies.py +0 -0
  24. {regcode-0.7.0 → regcode-0.8.0}/regcode/monty_sandbox.py +0 -0
  25. {regcode-0.7.0 → regcode-0.8.0}/regcode/permissions.py +0 -0
  26. {regcode-0.7.0 → regcode-0.8.0}/regcode/sandbox.py +0 -0
  27. {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/__init__.py +0 -0
  28. {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/base.py +0 -0
  29. {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/registry.py +0 -0
  30. {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/review_notes.py +0 -0
  31. {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/web_crawl.py +0 -0
  32. {regcode-0.7.0 → regcode-0.8.0}/regcode/tools/web_search.py +0 -0
  33. {regcode-0.7.0 → regcode-0.8.0}/regcode/tracing.py +0 -0
  34. {regcode-0.7.0 → regcode-0.8.0}/tests/__init__.py +0 -0
  35. {regcode-0.7.0 → regcode-0.8.0}/tests/conftest.py +0 -0
  36. {regcode-0.7.0 → regcode-0.8.0}/tests/test_api.py +0 -0
  37. {regcode-0.7.0 → regcode-0.8.0}/tests/test_cli.py +0 -0
  38. {regcode-0.7.0 → regcode-0.8.0}/tests/test_config.py +0 -0
  39. {regcode-0.7.0 → regcode-0.8.0}/tests/test_context_manager.py +0 -0
  40. {regcode-0.7.0 → regcode-0.8.0}/tests/test_dependencies.py +0 -0
  41. {regcode-0.7.0 → regcode-0.8.0}/tests/test_main.py +0 -0
  42. {regcode-0.7.0 → regcode-0.8.0}/tests/test_monty_sandbox.py +0 -0
  43. {regcode-0.7.0 → regcode-0.8.0}/tests/test_osv_cve.py +0 -0
  44. {regcode-0.7.0 → regcode-0.8.0}/tests/test_provider_extra.py +0 -0
  45. {regcode-0.7.0 → regcode-0.8.0}/tests/test_review_notes.py +0 -0
  46. {regcode-0.7.0 → regcode-0.8.0}/tests/test_web_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regcode
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Minimalistic coding agent with Python API and CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click>=8.0
@@ -10,6 +10,7 @@ Requires-Dist: mlflow>=3.14.0
10
10
  Requires-Dist: pathspec>=1.1.1
11
11
  Requires-Dist: pydantic-monty>=0.0.18
12
12
  Requires-Dist: pydantic-settings>=2.14.2
13
+ Requires-Dist: pyperclip>=1.9.0
13
14
  Requires-Dist: pytest>=8.0
14
15
  Requires-Dist: pyyaml>=6.0
15
16
  Requires-Dist: ruff>=0.8
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regcode"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Minimalistic coding agent with Python API and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -16,6 +16,7 @@ dependencies = [
16
16
  "mlflow>=3.14.0",
17
17
  "textual>=2.0.0",
18
18
  "pathspec>=1.1.1",
19
+ "pyperclip>=1.9.0",
19
20
  ]
20
21
 
21
22
  [project.scripts]
@@ -2,4 +2,4 @@ from regcode.config import Config
2
2
  from regcode.main import Agent, AgentStatus, ToolCall
3
3
 
4
4
  __all__ = ["Agent", "Config", "AgentStatus", "ToolCall"]
5
- __version__ = "0.6.3"
5
+ __version__ = "0.8.0"
@@ -0,0 +1,378 @@
1
+ """Clipboard utilities for image and text detection, with base64 encoding.
2
+
3
+ Supports Wayland (wl-paste) and X11 (xclip/xsel) and WSL clipboard backends.
4
+ Detects image vs text content and provides base64 encoding for images.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import logging
11
+ import mimetypes
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class ClipboardContent:
21
+ """Represents clipboard content with type information."""
22
+
23
+ content: str # Base64 for images, raw text for text
24
+ content_type: str # "image" or "text"
25
+ mime_type: str # e.g., "image/png", "text/plain"
26
+ is_image: bool
27
+
28
+
29
+ # Image extensions to MIME types mapping
30
+ IMAGE_EXTENSIONS: dict[str, str] = {
31
+ ".png": "image/png",
32
+ ".jpg": "image/jpeg",
33
+ ".jpeg": "image/jpeg",
34
+ ".gif": "image/gif",
35
+ ".webp": "image/webp",
36
+ ".bmp": "image/bmp",
37
+ ".tiff": "image/tiff",
38
+ ".svg": "image/svg+xml",
39
+ ".ico": "image/x-icon",
40
+ ".avif": "image/avif",
41
+ }
42
+
43
+
44
+ def detect_image_mime_type(ext: str) -> str:
45
+ """Detect MIME type from file extension."""
46
+ ext = ext.lower()
47
+ return IMAGE_EXTENSIONS.get(ext, "application/octet-stream")
48
+
49
+
50
+ def get_clipboard_backend() -> str:
51
+ """Detect which clipboard backend is available.
52
+
53
+ Returns one of: 'wl-clipboard', 'xclip', 'xsel', 'wsl', or empty string.
54
+ """
55
+ # Check Wayland first
56
+ try:
57
+ result = subprocess.run(
58
+ ["which", "wl-paste"],
59
+ capture_output=True,
60
+ text=True,
61
+ )
62
+ if result.returncode == 0:
63
+ return "wl-clipboard"
64
+ except (FileNotFoundError, OSError):
65
+ pass
66
+
67
+ # Check X11
68
+ try:
69
+ result = subprocess.run(
70
+ ["which", "xclip"],
71
+ capture_output=True,
72
+ text=True,
73
+ )
74
+ if result.returncode == 0:
75
+ return "xclip"
76
+ except (FileNotFoundError, OSError):
77
+ pass
78
+
79
+ try:
80
+ result = subprocess.run(
81
+ ["which", "xsel"],
82
+ capture_output=True,
83
+ text=True,
84
+ )
85
+ if result.returncode == 0:
86
+ return "xsel"
87
+ except (FileNotFoundError, OSError):
88
+ pass
89
+
90
+ # Check WSL clipboard integration (clip.exe + powershell.exe)
91
+ try:
92
+ result = subprocess.run(
93
+ ["which", "clip.exe"],
94
+ capture_output=True,
95
+ text=True,
96
+ )
97
+ if result.returncode == 0:
98
+ result2 = subprocess.run(
99
+ ["which", "powershell.exe"],
100
+ capture_output=True,
101
+ text=True,
102
+ )
103
+ if result2.returncode == 0:
104
+ return "wsl"
105
+ except (FileNotFoundError, OSError):
106
+ pass
107
+
108
+ return ""
109
+
110
+
111
+ def read_clipboard_text(backend: str) -> str:
112
+ """Read plain text from clipboard.
113
+
114
+ Args:
115
+ backend: Clipboard backend to use ('wl-clipboard', 'xclip', 'xsel', 'wsl', '')
116
+
117
+ Returns:
118
+ Text content from clipboard, or empty string on failure.
119
+ """
120
+ try:
121
+ if backend == "wl-clipboard":
122
+ result = subprocess.run(
123
+ ["wl-paste", "-t", "text/plain"],
124
+ capture_output=True,
125
+ text=True,
126
+ timeout=5,
127
+ )
128
+ elif backend == "xclip":
129
+ result = subprocess.run(
130
+ ["xclip", "-selection", "clipboard", "-o"],
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=5,
134
+ )
135
+ elif backend == "xsel":
136
+ result = subprocess.run(
137
+ ["xsel", "--clipboard", "--output"],
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=5,
141
+ )
142
+ elif backend == "wsl":
143
+ # WSL clipboard: use PowerShell to read clipboard
144
+ ps_script = (
145
+ "[Convert]::ToBase64String("
146
+ "[Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))"
147
+ )
148
+ result = subprocess.run(
149
+ [
150
+ "powershell.exe",
151
+ "-noprofile",
152
+ "-command",
153
+ ps_script,
154
+ ],
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=5,
158
+ )
159
+ if result.returncode == 0 and result.stdout.strip():
160
+ try:
161
+ base64_data = result.stdout.strip()
162
+ decoded_bytes = base64.b64decode(base64_data)
163
+ text = decoded_bytes.decode("utf-8")
164
+ # Normalize Windows line endings to Unix for terminal display
165
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
166
+ return text.strip()
167
+ except Exception:
168
+ return ""
169
+ return ""
170
+ else:
171
+ return ""
172
+
173
+ if result.returncode == 0:
174
+ text = result.stdout
175
+ # Normalize Windows line endings to Unix for terminal display
176
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
177
+ return text.strip()
178
+ else:
179
+ logger.debug("Failed to read text from clipboard: %s", result.stderr)
180
+ return ""
181
+ except (subprocess.TimeoutExpired, OSError, subprocess.SubprocessError) as e:
182
+ logger.debug("Error reading clipboard text: %s", e)
183
+ return ""
184
+
185
+
186
+ def read_clipboard_image(backend: str) -> Optional[bytes]:
187
+ """Read raw image bytes from clipboard.
188
+
189
+ Args:
190
+ backend: Clipboard backend to use
191
+
192
+ Returns:
193
+ Raw image bytes, or None if clipboard doesn't contain an image.
194
+ """
195
+ try:
196
+ if backend == "wl-clipboard":
197
+ result = subprocess.run(
198
+ ["wl-paste", "-t", "image/png"],
199
+ capture_output=True,
200
+ timeout=5,
201
+ )
202
+ if result.returncode == 0 and len(result.stdout) > 0:
203
+ return result.stdout
204
+ elif backend == "xclip":
205
+ # xclip doesn't support direct image output, need to check with xwd
206
+ # This is a fallback - primary Wayland support is through wl-clipboard
207
+ result = subprocess.run(
208
+ ["wl-paste", "-t", "image/png"],
209
+ capture_output=True,
210
+ timeout=5,
211
+ )
212
+ if result.returncode == 0 and len(result.stdout) > 0:
213
+ return result.stdout
214
+ elif backend == "wsl":
215
+ # WSL: load Windows Forms assembly and read clipboard image
216
+ ps_script = (
217
+ "Add-Type -AssemblyName System.Windows.Forms; "
218
+ "$ms = New-Object IO.MemoryStream; "
219
+ "[System.Windows.Forms.Clipboard]::GetImage().Save($ms, "
220
+ "[System.Drawing.Imaging.ImageFormat]::Png); "
221
+ "[Convert]::ToBase64String($ms.ToArray())"
222
+ )
223
+ result = subprocess.run(
224
+ [
225
+ "powershell.exe",
226
+ "-NoProfile",
227
+ "-NonInteractive",
228
+ "-Command",
229
+ ps_script,
230
+ ],
231
+ capture_output=True,
232
+ text=True,
233
+ timeout=10,
234
+ )
235
+ if result.returncode == 0 and result.stdout.strip():
236
+ try:
237
+ # PowerShell wraps base64 with newlines, remove all whitespace
238
+ clean = result.stdout.replace("\n", "").replace("\r", "").strip()
239
+ return base64.b64decode(clean)
240
+ except Exception:
241
+ logger.debug("Failed to decode WSL clipboard image base64")
242
+ return None
243
+ except (subprocess.TimeoutExpired, OSError, subprocess.SubprocessError) as e:
244
+ logger.debug("Error reading clipboard image: %s", e)
245
+
246
+ return None
247
+
248
+
249
+ def clipboard_is_image(backend: str) -> bool:
250
+ """Check if clipboard contains an image.
251
+
252
+ Args:
253
+ backend: Clipboard backend to use
254
+
255
+ Returns:
256
+ True if clipboard contains an image, False otherwise.
257
+ """
258
+ try:
259
+ if backend == "wl-clipboard":
260
+ result = subprocess.run(
261
+ ["wl-paste", "-t", "image/png"],
262
+ capture_output=True,
263
+ timeout=5,
264
+ )
265
+ if result.returncode == 0 and len(result.stdout) > 0:
266
+ # Verify it's actually a PNG
267
+ if result.stdout[:8] == b"\x89PNG\r\n\x1a\n":
268
+ return True
269
+ elif backend in ("xclip", "xsel"):
270
+ # For X11, check if we can extract image
271
+ return read_clipboard_image(backend) is not None
272
+ elif backend == "wsl":
273
+ # For WSL, try to get image via PowerShell
274
+ return read_clipboard_image(backend) is not None
275
+ except (subprocess.TimeoutExpired, OSError):
276
+ pass
277
+
278
+ return False
279
+
280
+
281
+ def get_image_mime_type(data: bytes) -> str:
282
+ """Detect MIME type from image data bytes.
283
+
284
+ Args:
285
+ data: Raw image bytes
286
+
287
+ Returns:
288
+ MIME type string (e.g., "image/png")
289
+ """
290
+ # Check PNG signature
291
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
292
+ return "image/png"
293
+
294
+ # Check JPEG signature
295
+ if data[:2] == b"\xff\xd8":
296
+ return "image/jpeg"
297
+
298
+ # Check GIF signature
299
+ if data[:4] in (b"GIF87a", b"GIF89a"):
300
+ return "image/gif"
301
+
302
+ # Check WebP signature
303
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
304
+ return "image/webp"
305
+
306
+ # Fallback: try to detect from magic bytes
307
+ mime_type = mimetypes.guess_type("image.tmp")
308
+ if mime_type and mime_type[0]:
309
+ return mime_type[0]
310
+
311
+ return "application/octet-stream"
312
+
313
+
314
+ def encode_image_to_base64(data: bytes, mime_type: str = "") -> str:
315
+ """Encode image bytes to base64 data URL.
316
+
317
+ Args:
318
+ data: Raw image bytes
319
+ mime_type: MIME type of the image
320
+
321
+ Returns:
322
+ Base64 data URL string (e.g., "data:image/png;base64,...")
323
+ """
324
+ if not mime_type:
325
+ mime_type = get_image_mime_type(data)
326
+
327
+ base64_data = base64.b64encode(data).decode("utf-8")
328
+ return f"data:{mime_type};base64,{base64_data}"
329
+
330
+
331
+ def get_clipboard_content() -> ClipboardContent:
332
+ """Get clipboard content, auto-detecting image vs text.
333
+
334
+ Returns:
335
+ ClipboardContent with detected type and base64-encoded data for images.
336
+ """
337
+ backend = get_clipboard_backend()
338
+ if not backend:
339
+ return ClipboardContent(
340
+ content="",
341
+ content_type="text",
342
+ mime_type="text/plain",
343
+ is_image=False,
344
+ )
345
+
346
+ # Check if clipboard contains an image
347
+ if clipboard_is_image(backend):
348
+ image_data = read_clipboard_image(backend)
349
+ if image_data is not None:
350
+ mime_type = get_image_mime_type(image_data)
351
+ base64_data = encode_image_to_base64(image_data, mime_type)
352
+ return ClipboardContent(
353
+ content=base64_data,
354
+ content_type="image",
355
+ mime_type=mime_type,
356
+ is_image=True,
357
+ )
358
+
359
+ # Fall back to text
360
+ text_content = read_clipboard_text(backend)
361
+ return ClipboardContent(
362
+ content=text_content,
363
+ content_type="text",
364
+ mime_type="text/plain",
365
+ is_image=False,
366
+ )
367
+
368
+
369
+ def read_multiline_text(backend: str) -> str:
370
+ """Read potentially multiline text from clipboard.
371
+
372
+ Args:
373
+ backend: Clipboard backend to use
374
+
375
+ Returns:
376
+ Multiline text content from clipboard.
377
+ """
378
+ return read_clipboard_text(backend)
@@ -427,7 +427,7 @@ class Agent:
427
427
 
428
428
  def chat(
429
429
  self,
430
- message: str | None = None,
430
+ message: str | list[dict[str, Any]] | None = None,
431
431
  stream: bool = False,
432
432
  max_tool_calls: int = 10,
433
433
  messages: list[dict[str, Any]] | None = None,
@@ -436,7 +436,9 @@ class Agent:
436
436
  """Send a message and get a response, with automatic tool use.
437
437
 
438
438
  Args:
439
- message: User message (required if 'messages' not provided).
439
+ message: User message string, or multimodal list of content parts.
440
+ Multimodal example: [{"type": "text", "text": "describe"},
441
+ {"type": "image_url", "image_url": {"url": "data:..."}}]
440
442
  stream: Whether to stream the final response.
441
443
  max_tool_calls: Maximum number of tool call turns.
442
444
  messages: Pre-built message list (overrides 'message' parameter).
@@ -447,20 +449,40 @@ class Agent:
447
449
  if messages is None:
448
450
  if message is None:
449
451
  raise ValueError("Either 'message' or 'messages' must be provided")
452
+ # Build user message content based on type
453
+ if isinstance(message, str):
454
+ user_content: str | list[dict[str, Any]] = message
455
+ elif isinstance(message, list):
456
+ # Multimodal message — already in litellm format
457
+ user_content = message
458
+ else:
459
+ raise ValueError(
460
+ f"message must be str or list[dict], got {type(message).__name__}"
461
+ )
450
462
  messages = [
451
463
  {"role": "system", "content": self._build_system_prompt()},
452
464
  *self._history,
453
- {"role": "user", "content": message},
465
+ {"role": "user", "content": user_content},
454
466
  ]
455
467
 
456
468
  # Add messages to context manager for compaction tracking
457
469
  # Only add messages not already in history to avoid double-counting
458
- existing_content_keys = {
459
- (msg.get("role"), msg.get("content", ""))
460
- for msg in self._history
461
- }
470
+ existing_content_keys: set[tuple] = set()
471
+ for msg in self._history:
472
+ content = msg.get("content")
473
+ # Multimodal content (list) is unhashable — convert to string key
474
+ if isinstance(content, list):
475
+ content_key = json.dumps(content, sort_keys=True)
476
+ else:
477
+ content_key = content or ""
478
+ existing_content_keys.add((msg.get("role"), content_key))
462
479
  for msg in messages:
463
- if (msg.get("role"), msg.get("content", "")) not in existing_content_keys:
480
+ content = msg.get("content")
481
+ if isinstance(content, list):
482
+ content_key = json.dumps(content, sort_keys=True)
483
+ else:
484
+ content_key = content or ""
485
+ if (msg.get("role"), content_key) not in existing_content_keys:
464
486
  self.context_manager.add_message(msg)
465
487
 
466
488
  # Check if compaction is needed
@@ -0,0 +1,188 @@
1
+ """Paste utility for file-based paste handling.
2
+
3
+ Intercepts paste events, saves content to temporary files, and generates
4
+ file references for the TUI. On message submission, file references are
5
+ detected and processed appropriately.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import mimetypes
12
+ import re
13
+ import tempfile
14
+ import uuid
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import List
18
+
19
+
20
+ @dataclass
21
+ class PasteEntry:
22
+ """Represents a single paste entry saved to a temp file."""
23
+
24
+ file_path: Path
25
+ mime_type: str
26
+ is_image: bool
27
+ content: str # For images: base64 data URI; for text: raw text
28
+
29
+
30
+ def detect_mime_type(text: str) -> str:
31
+ """Detect MIME type from text content.
32
+
33
+ Checks if text looks like base64-encoded image data.
34
+ """
35
+ if _looks_like_base64_image(text):
36
+ try:
37
+ raw = base64.b64decode(text)
38
+ return _detect_image_from_bytes(raw)
39
+ except Exception:
40
+ pass
41
+
42
+ return "text/plain"
43
+
44
+
45
+ def _looks_like_base64_image(text: str) -> bool:
46
+ """Check if text looks like base64-encoded image data."""
47
+ if len(text) < 20:
48
+ return False
49
+
50
+ # Check if text is mostly base64 characters
51
+ b64_pattern = re.compile(r"^[A-Za-z0-9+/=\s]+$")
52
+ if not b64_pattern.match(text):
53
+ return False
54
+
55
+ # Check for common image prefixes
56
+ for prefix in [
57
+ "iVBORw", # PNG
58
+ "/9j/", # JPEG
59
+ "R0lGOD", # GIF
60
+ "RklGRVBI", # WebP (RIFF)
61
+ ]:
62
+ if text.startswith(prefix):
63
+ return True
64
+
65
+ return False
66
+
67
+
68
+ def _detect_image_from_bytes(data: bytes) -> str:
69
+ """Detect MIME type from image bytes."""
70
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
71
+ return "image/png"
72
+ if data[:2] == b"\xff\xd8":
73
+ return "image/jpeg"
74
+ if data[:4] in (b"GIF87a", b"GIF89a"):
75
+ return "image/gif"
76
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
77
+ return "image/webp"
78
+ if data[:3] == b"BM":
79
+ return "image/bmp"
80
+ return "image/unknown"
81
+
82
+
83
+ def save_paste_content(content: str) -> PasteEntry:
84
+ """Save paste content to a temporary file.
85
+
86
+ Args:
87
+ content: The raw text content from clipboard.
88
+
89
+ Returns:
90
+ PasteEntry with file path, MIME type, and metadata.
91
+ """
92
+ file_id = uuid.uuid4().hex[:12]
93
+ mime_type = detect_mime_type(content)
94
+ is_image = mime_type.startswith("image/")
95
+
96
+ if is_image:
97
+ try:
98
+ raw_bytes = base64.b64decode(content)
99
+ # Use system temp dir with unique filename
100
+ ext = mimetypes.guess_extension(mime_type) or ".png"
101
+ if ext == ".jpe":
102
+ ext = ".jpg"
103
+ if ext == ".jfif":
104
+ ext = ".jpg"
105
+ file_path = Path(tempfile.gettempdir()) / f"regcode_paste_{file_id}{ext}"
106
+ file_path.write_bytes(raw_bytes)
107
+ except Exception:
108
+ file_path = Path(tempfile.gettempdir()) / f"regcode_paste_{file_id}.txt"
109
+ file_path.write_text(content, encoding="utf-8")
110
+ mime_type = "text/plain"
111
+ is_image = False
112
+ else:
113
+ file_path = Path(tempfile.gettempdir()) / f"regcode_paste_{file_id}.txt"
114
+ file_path.write_text(content, encoding="utf-8")
115
+
116
+ return PasteEntry(
117
+ file_path=file_path,
118
+ mime_type=mime_type,
119
+ is_image=is_image,
120
+ content=content,
121
+ )
122
+
123
+
124
+ def create_file_reference(paste_entry: PasteEntry) -> str:
125
+ """Create a [FILE: ...] reference string for display in input box."""
126
+ return f"[FILE: {paste_entry.file_path}]"
127
+
128
+
129
+ def parse_file_references(text: str) -> List[PasteEntry]:
130
+ """Parse [FILE: ...] references from text.
131
+
132
+ Args:
133
+ text: Text that may contain [FILE: ...] references.
134
+
135
+ Returns:
136
+ List of PasteEntry objects for each file reference found.
137
+ """
138
+ pattern = r"\[FILE:\s*([^]]+?)\s*\]"
139
+ matches = re.finditer(pattern, text)
140
+
141
+ entries = []
142
+ for match in matches:
143
+ file_path_str = match.group(1).strip()
144
+ file_path = Path(file_path_str)
145
+
146
+ if not file_path.exists():
147
+ continue
148
+
149
+ # Determine MIME type from extension
150
+ mime_type = mimetypes.guess_type(str(file_path))[0] or "text/plain"
151
+ is_image = mime_type.startswith("image/")
152
+
153
+ # Read content
154
+ try:
155
+ if is_image:
156
+ raw_bytes = file_path.read_bytes()
157
+ base64_data = base64.b64encode(raw_bytes).decode("utf-8")
158
+ content = f"data:{mime_type};base64,{base64_data}"
159
+ else:
160
+ content = file_path.read_text(encoding="utf-8")
161
+ except Exception:
162
+ continue
163
+
164
+ entries.append(
165
+ PasteEntry(
166
+ file_path=file_path,
167
+ mime_type=mime_type,
168
+ is_image=is_image,
169
+ content=content,
170
+ )
171
+ )
172
+
173
+ return entries
174
+
175
+
176
+ def remove_file_references(text: str) -> str:
177
+ """Remove [FILE: ...] references from text."""
178
+ return re.sub(r"\[FILE:\s*[^]]+?\s*\]", "", text).strip()
179
+
180
+
181
+ def cleanup_paste_files(entries: List[PasteEntry]) -> None:
182
+ """Remove paste files after processing."""
183
+ for entry in entries:
184
+ try:
185
+ if entry.file_path.exists():
186
+ entry.file_path.unlink()
187
+ except OSError:
188
+ pass