code-puppy 0.0.325__py3-none-any.whl → 0.0.341__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.
- code_puppy/agents/base_agent.py +110 -124
- code_puppy/claude_cache_client.py +208 -2
- code_puppy/cli_runner.py +152 -32
- code_puppy/command_line/add_model_menu.py +4 -0
- code_puppy/command_line/autosave_menu.py +23 -24
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +5 -0
- code_puppy/command_line/config_commands.py +24 -1
- code_puppy/command_line/core_commands.py +85 -0
- code_puppy/command_line/diff_menu.py +5 -0
- code_puppy/command_line/mcp/custom_server_form.py +4 -0
- code_puppy/command_line/mcp/install_menu.py +5 -1
- code_puppy/command_line/model_settings_menu.py +5 -0
- code_puppy/command_line/motd.py +13 -7
- code_puppy/command_line/onboarding_slides.py +180 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/prompt_toolkit_completion.py +118 -0
- code_puppy/config.py +3 -2
- code_puppy/http_utils.py +201 -279
- code_puppy/keymap.py +10 -8
- code_puppy/mcp_/managed_server.py +7 -11
- code_puppy/messaging/messages.py +3 -0
- code_puppy/messaging/rich_renderer.py +114 -22
- code_puppy/model_factory.py +102 -15
- code_puppy/models.json +2 -2
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +664 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/utils.py +126 -7
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +295 -3
- code_puppy/tools/command_runner.py +43 -54
- code_puppy/tools/common.py +3 -9
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
- {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Clipboard image reading and management utilities.
|
|
2
|
+
|
|
3
|
+
Provides cross-platform clipboard image capture:
|
|
4
|
+
- Windows/macOS: Uses PIL.ImageGrab (native)
|
|
5
|
+
- Linux: Falls back to xclip or wl-paste via subprocess
|
|
6
|
+
|
|
7
|
+
Also provides a thread-safe ClipboardAttachmentManager for managing
|
|
8
|
+
pending clipboard image attachments in the CLI.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import logging
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
# Try to import PIL - it's optional but needed for clipboard image support
|
|
20
|
+
try:
|
|
21
|
+
from PIL import Image, ImageGrab
|
|
22
|
+
|
|
23
|
+
PIL_AVAILABLE = True
|
|
24
|
+
# SEC-CLIP-002: Protect against decompression bombs
|
|
25
|
+
# Set explicit limit (PIL default) to prevent memory exhaustion from malicious images
|
|
26
|
+
Image.MAX_IMAGE_PIXELS = 178956970
|
|
27
|
+
except ImportError:
|
|
28
|
+
PIL_AVAILABLE = False
|
|
29
|
+
Image = None # type: ignore[misc, assignment]
|
|
30
|
+
ImageGrab = None # type: ignore[misc, assignment]
|
|
31
|
+
|
|
32
|
+
# Import BinaryContent for pydantic-ai integration
|
|
33
|
+
try:
|
|
34
|
+
from pydantic_ai import BinaryContent
|
|
35
|
+
|
|
36
|
+
BINARY_CONTENT_AVAILABLE = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
BINARY_CONTENT_AVAILABLE = False
|
|
39
|
+
BinaryContent = None # type: ignore[misc, assignment]
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Constants
|
|
44
|
+
MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024 # 10MB
|
|
45
|
+
MAX_IMAGE_DIMENSION = 4096 # Max width/height for resize
|
|
46
|
+
MAX_PENDING_IMAGES = (
|
|
47
|
+
10 # SEC-CLIP-001: Limit pending images to prevent memory exhaustion
|
|
48
|
+
)
|
|
49
|
+
CLIPBOARD_RATE_LIMIT_SECONDS: float = 0.5 # SEC-CLIP-004: Max 2 captures per second
|
|
50
|
+
|
|
51
|
+
# Rate limiting state
|
|
52
|
+
_last_clipboard_capture: float = 0.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_open_image(image_bytes: bytes) -> Optional["Image.Image"]:
|
|
56
|
+
"""Safely open and verify an image from bytes.
|
|
57
|
+
|
|
58
|
+
Verifies image integrity to protect against malicious images.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
image_bytes: Raw image bytes to open.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
PIL Image if valid, None if verification fails.
|
|
65
|
+
"""
|
|
66
|
+
if not PIL_AVAILABLE or Image is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# First pass: verify integrity without fully loading
|
|
71
|
+
verify_image = Image.open(io.BytesIO(image_bytes))
|
|
72
|
+
verify_image.verify() # Checks for corruption/malicious data
|
|
73
|
+
|
|
74
|
+
# Re-open after verify (verify() closes the image)
|
|
75
|
+
image = Image.open(io.BytesIO(image_bytes))
|
|
76
|
+
return image
|
|
77
|
+
except Image.DecompressionBombError as e:
|
|
78
|
+
logger.warning(f"Rejected decompression bomb image: {e}")
|
|
79
|
+
return None
|
|
80
|
+
except Image.UnidentifiedImageError as e:
|
|
81
|
+
logger.warning(f"Rejected unidentified image format: {e}")
|
|
82
|
+
return None
|
|
83
|
+
except OSError as e:
|
|
84
|
+
logger.warning(f"Rejected potentially malicious image: {e}")
|
|
85
|
+
return None
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.warning(f"Failed to open/verify image: {type(e).__name__}: {e}")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_linux_clipboard_tool() -> Optional[str]:
|
|
92
|
+
"""Check which Linux clipboard tool is available.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
'xclip', 'wl-paste', or None if neither is available.
|
|
96
|
+
"""
|
|
97
|
+
# Check for wl-paste first (Wayland)
|
|
98
|
+
try:
|
|
99
|
+
subprocess.run(
|
|
100
|
+
["wl-paste", "--version"],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
timeout=5,
|
|
103
|
+
)
|
|
104
|
+
return "wl-paste"
|
|
105
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# Check for xclip (X11)
|
|
109
|
+
try:
|
|
110
|
+
subprocess.run(
|
|
111
|
+
["xclip", "-version"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
timeout=5,
|
|
114
|
+
)
|
|
115
|
+
return "xclip"
|
|
116
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_linux_clipboard_image() -> Optional[bytes]:
|
|
123
|
+
"""Get clipboard image on Linux using xclip or wl-paste.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
PNG bytes if image found, None otherwise.
|
|
127
|
+
"""
|
|
128
|
+
tool = _check_linux_clipboard_tool()
|
|
129
|
+
|
|
130
|
+
if tool is None:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"No clipboard tool found on Linux. "
|
|
133
|
+
"Install 'xclip' (X11) or 'wl-clipboard' (Wayland) for clipboard image support."
|
|
134
|
+
)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
if tool == "wl-paste":
|
|
139
|
+
# wl-paste for Wayland
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
["wl-paste", "--type", "image/png"],
|
|
142
|
+
capture_output=True,
|
|
143
|
+
timeout=10,
|
|
144
|
+
)
|
|
145
|
+
if result.returncode == 0 and result.stdout:
|
|
146
|
+
return result.stdout
|
|
147
|
+
elif tool == "xclip":
|
|
148
|
+
# xclip for X11
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
151
|
+
capture_output=True,
|
|
152
|
+
timeout=10,
|
|
153
|
+
)
|
|
154
|
+
if result.returncode == 0 and result.stdout:
|
|
155
|
+
return result.stdout
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
logger.warning(f"Timeout reading clipboard with {tool}")
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.warning(f"Error reading clipboard with {tool}: {e}")
|
|
160
|
+
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resize_image_if_needed(image: "Image.Image", max_bytes: int) -> "Image.Image":
|
|
165
|
+
"""Resize image if it exceeds max size when saved as PNG.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
image: PIL Image to potentially resize.
|
|
169
|
+
max_bytes: Maximum allowed size in bytes.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Original or resized image.
|
|
173
|
+
"""
|
|
174
|
+
if Image is None:
|
|
175
|
+
return image
|
|
176
|
+
|
|
177
|
+
# Check current size
|
|
178
|
+
buffer = io.BytesIO()
|
|
179
|
+
image.save(buffer, format="PNG", optimize=True)
|
|
180
|
+
current_size = buffer.tell()
|
|
181
|
+
|
|
182
|
+
if current_size <= max_bytes:
|
|
183
|
+
return image
|
|
184
|
+
|
|
185
|
+
logger.info(
|
|
186
|
+
f"Image size ({current_size / 1024 / 1024:.2f}MB) exceeds limit "
|
|
187
|
+
f"({max_bytes / 1024 / 1024:.2f}MB), resizing..."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Calculate scale factor to reduce size
|
|
191
|
+
# Rough estimate: size scales with area (width * height)
|
|
192
|
+
scale_factor = (max_bytes / current_size) ** 0.5 * 0.9 # 0.9 for safety margin
|
|
193
|
+
|
|
194
|
+
new_width = int(image.width * scale_factor)
|
|
195
|
+
new_height = int(image.height * scale_factor)
|
|
196
|
+
|
|
197
|
+
# Ensure we don't go below minimum dimensions
|
|
198
|
+
new_width = max(new_width, 100)
|
|
199
|
+
new_height = max(new_height, 100)
|
|
200
|
+
|
|
201
|
+
# Also cap at max dimension
|
|
202
|
+
if new_width > MAX_IMAGE_DIMENSION:
|
|
203
|
+
ratio = MAX_IMAGE_DIMENSION / new_width
|
|
204
|
+
new_width = MAX_IMAGE_DIMENSION
|
|
205
|
+
new_height = int(new_height * ratio)
|
|
206
|
+
if new_height > MAX_IMAGE_DIMENSION:
|
|
207
|
+
ratio = MAX_IMAGE_DIMENSION / new_height
|
|
208
|
+
new_height = MAX_IMAGE_DIMENSION
|
|
209
|
+
new_width = int(new_width * ratio)
|
|
210
|
+
|
|
211
|
+
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
212
|
+
logger.info(
|
|
213
|
+
f"Resized image from {image.width}x{image.height} to {new_width}x{new_height}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return resized
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def has_image_in_clipboard() -> bool:
|
|
220
|
+
"""Check if clipboard contains an image.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True if clipboard contains an image, False otherwise.
|
|
224
|
+
"""
|
|
225
|
+
if sys.platform == "linux":
|
|
226
|
+
# For Linux, we need to actually try to get the image
|
|
227
|
+
# since there's no lightweight "check" method
|
|
228
|
+
tool = _check_linux_clipboard_tool()
|
|
229
|
+
if tool is None:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
if tool == "wl-paste":
|
|
234
|
+
result = subprocess.run(
|
|
235
|
+
["wl-paste", "--list-types"],
|
|
236
|
+
capture_output=True,
|
|
237
|
+
timeout=5,
|
|
238
|
+
text=True,
|
|
239
|
+
)
|
|
240
|
+
return "image/png" in result.stdout or "image/" in result.stdout
|
|
241
|
+
elif tool == "xclip":
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
244
|
+
capture_output=True,
|
|
245
|
+
timeout=5,
|
|
246
|
+
text=True,
|
|
247
|
+
)
|
|
248
|
+
return "image/png" in result.stdout or "image/" in result.stdout
|
|
249
|
+
except (subprocess.TimeoutExpired, Exception):
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
# Windows/macOS - use PIL
|
|
255
|
+
if not PIL_AVAILABLE:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
image = ImageGrab.grabclipboard()
|
|
260
|
+
return image is not None and isinstance(image, Image.Image)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.debug(f"Error checking clipboard: {e}")
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_clipboard_image() -> Optional[bytes]:
|
|
267
|
+
"""Get clipboard image as PNG bytes.
|
|
268
|
+
|
|
269
|
+
Handles cross-platform clipboard access:
|
|
270
|
+
- Windows/macOS: Uses PIL.ImageGrab
|
|
271
|
+
- Linux: Uses xclip or wl-paste
|
|
272
|
+
|
|
273
|
+
Images larger than 10MB are automatically resized.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
PNG bytes if clipboard contains an image, None otherwise.
|
|
277
|
+
"""
|
|
278
|
+
image_bytes: Optional[bytes] = None
|
|
279
|
+
|
|
280
|
+
# Linux path - use command line tools
|
|
281
|
+
if sys.platform == "linux":
|
|
282
|
+
image_bytes = _get_linux_clipboard_image()
|
|
283
|
+
if image_bytes is None:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
# Check size and resize if needed
|
|
287
|
+
if len(image_bytes) > MAX_IMAGE_SIZE_BYTES:
|
|
288
|
+
if not PIL_AVAILABLE:
|
|
289
|
+
logger.warning(
|
|
290
|
+
f"Image size ({len(image_bytes) / 1024 / 1024:.2f}MB) exceeds limit, "
|
|
291
|
+
"but PIL not available for resizing."
|
|
292
|
+
)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
# Use safe image opening with verification
|
|
297
|
+
image = _safe_open_image(image_bytes)
|
|
298
|
+
if image is None:
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Image verification failed for Linux clipboard image"
|
|
301
|
+
)
|
|
302
|
+
return None
|
|
303
|
+
image = _resize_image_if_needed(image, MAX_IMAGE_SIZE_BYTES)
|
|
304
|
+
buffer = io.BytesIO()
|
|
305
|
+
image.save(buffer, format="PNG", optimize=True)
|
|
306
|
+
image_bytes = buffer.getvalue()
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.warning(f"Error resizing Linux clipboard image: {e}")
|
|
309
|
+
return None
|
|
310
|
+
else:
|
|
311
|
+
# Verify even small images for safety
|
|
312
|
+
if PIL_AVAILABLE:
|
|
313
|
+
image = _safe_open_image(image_bytes)
|
|
314
|
+
if image is None:
|
|
315
|
+
logger.warning(
|
|
316
|
+
"Image verification failed for Linux clipboard image"
|
|
317
|
+
)
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
return image_bytes
|
|
321
|
+
|
|
322
|
+
# Windows/macOS path - use PIL
|
|
323
|
+
if not PIL_AVAILABLE:
|
|
324
|
+
logger.warning("PIL/Pillow not available. Install with: pip install Pillow")
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
image = ImageGrab.grabclipboard()
|
|
329
|
+
|
|
330
|
+
if image is None:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
if not isinstance(image, Image.Image):
|
|
334
|
+
# Could be a list of file paths on some systems
|
|
335
|
+
logger.debug(f"Clipboard contains non-image data: {type(image)}")
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
# Log original dimensions
|
|
339
|
+
logger.info(f"Captured clipboard image: {image.width}x{image.height}")
|
|
340
|
+
|
|
341
|
+
# Convert to RGB if necessary (handles RGBA, P mode, etc.)
|
|
342
|
+
if image.mode in ("RGBA", "LA") or (
|
|
343
|
+
image.mode == "P" and "transparency" in image.info
|
|
344
|
+
):
|
|
345
|
+
# Keep alpha channel for PNG
|
|
346
|
+
pass
|
|
347
|
+
elif image.mode != "RGB":
|
|
348
|
+
image = image.convert("RGB")
|
|
349
|
+
|
|
350
|
+
# Resize if needed
|
|
351
|
+
image = _resize_image_if_needed(image, MAX_IMAGE_SIZE_BYTES)
|
|
352
|
+
|
|
353
|
+
# Convert to PNG bytes
|
|
354
|
+
buffer = io.BytesIO()
|
|
355
|
+
image.save(buffer, format="PNG", optimize=True)
|
|
356
|
+
image_bytes = buffer.getvalue()
|
|
357
|
+
|
|
358
|
+
logger.info(f"Clipboard image size: {len(image_bytes) / 1024:.1f}KB")
|
|
359
|
+
return image_bytes
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.warning(f"Error reading clipboard image: {e}")
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_clipboard_image_as_binary_content() -> Optional["BinaryContent"]:
|
|
367
|
+
"""Get clipboard image as pydantic-ai BinaryContent.
|
|
368
|
+
|
|
369
|
+
This is the preferred method for integrating clipboard images
|
|
370
|
+
with pydantic-ai agents.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
BinaryContent with PNG image if available, None otherwise.
|
|
374
|
+
"""
|
|
375
|
+
if not BINARY_CONTENT_AVAILABLE:
|
|
376
|
+
logger.warning("pydantic-ai BinaryContent not available")
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
image_bytes = get_clipboard_image()
|
|
380
|
+
if image_bytes is None:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
return BinaryContent(data=image_bytes, media_type="image/png")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class ClipboardAttachmentManager:
|
|
387
|
+
"""Thread-safe manager for pending clipboard image attachments.
|
|
388
|
+
|
|
389
|
+
This class manages clipboard images that have been captured but not yet
|
|
390
|
+
sent to the AI model. It provides a simple interface for adding images,
|
|
391
|
+
retrieving them as BinaryContent, and clearing the queue.
|
|
392
|
+
|
|
393
|
+
Usage:
|
|
394
|
+
manager = get_clipboard_manager()
|
|
395
|
+
placeholder = manager.add_image(image_bytes)
|
|
396
|
+
# Later, when sending to AI:
|
|
397
|
+
images = manager.get_pending_images()
|
|
398
|
+
manager.clear_pending()
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(self) -> None:
|
|
402
|
+
"""Initialize the clipboard attachment manager."""
|
|
403
|
+
self._pending_images: list[bytes] = []
|
|
404
|
+
self._lock = threading.Lock()
|
|
405
|
+
self._counter = 0
|
|
406
|
+
|
|
407
|
+
def add_image(self, image_bytes: bytes) -> str:
|
|
408
|
+
"""Add image bytes to pending attachments.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
image_bytes: PNG image bytes to add.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Placeholder ID string like '[📋 clipboard image 1]'
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
ValueError: If MAX_PENDING_IMAGES limit is reached.
|
|
418
|
+
"""
|
|
419
|
+
with self._lock:
|
|
420
|
+
# SEC-CLIP-001: Check limit BEFORE adding to prevent memory exhaustion
|
|
421
|
+
if len(self._pending_images) >= MAX_PENDING_IMAGES:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"Maximum of {MAX_PENDING_IMAGES} pending images reached. "
|
|
424
|
+
"Send your message to clear the queue, or use /paste clear."
|
|
425
|
+
)
|
|
426
|
+
self._counter += 1
|
|
427
|
+
self._pending_images.append(image_bytes)
|
|
428
|
+
placeholder = f"[📋 clipboard image {self._counter}]"
|
|
429
|
+
logger.debug(
|
|
430
|
+
f"Added clipboard image {self._counter} "
|
|
431
|
+
f"({len(image_bytes) / 1024:.1f}KB)"
|
|
432
|
+
)
|
|
433
|
+
return placeholder
|
|
434
|
+
|
|
435
|
+
def get_pending_images(self) -> list["BinaryContent"]:
|
|
436
|
+
"""Get all pending images as BinaryContent list.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
List of BinaryContent objects for each pending image.
|
|
440
|
+
Returns empty list if BinaryContent not available.
|
|
441
|
+
"""
|
|
442
|
+
if not BINARY_CONTENT_AVAILABLE:
|
|
443
|
+
logger.warning("BinaryContent not available, returning empty list")
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
with self._lock:
|
|
447
|
+
return [
|
|
448
|
+
BinaryContent(data=img_bytes, media_type="image/png")
|
|
449
|
+
for img_bytes in self._pending_images
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
def clear_pending(self) -> None:
|
|
453
|
+
"""Clear all pending images."""
|
|
454
|
+
with self._lock:
|
|
455
|
+
count = len(self._pending_images)
|
|
456
|
+
self._pending_images.clear()
|
|
457
|
+
if count > 0:
|
|
458
|
+
logger.debug(f"Cleared {count} pending clipboard image(s)")
|
|
459
|
+
|
|
460
|
+
def get_pending_count(self) -> int:
|
|
461
|
+
"""Get count of pending images.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Number of images currently pending.
|
|
465
|
+
"""
|
|
466
|
+
with self._lock:
|
|
467
|
+
return len(self._pending_images)
|
|
468
|
+
|
|
469
|
+
def has_pending(self) -> bool:
|
|
470
|
+
"""Check if there are any pending images.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
True if there are pending images, False otherwise.
|
|
474
|
+
"""
|
|
475
|
+
with self._lock:
|
|
476
|
+
return len(self._pending_images) > 0
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# Global singleton instance
|
|
480
|
+
_clipboard_manager: Optional[ClipboardAttachmentManager] = None
|
|
481
|
+
_manager_lock = threading.Lock()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def get_clipboard_manager() -> ClipboardAttachmentManager:
|
|
485
|
+
"""Get or create the global clipboard manager singleton.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
The global ClipboardAttachmentManager instance.
|
|
489
|
+
"""
|
|
490
|
+
global _clipboard_manager
|
|
491
|
+
|
|
492
|
+
if _clipboard_manager is None:
|
|
493
|
+
with _manager_lock:
|
|
494
|
+
# Double-check locking pattern
|
|
495
|
+
if _clipboard_manager is None:
|
|
496
|
+
_clipboard_manager = ClipboardAttachmentManager()
|
|
497
|
+
|
|
498
|
+
return _clipboard_manager
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def capture_clipboard_image_to_pending() -> Optional[str]:
|
|
502
|
+
"""Convenience function to capture clipboard image and add to pending.
|
|
503
|
+
|
|
504
|
+
This combines get_clipboard_image() and add_image() into a single call.
|
|
505
|
+
Includes rate limiting to prevent rapid captures (SEC-CLIP-004).
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Placeholder string if image captured, None if no image or rate limited.
|
|
509
|
+
"""
|
|
510
|
+
global _last_clipboard_capture
|
|
511
|
+
|
|
512
|
+
# SEC-CLIP-004: Rate limiting to prevent rapid captures
|
|
513
|
+
now = time.monotonic()
|
|
514
|
+
if now - _last_clipboard_capture < CLIPBOARD_RATE_LIMIT_SECONDS:
|
|
515
|
+
logger.debug("Clipboard capture rate limited")
|
|
516
|
+
return None # Rate limited, silently ignore
|
|
517
|
+
|
|
518
|
+
image_bytes = get_clipboard_image()
|
|
519
|
+
if image_bytes is None:
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
manager = get_clipboard_manager()
|
|
523
|
+
placeholder = manager.add_image(image_bytes)
|
|
524
|
+
|
|
525
|
+
# Update timestamp on successful capture
|
|
526
|
+
_last_clipboard_capture = now
|
|
527
|
+
return placeholder
|
|
@@ -230,6 +230,11 @@ async def interactive_colors_picker() -> Optional[dict]:
|
|
|
230
230
|
sys.stdout.write("\033[?1049l") # Exit alternate buffer
|
|
231
231
|
sys.stdout.flush()
|
|
232
232
|
|
|
233
|
+
# Clear exit message
|
|
234
|
+
from code_puppy.messaging import emit_info
|
|
235
|
+
|
|
236
|
+
emit_info("✓ Exited banner color configuration")
|
|
237
|
+
|
|
233
238
|
# Return changes if any
|
|
234
239
|
if config.has_changes():
|
|
235
240
|
return config.current_colors
|
|
@@ -46,6 +46,7 @@ def handle_show_command(command: str) -> bool:
|
|
|
46
46
|
get_use_dbos,
|
|
47
47
|
get_yolo_mode,
|
|
48
48
|
)
|
|
49
|
+
from code_puppy.keymap import get_cancel_agent_display_name
|
|
49
50
|
from code_puppy.messaging import emit_info
|
|
50
51
|
|
|
51
52
|
puppy_name = get_puppy_name()
|
|
@@ -79,6 +80,7 @@ def handle_show_command(command: str) -> bool:
|
|
|
79
80
|
[bold]reasoning_effort:[/bold] [cyan]{get_openai_reasoning_effort()}[/cyan]
|
|
80
81
|
[bold]verbosity:[/bold] [cyan]{get_openai_verbosity()}[/cyan]
|
|
81
82
|
[bold]temperature:[/bold] [cyan]{effective_temperature if effective_temperature is not None else "(model default)"}[/cyan]{" (per-model)" if effective_temperature != global_temperature and effective_temperature is not None else ""}
|
|
83
|
+
[bold]cancel_agent_key:[/bold] [cyan]{get_cancel_agent_display_name()}[/cyan] (options: ctrl+c, ctrl+k, ctrl+q)
|
|
82
84
|
|
|
83
85
|
"""
|
|
84
86
|
emit_info(Text.from_markup(status_msg))
|
|
@@ -200,9 +202,13 @@ def handle_set_command(command: str) -> bool:
|
|
|
200
202
|
"\n[yellow]Session Management[/yellow]"
|
|
201
203
|
"\n [cyan]auto_save_session[/cyan] Auto-save chat after every response (true/false)"
|
|
202
204
|
)
|
|
205
|
+
keymap_help = (
|
|
206
|
+
"\n[yellow]Keyboard Shortcuts[/yellow]"
|
|
207
|
+
"\n [cyan]cancel_agent_key[/cyan] Key to cancel agent tasks (ctrl+c, ctrl+k, or ctrl+q)"
|
|
208
|
+
)
|
|
203
209
|
emit_warning(
|
|
204
210
|
Text.from_markup(
|
|
205
|
-
f"Usage: /set KEY=VALUE or /set KEY VALUE\nConfig keys: {', '.join(config_keys)}\n[dim]Note: compaction_strategy can be 'summarization' or 'truncation'[/dim]{session_help}"
|
|
211
|
+
f"Usage: /set KEY=VALUE or /set KEY VALUE\nConfig keys: {', '.join(config_keys)}\n[dim]Note: compaction_strategy can be 'summarization' or 'truncation'[/dim]{session_help}{keymap_help}"
|
|
206
212
|
)
|
|
207
213
|
)
|
|
208
214
|
return True
|
|
@@ -215,6 +221,23 @@ def handle_set_command(command: str) -> bool:
|
|
|
215
221
|
)
|
|
216
222
|
)
|
|
217
223
|
|
|
224
|
+
# Validate cancel_agent_key before setting
|
|
225
|
+
if key == "cancel_agent_key":
|
|
226
|
+
from code_puppy.keymap import VALID_CANCEL_KEYS
|
|
227
|
+
|
|
228
|
+
normalized_value = value.strip().lower()
|
|
229
|
+
if normalized_value not in VALID_CANCEL_KEYS:
|
|
230
|
+
emit_error(
|
|
231
|
+
f"Invalid cancel_agent_key '{value}'. Valid options: {', '.join(sorted(VALID_CANCEL_KEYS))}"
|
|
232
|
+
)
|
|
233
|
+
return True
|
|
234
|
+
value = normalized_value # Use normalized value
|
|
235
|
+
emit_info(
|
|
236
|
+
Text.from_markup(
|
|
237
|
+
"[yellow]⚠️ cancel_agent_key changed. Please restart Code Puppy for this change to take effect.[/yellow]"
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
218
241
|
set_config_value(key, value)
|
|
219
242
|
emit_success(f'Set {key} = "{value}" in puppy.cfg!')
|
|
220
243
|
|
|
@@ -115,6 +115,91 @@ def handle_motd_command(command: str) -> bool:
|
|
|
115
115
|
return True
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
@register_command(
|
|
119
|
+
name="paste",
|
|
120
|
+
description="Paste image from clipboard (same as F3, or Ctrl+V with image)",
|
|
121
|
+
usage="/paste, /clipboard, /cb",
|
|
122
|
+
aliases=["clipboard", "cb"],
|
|
123
|
+
category="core",
|
|
124
|
+
)
|
|
125
|
+
def handle_paste_command(command: str) -> bool:
|
|
126
|
+
"""Paste an image from the clipboard into the pending attachments."""
|
|
127
|
+
from code_puppy.command_line.clipboard import (
|
|
128
|
+
capture_clipboard_image_to_pending,
|
|
129
|
+
get_clipboard_manager,
|
|
130
|
+
has_image_in_clipboard,
|
|
131
|
+
)
|
|
132
|
+
from code_puppy.messaging import emit_info, emit_success, emit_warning
|
|
133
|
+
|
|
134
|
+
if not has_image_in_clipboard():
|
|
135
|
+
emit_warning("No image found in clipboard")
|
|
136
|
+
emit_info("Copy an image (screenshot, from browser, etc.) and try again")
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
placeholder = capture_clipboard_image_to_pending()
|
|
140
|
+
if placeholder:
|
|
141
|
+
manager = get_clipboard_manager()
|
|
142
|
+
count = manager.get_pending_count()
|
|
143
|
+
emit_success(f"📋 {placeholder}")
|
|
144
|
+
emit_info(f"Total pending clipboard images: {count}")
|
|
145
|
+
emit_info("Type your prompt and press Enter to send with the image(s)")
|
|
146
|
+
else:
|
|
147
|
+
emit_warning("Failed to capture clipboard image")
|
|
148
|
+
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@register_command(
|
|
153
|
+
name="tutorial",
|
|
154
|
+
description="Run the interactive tutorial wizard",
|
|
155
|
+
usage="/tutorial",
|
|
156
|
+
category="core",
|
|
157
|
+
)
|
|
158
|
+
def handle_tutorial_command(command: str) -> bool:
|
|
159
|
+
"""Run the interactive tutorial wizard.
|
|
160
|
+
|
|
161
|
+
Usage:
|
|
162
|
+
/tutorial - Run the tutorial (can be run anytime)
|
|
163
|
+
"""
|
|
164
|
+
import asyncio
|
|
165
|
+
import concurrent.futures
|
|
166
|
+
|
|
167
|
+
from code_puppy.command_line.onboarding_wizard import (
|
|
168
|
+
reset_onboarding,
|
|
169
|
+
run_onboarding_wizard,
|
|
170
|
+
)
|
|
171
|
+
from code_puppy.config import set_model_name
|
|
172
|
+
|
|
173
|
+
# Always reset so user can re-run the tutorial anytime
|
|
174
|
+
reset_onboarding()
|
|
175
|
+
|
|
176
|
+
# Run the async wizard in a thread pool (same pattern as agent picker)
|
|
177
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
178
|
+
future = executor.submit(lambda: asyncio.run(run_onboarding_wizard()))
|
|
179
|
+
result = future.result(timeout=300) # 5 min timeout
|
|
180
|
+
|
|
181
|
+
if result == "chatgpt":
|
|
182
|
+
emit_info("🔐 Starting ChatGPT OAuth flow...")
|
|
183
|
+
from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow
|
|
184
|
+
|
|
185
|
+
run_oauth_flow()
|
|
186
|
+
set_model_name("chatgpt-gpt-5.2-codex")
|
|
187
|
+
elif result == "claude":
|
|
188
|
+
emit_info("🔐 Starting Claude Code OAuth flow...")
|
|
189
|
+
from code_puppy.plugins.claude_code_oauth.register_callbacks import (
|
|
190
|
+
_perform_authentication,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
_perform_authentication()
|
|
194
|
+
set_model_name("claude-code-claude-opus-4-5-20251101")
|
|
195
|
+
elif result == "completed":
|
|
196
|
+
emit_info("🎉 Tutorial complete! Happy coding!")
|
|
197
|
+
elif result == "skipped":
|
|
198
|
+
emit_info("⏭️ Tutorial skipped. Run /tutorial anytime!")
|
|
199
|
+
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
118
203
|
@register_command(
|
|
119
204
|
name="exit",
|
|
120
205
|
description="Exit interactive mode",
|
|
@@ -456,6 +456,11 @@ async def interactive_diff_picker() -> Optional[dict]:
|
|
|
456
456
|
sys.stdout.write("\033[?1049l") # Exit alternate buffer
|
|
457
457
|
sys.stdout.flush()
|
|
458
458
|
|
|
459
|
+
# Clear exit message
|
|
460
|
+
from code_puppy.messaging import emit_info
|
|
461
|
+
|
|
462
|
+
emit_info("✓ Exited diff color configuration")
|
|
463
|
+
|
|
459
464
|
# Return changes if any
|
|
460
465
|
if config.has_changes():
|
|
461
466
|
return {
|