code-puppy 0.0.338__py3-none-any.whl → 0.0.340__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/claude_cache_client.py +76 -18
- code_puppy/cli_runner.py +46 -5
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/core_commands.py +34 -0
- code_puppy/command_line/prompt_toolkit_completion.py +112 -0
- code_puppy/mcp_/managed_server.py +7 -11
- code_puppy/tools/common.py +3 -9
- {code_puppy-0.0.338.dist-info → code_puppy-0.0.340.dist-info}/METADATA +2 -1
- {code_puppy-0.0.338.dist-info → code_puppy-0.0.340.dist-info}/RECORD +14 -13
- {code_puppy-0.0.338.data → code_puppy-0.0.340.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.338.data → code_puppy-0.0.340.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.338.dist-info → code_puppy-0.0.340.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.338.dist-info → code_puppy-0.0.340.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.338.dist-info → code_puppy-0.0.340.dist-info}/licenses/LICENSE +0 -0
|
@@ -10,10 +10,13 @@ serialization, avoiding httpx/Pydantic internals.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import logging
|
|
13
14
|
from typing import Any, Callable, MutableMapping
|
|
14
15
|
|
|
15
16
|
import httpx
|
|
16
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
17
20
|
try:
|
|
18
21
|
from anthropic import AsyncAnthropic
|
|
19
22
|
except ImportError: # pragma: no cover - optional dep
|
|
@@ -58,25 +61,44 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
58
61
|
pass
|
|
59
62
|
response = await super().send(request, *args, **kwargs)
|
|
60
63
|
try:
|
|
61
|
-
|
|
64
|
+
# Check for both 401 and 400 - Anthropic/Cloudflare may return 400 for auth errors
|
|
65
|
+
# Also check if it's a Cloudflare HTML error response
|
|
66
|
+
if response.status_code in (400, 401) and not request.extensions.get(
|
|
62
67
|
"claude_oauth_refresh_attempted"
|
|
63
68
|
):
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
self.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
# Determine if this is an auth error (including Cloudflare HTML errors)
|
|
70
|
+
is_auth_error = response.status_code == 401
|
|
71
|
+
|
|
72
|
+
if response.status_code == 400:
|
|
73
|
+
# Check if this is a Cloudflare HTML error
|
|
74
|
+
is_auth_error = self._is_cloudflare_html_error(response)
|
|
75
|
+
if is_auth_error:
|
|
76
|
+
logger.info(
|
|
77
|
+
"Detected Cloudflare 400 error (likely auth-related), attempting token refresh"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if is_auth_error:
|
|
81
|
+
refreshed_token = self._refresh_claude_oauth_token()
|
|
82
|
+
if refreshed_token:
|
|
83
|
+
logger.info("Token refreshed successfully, retrying request")
|
|
84
|
+
await response.aclose()
|
|
85
|
+
body_bytes = self._extract_body_bytes(request)
|
|
86
|
+
headers = dict(request.headers)
|
|
87
|
+
self._update_auth_headers(headers, refreshed_token)
|
|
88
|
+
retry_request = self.build_request(
|
|
89
|
+
method=request.method,
|
|
90
|
+
url=request.url,
|
|
91
|
+
headers=headers,
|
|
92
|
+
content=body_bytes,
|
|
93
|
+
)
|
|
94
|
+
retry_request.extensions["claude_oauth_refresh_attempted"] = (
|
|
95
|
+
True
|
|
96
|
+
)
|
|
97
|
+
return await super().send(retry_request, *args, **kwargs)
|
|
98
|
+
else:
|
|
99
|
+
logger.warning("Token refresh failed, returning original error")
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
logger.debug("Error during token refresh attempt: %s", exc)
|
|
80
102
|
return response
|
|
81
103
|
|
|
82
104
|
@staticmethod
|
|
@@ -111,15 +133,51 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
111
133
|
else:
|
|
112
134
|
headers["Authorization"] = bearer_value
|
|
113
135
|
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _is_cloudflare_html_error(response: httpx.Response) -> bool:
|
|
138
|
+
"""Check if this is a Cloudflare HTML error response.
|
|
139
|
+
|
|
140
|
+
Cloudflare often returns HTML error pages with status 400 when
|
|
141
|
+
there are authentication issues.
|
|
142
|
+
"""
|
|
143
|
+
# Check content type
|
|
144
|
+
content_type = response.headers.get("content-type", "")
|
|
145
|
+
if "text/html" not in content_type.lower():
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
# Check if body contains Cloudflare markers
|
|
149
|
+
try:
|
|
150
|
+
# Read response body if not already consumed
|
|
151
|
+
if hasattr(response, "_content") and response._content:
|
|
152
|
+
body = response._content.decode("utf-8", errors="ignore")
|
|
153
|
+
else:
|
|
154
|
+
# Try to read the text (this might be already consumed)
|
|
155
|
+
try:
|
|
156
|
+
body = response.text
|
|
157
|
+
except Exception:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
# Look for Cloudflare and 400 Bad Request markers
|
|
161
|
+
body_lower = body.lower()
|
|
162
|
+
return "cloudflare" in body_lower and "400 bad request" in body_lower
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
logger.debug("Error checking for Cloudflare error: %s", exc)
|
|
165
|
+
return False
|
|
166
|
+
|
|
114
167
|
def _refresh_claude_oauth_token(self) -> str | None:
|
|
115
168
|
try:
|
|
116
169
|
from code_puppy.plugins.claude_code_oauth.utils import refresh_access_token
|
|
117
170
|
|
|
171
|
+
logger.info("Attempting to refresh Claude Code OAuth token...")
|
|
118
172
|
refreshed_token = refresh_access_token(force=True)
|
|
119
173
|
if refreshed_token:
|
|
120
174
|
self._update_auth_headers(self.headers, refreshed_token)
|
|
175
|
+
logger.info("Successfully refreshed Claude Code OAuth token")
|
|
176
|
+
else:
|
|
177
|
+
logger.warning("Token refresh returned None")
|
|
121
178
|
return refreshed_token
|
|
122
|
-
except Exception:
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
logger.error("Exception during token refresh: %s", exc)
|
|
123
181
|
return None
|
|
124
182
|
|
|
125
183
|
@staticmethod
|
code_puppy/cli_runner.py
CHANGED
|
@@ -22,6 +22,7 @@ from rich.console import Console
|
|
|
22
22
|
from code_puppy import __version__, callbacks, plugins
|
|
23
23
|
from code_puppy.agents import get_current_agent
|
|
24
24
|
from code_puppy.command_line.attachments import parse_prompt_attachments
|
|
25
|
+
from code_puppy.command_line.clipboard import get_clipboard_manager
|
|
25
26
|
from code_puppy.config import (
|
|
26
27
|
AUTOSAVE_DIR,
|
|
27
28
|
COMMAND_HISTORY_FILE,
|
|
@@ -143,8 +144,8 @@ async def main():
|
|
|
143
144
|
except ImportError:
|
|
144
145
|
emit_system_message("🐶 Code Puppy is Loading...")
|
|
145
146
|
|
|
146
|
-
#
|
|
147
|
-
|
|
147
|
+
# Truecolor warning moved to interactive_mode() so it prints LAST
|
|
148
|
+
# after all the help stuff - max visibility for the ugly red box!
|
|
148
149
|
|
|
149
150
|
available_port = find_available_port()
|
|
150
151
|
if available_port is None:
|
|
@@ -354,6 +355,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
354
355
|
emit_system_message(
|
|
355
356
|
"Type @ for path completion, or /model to pick a model. Toggle multiline with Alt+M or F2; newline: Ctrl+J."
|
|
356
357
|
)
|
|
358
|
+
emit_system_message("Paste images: Ctrl+V (even on Mac!), F3, or /paste command.")
|
|
359
|
+
import platform
|
|
360
|
+
|
|
361
|
+
if platform.system() == "Darwin":
|
|
362
|
+
emit_system_message(
|
|
363
|
+
"💡 macOS tip: Use Ctrl+V (not Cmd+V) to paste images in terminal."
|
|
364
|
+
)
|
|
357
365
|
cancel_key = get_cancel_agent_display_name()
|
|
358
366
|
emit_system_message(
|
|
359
367
|
f"Press {cancel_key} during processing to cancel the current task or inference. Use Ctrl+X to interrupt running shell commands."
|
|
@@ -374,6 +382,10 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
374
382
|
|
|
375
383
|
emit_warning(f"MOTD error: {e}")
|
|
376
384
|
|
|
385
|
+
# Print truecolor warning LAST so it's the most visible thing on startup
|
|
386
|
+
# Big ugly red box should be impossible to miss! 🔴
|
|
387
|
+
print_truecolor_warning(display_console)
|
|
388
|
+
|
|
377
389
|
# Initialize the runtime agent manager
|
|
378
390
|
if initial_command:
|
|
379
391
|
from code_puppy.agents import get_current_agent
|
|
@@ -567,6 +579,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
567
579
|
|
|
568
580
|
# Check for clear command (supports both `clear` and `/clear`)
|
|
569
581
|
if task.strip().lower() in ("clear", "/clear"):
|
|
582
|
+
from code_puppy.command_line.clipboard import get_clipboard_manager
|
|
570
583
|
from code_puppy.messaging import (
|
|
571
584
|
emit_info,
|
|
572
585
|
emit_system_message,
|
|
@@ -579,6 +592,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
579
592
|
emit_warning("Conversation history cleared!")
|
|
580
593
|
emit_system_message("The agent will not remember previous interactions.")
|
|
581
594
|
emit_info(f"Auto-save session rotated to: {new_session_id}")
|
|
595
|
+
|
|
596
|
+
# Also clear pending clipboard images
|
|
597
|
+
clipboard_manager = get_clipboard_manager()
|
|
598
|
+
clipboard_count = clipboard_manager.get_pending_count()
|
|
599
|
+
clipboard_manager.clear_pending()
|
|
600
|
+
if clipboard_count > 0:
|
|
601
|
+
emit_info(f"Cleared {clipboard_count} pending clipboard image(s)")
|
|
582
602
|
continue
|
|
583
603
|
|
|
584
604
|
# Parse attachments first so leading paths aren't misread as commands
|
|
@@ -761,6 +781,7 @@ async def run_prompt_with_attachments(
|
|
|
761
781
|
tuple: (result, task) where result is the agent response and task is the asyncio task
|
|
762
782
|
"""
|
|
763
783
|
import asyncio
|
|
784
|
+
import re
|
|
764
785
|
|
|
765
786
|
from code_puppy.messaging import emit_system_message, emit_warning
|
|
766
787
|
|
|
@@ -769,21 +790,41 @@ async def run_prompt_with_attachments(
|
|
|
769
790
|
for warning in processed_prompt.warnings:
|
|
770
791
|
emit_warning(warning)
|
|
771
792
|
|
|
793
|
+
# Get clipboard images and merge with file attachments
|
|
794
|
+
clipboard_manager = get_clipboard_manager()
|
|
795
|
+
clipboard_images = clipboard_manager.get_pending_images()
|
|
796
|
+
|
|
797
|
+
# Clear pending clipboard images after retrieval
|
|
798
|
+
clipboard_manager.clear_pending()
|
|
799
|
+
|
|
800
|
+
# Build summary of all attachments
|
|
772
801
|
summary_parts = []
|
|
773
802
|
if processed_prompt.attachments:
|
|
774
|
-
summary_parts.append(f"
|
|
803
|
+
summary_parts.append(f"files: {len(processed_prompt.attachments)}")
|
|
804
|
+
if clipboard_images:
|
|
805
|
+
summary_parts.append(f"clipboard images: {len(clipboard_images)}")
|
|
775
806
|
if processed_prompt.link_attachments:
|
|
776
807
|
summary_parts.append(f"urls: {len(processed_prompt.link_attachments)}")
|
|
777
808
|
if summary_parts:
|
|
778
809
|
emit_system_message("Attachments detected -> " + ", ".join(summary_parts))
|
|
779
810
|
|
|
780
|
-
|
|
811
|
+
# Clean up clipboard placeholders from the prompt text
|
|
812
|
+
cleaned_prompt = processed_prompt.prompt
|
|
813
|
+
if clipboard_images and cleaned_prompt:
|
|
814
|
+
cleaned_prompt = re.sub(
|
|
815
|
+
r"\[📋 clipboard image \d+\]\s*", "", cleaned_prompt
|
|
816
|
+
).strip()
|
|
817
|
+
|
|
818
|
+
if not cleaned_prompt:
|
|
781
819
|
emit_warning(
|
|
782
820
|
"Prompt is empty after removing attachments; add instructions and retry."
|
|
783
821
|
)
|
|
784
822
|
return None, None
|
|
785
823
|
|
|
824
|
+
# Combine file attachments with clipboard images
|
|
786
825
|
attachments = [attachment.content for attachment in processed_prompt.attachments]
|
|
826
|
+
attachments.extend(clipboard_images) # Add clipboard images
|
|
827
|
+
|
|
787
828
|
link_attachments = [link.url_part for link in processed_prompt.link_attachments]
|
|
788
829
|
|
|
789
830
|
# IMPORTANT: Set the shared console on the agent so that streaming output
|
|
@@ -795,7 +836,7 @@ async def run_prompt_with_attachments(
|
|
|
795
836
|
# Create the agent task first so we can track and cancel it
|
|
796
837
|
agent_task = asyncio.create_task(
|
|
797
838
|
agent.run_with_mcp(
|
|
798
|
-
|
|
839
|
+
cleaned_prompt, # Use cleaned prompt (clipboard placeholders removed)
|
|
799
840
|
attachments=attachments,
|
|
800
841
|
link_attachments=link_attachments,
|
|
801
842
|
)
|
|
@@ -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
|
|
@@ -115,6 +115,40 @@ 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
|
+
|
|
118
152
|
@register_command(
|
|
119
153
|
name="tutorial",
|
|
120
154
|
description="Run the interactive tutorial wizard",
|
|
@@ -27,6 +27,11 @@ from code_puppy.command_line.attachments import (
|
|
|
27
27
|
_detect_path_tokens,
|
|
28
28
|
_tokenise,
|
|
29
29
|
)
|
|
30
|
+
from code_puppy.command_line.clipboard import (
|
|
31
|
+
capture_clipboard_image_to_pending,
|
|
32
|
+
get_clipboard_manager,
|
|
33
|
+
has_image_in_clipboard,
|
|
34
|
+
)
|
|
30
35
|
from code_puppy.command_line.command_registry import get_unique_commands
|
|
31
36
|
from code_puppy.command_line.file_path_completion import FilePathCompleter
|
|
32
37
|
from code_puppy.command_line.load_context_completion import LoadContextCompleter
|
|
@@ -644,6 +649,113 @@ async def get_input_with_combined_completion(
|
|
|
644
649
|
else:
|
|
645
650
|
event.current_buffer.validate_and_handle()
|
|
646
651
|
|
|
652
|
+
# Handle bracketed paste (triggered by most terminal Cmd+V / Ctrl+V)
|
|
653
|
+
# This is the PRIMARY paste handler - works with Cmd+V on macOS terminals
|
|
654
|
+
@bindings.add(Keys.BracketedPaste)
|
|
655
|
+
def handle_bracketed_paste(event):
|
|
656
|
+
"""Handle bracketed paste - works with Cmd+V on macOS terminals."""
|
|
657
|
+
# The pasted data is in event.data
|
|
658
|
+
pasted_data = event.data
|
|
659
|
+
|
|
660
|
+
# Check if clipboard has an image (the pasted text might just be empty or a file path)
|
|
661
|
+
try:
|
|
662
|
+
if has_image_in_clipboard():
|
|
663
|
+
placeholder = capture_clipboard_image_to_pending()
|
|
664
|
+
if placeholder:
|
|
665
|
+
event.app.current_buffer.insert_text(placeholder + " ")
|
|
666
|
+
count = get_clipboard_manager().get_pending_count()
|
|
667
|
+
sys.stdout.write(f"\033[36m📋 +image ({count})\033[0m ")
|
|
668
|
+
sys.stdout.flush()
|
|
669
|
+
return # Don't also paste the text data
|
|
670
|
+
except Exception:
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
# No image - insert the pasted text as normal
|
|
674
|
+
if pasted_data:
|
|
675
|
+
event.app.current_buffer.insert_text(pasted_data)
|
|
676
|
+
|
|
677
|
+
# Fallback Ctrl+V for terminals without bracketed paste support
|
|
678
|
+
@bindings.add("c-v", eager=True)
|
|
679
|
+
def handle_smart_paste(event):
|
|
680
|
+
"""Handle Ctrl+V - auto-detect image vs text in clipboard."""
|
|
681
|
+
try:
|
|
682
|
+
# Check for image first
|
|
683
|
+
if has_image_in_clipboard():
|
|
684
|
+
placeholder = capture_clipboard_image_to_pending()
|
|
685
|
+
if placeholder:
|
|
686
|
+
event.app.current_buffer.insert_text(placeholder + " ")
|
|
687
|
+
count = get_clipboard_manager().get_pending_count()
|
|
688
|
+
sys.stdout.write(f"\033[36m📋 +image ({count})\033[0m ")
|
|
689
|
+
sys.stdout.flush()
|
|
690
|
+
return # Don't also paste text
|
|
691
|
+
except Exception:
|
|
692
|
+
pass # Fall through to text paste on any error
|
|
693
|
+
|
|
694
|
+
# No image (or error) - do normal text paste
|
|
695
|
+
# prompt_toolkit doesn't have built-in paste, so we handle it manually
|
|
696
|
+
try:
|
|
697
|
+
import platform
|
|
698
|
+
import subprocess
|
|
699
|
+
|
|
700
|
+
text = None
|
|
701
|
+
system = platform.system()
|
|
702
|
+
|
|
703
|
+
if system == "Darwin": # macOS
|
|
704
|
+
result = subprocess.run(
|
|
705
|
+
["pbpaste"], capture_output=True, text=True, timeout=2
|
|
706
|
+
)
|
|
707
|
+
if result.returncode == 0:
|
|
708
|
+
text = result.stdout
|
|
709
|
+
elif system == "Windows":
|
|
710
|
+
# Windows - use powershell
|
|
711
|
+
result = subprocess.run(
|
|
712
|
+
["powershell", "-command", "Get-Clipboard"],
|
|
713
|
+
capture_output=True,
|
|
714
|
+
text=True,
|
|
715
|
+
timeout=2,
|
|
716
|
+
)
|
|
717
|
+
if result.returncode == 0:
|
|
718
|
+
text = result.stdout.rstrip("\r\n")
|
|
719
|
+
else: # Linux
|
|
720
|
+
# Try xclip first, then xsel
|
|
721
|
+
for cmd in [
|
|
722
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
723
|
+
["xsel", "--clipboard", "--output"],
|
|
724
|
+
]:
|
|
725
|
+
try:
|
|
726
|
+
result = subprocess.run(
|
|
727
|
+
cmd, capture_output=True, text=True, timeout=2
|
|
728
|
+
)
|
|
729
|
+
if result.returncode == 0:
|
|
730
|
+
text = result.stdout
|
|
731
|
+
break
|
|
732
|
+
except FileNotFoundError:
|
|
733
|
+
continue
|
|
734
|
+
|
|
735
|
+
if text:
|
|
736
|
+
event.app.current_buffer.insert_text(text)
|
|
737
|
+
except Exception:
|
|
738
|
+
pass # Silently fail if text paste doesn't work
|
|
739
|
+
|
|
740
|
+
# F3 - dedicated image paste (shows error if no image)
|
|
741
|
+
@bindings.add("f3")
|
|
742
|
+
def handle_image_paste_f3(event):
|
|
743
|
+
"""Handle F3 - paste image from clipboard (image-only, shows error if none)."""
|
|
744
|
+
try:
|
|
745
|
+
if has_image_in_clipboard():
|
|
746
|
+
placeholder = capture_clipboard_image_to_pending()
|
|
747
|
+
if placeholder:
|
|
748
|
+
event.app.current_buffer.insert_text(placeholder + " ")
|
|
749
|
+
count = get_clipboard_manager().get_pending_count()
|
|
750
|
+
sys.stdout.write(f"\033[36m📋 +image ({count})\033[0m ")
|
|
751
|
+
sys.stdout.flush()
|
|
752
|
+
else:
|
|
753
|
+
sys.stdout.write("\033[33m⚠️ no image\033[0m ")
|
|
754
|
+
sys.stdout.flush()
|
|
755
|
+
except Exception:
|
|
756
|
+
sys.stdout.write("\033[31m❌ clipboard error\033[0m ")
|
|
757
|
+
sys.stdout.flush()
|
|
758
|
+
|
|
647
759
|
session = PromptSession(
|
|
648
760
|
completer=completer,
|
|
649
761
|
history=history,
|
|
@@ -222,18 +222,14 @@ class ManagedMCPServer:
|
|
|
222
222
|
http_kwargs["timeout"] = config["timeout"]
|
|
223
223
|
if "read_timeout" in config:
|
|
224
224
|
http_kwargs["read_timeout"] = config["read_timeout"]
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if isinstance(v, str):
|
|
232
|
-
resolved_headers[k] = os.path.expandvars(v)
|
|
233
|
-
else:
|
|
234
|
-
resolved_headers[k] = v
|
|
235
|
-
http_kwargs["headers"] = resolved_headers
|
|
225
|
+
|
|
226
|
+
# Handle http_client vs headers (mutually exclusive)
|
|
227
|
+
if "http_client" in config:
|
|
228
|
+
# Use provided http_client
|
|
229
|
+
http_kwargs["http_client"] = config["http_client"]
|
|
230
|
+
elif config.get("headers"):
|
|
236
231
|
# Create HTTP client if headers are provided but no client specified
|
|
232
|
+
http_kwargs["http_client"] = self._get_http_client()
|
|
237
233
|
|
|
238
234
|
self._pydantic_server = MCPServerStreamableHTTP(
|
|
239
235
|
**http_kwargs, process_tool_call=process_tool_call
|
code_puppy/tools/common.py
CHANGED
|
@@ -727,15 +727,9 @@ def _format_diff_with_syntax_highlighting(
|
|
|
727
727
|
result.append("\n")
|
|
728
728
|
continue
|
|
729
729
|
|
|
730
|
-
#
|
|
731
|
-
if line.startswith("---"):
|
|
732
|
-
|
|
733
|
-
elif line.startswith("+++"):
|
|
734
|
-
result.append(line, style="yellow")
|
|
735
|
-
elif line.startswith("@@"):
|
|
736
|
-
result.append(line, style="cyan")
|
|
737
|
-
elif line.startswith(("diff ", "index ")):
|
|
738
|
-
result.append(line, style="dim")
|
|
730
|
+
# Skip diff headers - they're redundant noise since we show the filename in the banner
|
|
731
|
+
if line.startswith(("---", "+++", "@@", "diff ", "index ")):
|
|
732
|
+
continue
|
|
739
733
|
else:
|
|
740
734
|
# Determine line type and extract code content
|
|
741
735
|
if line.startswith("-"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-puppy
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.340
|
|
4
4
|
Summary: Code generation agent
|
|
5
5
|
Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
|
|
6
6
|
Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
|
|
@@ -22,6 +22,7 @@ Requires-Dist: httpx[http2]>=0.24.1
|
|
|
22
22
|
Requires-Dist: json-repair>=0.46.2
|
|
23
23
|
Requires-Dist: logfire>=0.7.1
|
|
24
24
|
Requires-Dist: openai>=1.99.1
|
|
25
|
+
Requires-Dist: pillow>=10.0.0
|
|
25
26
|
Requires-Dist: playwright>=1.40.0
|
|
26
27
|
Requires-Dist: prompt-toolkit>=3.0.52
|
|
27
28
|
Requires-Dist: pydantic-ai==1.25.0
|
|
@@ -2,8 +2,8 @@ code_puppy/__init__.py,sha256=xMPewo9RNHb3yfFNIk5WCbv2cvSPtJOCgK2-GqLbNnU,373
|
|
|
2
2
|
code_puppy/__main__.py,sha256=pDVssJOWP8A83iFkxMLY9YteHYat0EyWDQqMkKHpWp4,203
|
|
3
3
|
code_puppy/callbacks.py,sha256=hqTV--dNxG5vwWWm3MrEjmb8MZuHFFdmHePl23NXPHk,8621
|
|
4
4
|
code_puppy/chatgpt_codex_client.py,sha256=Om0ANB_kpHubhCwNzF9ENf8RvKBqs0IYzBLl_SNw0Vk,9833
|
|
5
|
-
code_puppy/claude_cache_client.py,sha256=
|
|
6
|
-
code_puppy/cli_runner.py,sha256=
|
|
5
|
+
code_puppy/claude_cache_client.py,sha256=hfDsb2x_L2maXqI0xQNX7gCDeHRI9XEttK0geoiNqQM,10678
|
|
6
|
+
code_puppy/cli_runner.py,sha256=BQu5Sa9y_ueqtgvbmuhWS-Tmd1FAjMbhTrtjFKbVZjM,34919
|
|
7
7
|
code_puppy/config.py,sha256=RlnrLkyFXm7h2Htf8rQA7vqoAyzLPMrESle417uLmFw,52373
|
|
8
8
|
code_puppy/error_logging.py,sha256=a80OILCUtJhexI6a9GM-r5LqIdjvSRzggfgPp2jv1X0,3297
|
|
9
9
|
code_puppy/gemini_code_assist.py,sha256=KGS7sO5OLc83nDF3xxS-QiU6vxW9vcm6hmzilu79Ef8,13867
|
|
@@ -47,11 +47,12 @@ code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZ
|
|
|
47
47
|
code_puppy/command_line/add_model_menu.py,sha256=caXxSQc6dgx0qQ68RRFrDTsiH-wZjl4nUv2r0javhaM,43262
|
|
48
48
|
code_puppy/command_line/attachments.py,sha256=4Q5I2Es4j0ltnz5wjw2z0QXMsiMJvEfWRkPf_lJeITM,13093
|
|
49
49
|
code_puppy/command_line/autosave_menu.py,sha256=vW-2UqX8qT2Rb07ZSbsCG1MMwCPIH4MQ4YM9PEkcPyo,20105
|
|
50
|
+
code_puppy/command_line/clipboard.py,sha256=oe9bfAX5RnT81FiYrDmhvHaePS1tAT-NFG1fSXubSD4,16869
|
|
50
51
|
code_puppy/command_line/colors_menu.py,sha256=LoFVfJ-Mo-Eq9hnb2Rj5mn7oBCnadAGr-8NNHsHlu18,17273
|
|
51
52
|
code_puppy/command_line/command_handler.py,sha256=CY9F27eovZJK_kpU1YmbroYLWGTCuouCOQ-TXfDp-nw,10916
|
|
52
53
|
code_puppy/command_line/command_registry.py,sha256=qFySsw1g8dol3kgi0p6cXrIDlP11_OhOoaQ5nAadWXg,4416
|
|
53
54
|
code_puppy/command_line/config_commands.py,sha256=qS9Cm758DPz2QGvHLhAV4Tp_Xfgo3PyoCoLDusbnmCw,25742
|
|
54
|
-
code_puppy/command_line/core_commands.py,sha256=
|
|
55
|
+
code_puppy/command_line/core_commands.py,sha256=ujAPD4yDbXwYGJJfR2u4ei24eBV-Ps_-BVBjFMEoJy0,27668
|
|
55
56
|
code_puppy/command_line/diff_menu.py,sha256=_Gr9SP9fbItk-08dya9WTAR53s_PlyAvEnbt-8VWKPk,24141
|
|
56
57
|
code_puppy/command_line/file_path_completion.py,sha256=gw8NpIxa6GOpczUJRyh7VNZwoXKKn-yvCqit7h2y6Gg,2931
|
|
57
58
|
code_puppy/command_line/load_context_completion.py,sha256=a3JvLDeLLSYxVgTjAdqWzS4spjv6ccCrK2LKZgVJ1IM,2202
|
|
@@ -62,7 +63,7 @@ code_puppy/command_line/motd.py,sha256=XuIk3UTLawwVFM-NfoaJGU5F2hPLASTFXq84UdDMT
|
|
|
62
63
|
code_puppy/command_line/onboarding_slides.py,sha256=tHob7rB_n32dfjtPH-RSG0WLMjDHhlmNxfsF7WCgcVc,7191
|
|
63
64
|
code_puppy/command_line/onboarding_wizard.py,sha256=U5lV_1P3IwDYZUHar0zKgdp121zzkvOwwORvdCZwFcw,10241
|
|
64
65
|
code_puppy/command_line/pin_command_completion.py,sha256=juSvdqRpk7AdfkPy1DJx5NzfEUU5KYGlChvP0hisM18,11667
|
|
65
|
-
code_puppy/command_line/prompt_toolkit_completion.py,sha256=
|
|
66
|
+
code_puppy/command_line/prompt_toolkit_completion.py,sha256=pyawy4qWGIkEMZVWDRYy_noFTD25k6vR3Z2fSH12jVU,32009
|
|
66
67
|
code_puppy/command_line/session_commands.py,sha256=Jh8GGfhlfBAEVfucKLbcZjNaXYd0twImiOwq2ZnGdQQ,9902
|
|
67
68
|
code_puppy/command_line/utils.py,sha256=7eyxDHjPjPB9wGDJQQcXV_zOsGdYsFgI0SGCetVmTqE,1251
|
|
68
69
|
code_puppy/command_line/mcp/__init__.py,sha256=0-OQuwjq_pLiTVJ1_NrirVwdRerghyKs_MTZkwPC7YY,315
|
|
@@ -98,7 +99,7 @@ code_puppy/mcp_/config_wizard.py,sha256=JNNpgnSD6PFSyS3pTdEdD164oXd2VKp4VHLSz3To
|
|
|
98
99
|
code_puppy/mcp_/dashboard.py,sha256=VtaFxLtPnbM_HL2TXRDAg6IqcM-EcFkoghGgkfhMrKI,9417
|
|
99
100
|
code_puppy/mcp_/error_isolation.py,sha256=mpPBiH17zTXPsOEAn9WmkbwQwnt4gmgiaWv87JBJbUo,12426
|
|
100
101
|
code_puppy/mcp_/health_monitor.py,sha256=n5R6EeYOYbUucUFe74qGWCU3g6Mep5UEQbLF0wbT0dU,19688
|
|
101
|
-
code_puppy/mcp_/managed_server.py,sha256=
|
|
102
|
+
code_puppy/mcp_/managed_server.py,sha256=APqFKjHtsG8iM4so1dYxvKnb0BTmppHnaY8UJ5DBE9g,14075
|
|
102
103
|
code_puppy/mcp_/manager.py,sha256=pJ4cALicTxfwG2JIjJraLLf0Mzes-cEVAKIcUwfOoKA,29172
|
|
103
104
|
code_puppy/mcp_/mcp_logs.py,sha256=o4pSHwELWIjEjqhfaMMEGrBvb159-VIgUp21E707BPo,6264
|
|
104
105
|
code_puppy/mcp_/registry.py,sha256=U_t12WQ-En-KGyZoiTYdqlhp9NkDTWafu8g5InvF2NM,15774
|
|
@@ -160,7 +161,7 @@ code_puppy/prompts/codex_system_prompt.md,sha256=hEFTCziroLqZmqNle5kG34A8kvTteOW
|
|
|
160
161
|
code_puppy/tools/__init__.py,sha256=BVTZ85jLHgDANwOnUSOz3UDlp8VQDq4DoGF23BRlyWw,6032
|
|
161
162
|
code_puppy/tools/agent_tools.py,sha256=snBI6FlFtR03CbYKXwu53R48c_fRSuDIwcNdVUruLcA,21020
|
|
162
163
|
code_puppy/tools/command_runner.py,sha256=3qXVnVTaBPia6y2D29As47_TRKgpyCj82yMFK-8UUYc,44954
|
|
163
|
-
code_puppy/tools/common.py,sha256=
|
|
164
|
+
code_puppy/tools/common.py,sha256=IYf-KOcP5eN2MwTlpULSXNATn7GzloAKl7_M1Uyfe4Y,40360
|
|
164
165
|
code_puppy/tools/file_modifications.py,sha256=vz9n7R0AGDSdLUArZr_55yJLkyI30M8zreAppxIx02M,29380
|
|
165
166
|
code_puppy/tools/file_operations.py,sha256=CqhpuBnOFOcQCIYXOujskxq2VMLWYJhibYrH0YcPSfA,35692
|
|
166
167
|
code_puppy/tools/tools_content.py,sha256=bsBqW-ppd1XNAS_g50B3UHDQBWEALC1UneH6-afz1zo,2365
|
|
@@ -174,10 +175,10 @@ code_puppy/tools/browser/browser_scripts.py,sha256=sNb8eLEyzhasy5hV4B9OjM8yIVMLV
|
|
|
174
175
|
code_puppy/tools/browser/browser_workflows.py,sha256=nitW42vCf0ieTX1gLabozTugNQ8phtoFzZbiAhw1V90,6491
|
|
175
176
|
code_puppy/tools/browser/camoufox_manager.py,sha256=RZjGOEftE5sI_tsercUyXFSZI2wpStXf-q0PdYh2G3I,8680
|
|
176
177
|
code_puppy/tools/browser/vqa_agent.py,sha256=DBn9HKloILqJSTSdNZzH_PYWT0B2h9VwmY6akFQI_uU,2913
|
|
177
|
-
code_puppy-0.0.
|
|
178
|
-
code_puppy-0.0.
|
|
179
|
-
code_puppy-0.0.
|
|
180
|
-
code_puppy-0.0.
|
|
181
|
-
code_puppy-0.0.
|
|
182
|
-
code_puppy-0.0.
|
|
183
|
-
code_puppy-0.0.
|
|
178
|
+
code_puppy-0.0.340.data/data/code_puppy/models.json,sha256=FMQdE_yvP_8y0xxt3K918UkFL9cZMYAqW1SfXcQkU_k,3105
|
|
179
|
+
code_puppy-0.0.340.data/data/code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
|
|
180
|
+
code_puppy-0.0.340.dist-info/METADATA,sha256=QyW_MkSEm_N8B6ezU2dgW7rkkjI0mTiKhKjn37UEIZU,27550
|
|
181
|
+
code_puppy-0.0.340.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
182
|
+
code_puppy-0.0.340.dist-info/entry_points.txt,sha256=Tp4eQC99WY3HOKd3sdvb22vZODRq0XkZVNpXOag_KdI,91
|
|
183
|
+
code_puppy-0.0.340.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
|
|
184
|
+
code_puppy-0.0.340.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|