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.
Files changed (52) hide show
  1. code_puppy/agents/base_agent.py +110 -124
  2. code_puppy/claude_cache_client.py +208 -2
  3. code_puppy/cli_runner.py +152 -32
  4. code_puppy/command_line/add_model_menu.py +4 -0
  5. code_puppy/command_line/autosave_menu.py +23 -24
  6. code_puppy/command_line/clipboard.py +527 -0
  7. code_puppy/command_line/colors_menu.py +5 -0
  8. code_puppy/command_line/config_commands.py +24 -1
  9. code_puppy/command_line/core_commands.py +85 -0
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/install_menu.py +5 -1
  13. code_puppy/command_line/model_settings_menu.py +5 -0
  14. code_puppy/command_line/motd.py +13 -7
  15. code_puppy/command_line/onboarding_slides.py +180 -0
  16. code_puppy/command_line/onboarding_wizard.py +340 -0
  17. code_puppy/command_line/prompt_toolkit_completion.py +118 -0
  18. code_puppy/config.py +3 -2
  19. code_puppy/http_utils.py +201 -279
  20. code_puppy/keymap.py +10 -8
  21. code_puppy/mcp_/managed_server.py +7 -11
  22. code_puppy/messaging/messages.py +3 -0
  23. code_puppy/messaging/rich_renderer.py +114 -22
  24. code_puppy/model_factory.py +102 -15
  25. code_puppy/models.json +2 -2
  26. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  27. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  28. code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
  29. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  30. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  31. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  32. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  33. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  34. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  35. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  36. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  37. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  38. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  39. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  40. code_puppy/plugins/claude_code_oauth/utils.py +126 -7
  41. code_puppy/reopenable_async_client.py +8 -8
  42. code_puppy/terminal_utils.py +295 -3
  43. code_puppy/tools/command_runner.py +43 -54
  44. code_puppy/tools/common.py +3 -9
  45. code_puppy/uvx_detection.py +242 -0
  46. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
  47. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
  48. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
  49. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
  50. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
  51. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
  52. {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 {