code-puppy 0.0.336__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 +79 -31
- code_puppy/claude_cache_client.py +208 -2
- code_puppy/cli_runner.py +49 -32
- code_puppy/command_line/autosave_menu.py +18 -24
- 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 +118 -0
- code_puppy/http_utils.py +93 -130
- code_puppy/mcp_/managed_server.py +7 -11
- code_puppy/messaging/messages.py +3 -0
- code_puppy/messaging/rich_renderer.py +13 -3
- code_puppy/model_factory.py +16 -0
- code_puppy/models.json +2 -2
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +17 -2
- code_puppy/plugins/claude_code_oauth/utils.py +126 -7
- code_puppy/terminal_utils.py +128 -1
- code_puppy/tools/command_runner.py +1 -0
- code_puppy/tools/common.py +3 -9
- {code_puppy-0.0.336.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.341.dist-info}/METADATA +19 -71
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.341.dist-info}/RECORD +25 -24
- {code_puppy-0.0.336.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.341.dist-info}/licenses/LICENSE +0 -0
code_puppy/agents/base_agent.py
CHANGED
|
@@ -913,6 +913,11 @@ class BaseAgent(ABC):
|
|
|
913
913
|
"""
|
|
914
914
|
Truncate message history to manage token usage.
|
|
915
915
|
|
|
916
|
+
Protects:
|
|
917
|
+
- The first message (system prompt) - always kept
|
|
918
|
+
- The second message if it contains a ThinkingPart (extended thinking context)
|
|
919
|
+
- The most recent messages up to protected_tokens
|
|
920
|
+
|
|
916
921
|
Args:
|
|
917
922
|
messages: List of messages to truncate
|
|
918
923
|
protected_tokens: Number of tokens to protect
|
|
@@ -924,12 +929,30 @@ class BaseAgent(ABC):
|
|
|
924
929
|
|
|
925
930
|
emit_info("Truncating message history to manage token usage")
|
|
926
931
|
result = [messages[0]] # Always keep the first message (system prompt)
|
|
932
|
+
|
|
933
|
+
# Check if second message exists and contains a ThinkingPart
|
|
934
|
+
# If so, protect it (extended thinking context shouldn't be lost)
|
|
935
|
+
skip_second = False
|
|
936
|
+
if len(messages) > 1:
|
|
937
|
+
second_msg = messages[1]
|
|
938
|
+
has_thinking = any(
|
|
939
|
+
isinstance(part, ThinkingPart) for part in second_msg.parts
|
|
940
|
+
)
|
|
941
|
+
if has_thinking:
|
|
942
|
+
result.append(second_msg)
|
|
943
|
+
skip_second = True
|
|
944
|
+
|
|
927
945
|
num_tokens = 0
|
|
928
946
|
stack = queue.LifoQueue()
|
|
929
947
|
|
|
948
|
+
# Determine which messages to consider for the recent-tokens window
|
|
949
|
+
# Skip first message (already added), and skip second if it has thinking
|
|
950
|
+
start_idx = 2 if skip_second else 1
|
|
951
|
+
messages_to_scan = messages[start_idx:]
|
|
952
|
+
|
|
930
953
|
# Put messages in reverse order (most recent first) into the stack
|
|
931
954
|
# but break when we exceed protected_tokens
|
|
932
|
-
for
|
|
955
|
+
for msg in reversed(messages_to_scan):
|
|
933
956
|
num_tokens += self.estimate_tokens_for_message(msg)
|
|
934
957
|
if num_tokens > protected_tokens:
|
|
935
958
|
break
|
|
@@ -1353,7 +1376,6 @@ class BaseAgent(ABC):
|
|
|
1353
1376
|
ToolCallPartDelta,
|
|
1354
1377
|
)
|
|
1355
1378
|
from rich.console import Console
|
|
1356
|
-
from rich.markdown import Markdown
|
|
1357
1379
|
from rich.markup import escape
|
|
1358
1380
|
|
|
1359
1381
|
from code_puppy.messaging.spinner import pause_all_spinners
|
|
@@ -1375,10 +1397,17 @@ class BaseAgent(ABC):
|
|
|
1375
1397
|
text_parts: set[int] = set() # Track which parts are text
|
|
1376
1398
|
tool_parts: set[int] = set() # Track which parts are tool calls
|
|
1377
1399
|
banner_printed: set[int] = set() # Track if banner was already printed
|
|
1378
|
-
text_buffer: dict[int, list[str]] = {} # Buffer text for final markdown render
|
|
1379
1400
|
token_count: dict[int, int] = {} # Track token count per text/tool part
|
|
1380
1401
|
did_stream_anything = False # Track if we streamed any content
|
|
1381
1402
|
|
|
1403
|
+
# Termflow streaming state for text parts
|
|
1404
|
+
from termflow import Parser as TermflowParser
|
|
1405
|
+
from termflow import Renderer as TermflowRenderer
|
|
1406
|
+
|
|
1407
|
+
termflow_parsers: dict[int, TermflowParser] = {}
|
|
1408
|
+
termflow_renderers: dict[int, TermflowRenderer] = {}
|
|
1409
|
+
termflow_line_buffers: dict[int, str] = {} # Buffer incomplete lines
|
|
1410
|
+
|
|
1382
1411
|
def _print_thinking_banner() -> None:
|
|
1383
1412
|
"""Print the THINKING banner with spinner pause and line clear."""
|
|
1384
1413
|
nonlocal did_stream_anything
|
|
@@ -1437,13 +1466,17 @@ class BaseAgent(ABC):
|
|
|
1437
1466
|
elif isinstance(part, TextPart):
|
|
1438
1467
|
streaming_parts.add(event.index)
|
|
1439
1468
|
text_parts.add(event.index)
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1469
|
+
# Initialize termflow streaming for this text part
|
|
1470
|
+
termflow_parsers[event.index] = TermflowParser()
|
|
1471
|
+
termflow_renderers[event.index] = TermflowRenderer(
|
|
1472
|
+
output=console.file, width=console.width
|
|
1473
|
+
)
|
|
1474
|
+
termflow_line_buffers[event.index] = ""
|
|
1475
|
+
# Handle initial content if present
|
|
1443
1476
|
if part.content and part.content.strip():
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1477
|
+
_print_response_banner()
|
|
1478
|
+
banner_printed.add(event.index)
|
|
1479
|
+
termflow_line_buffers[event.index] = part.content
|
|
1447
1480
|
elif isinstance(part, ToolCallPart):
|
|
1448
1481
|
streaming_parts.add(event.index)
|
|
1449
1482
|
tool_parts.add(event.index)
|
|
@@ -1459,22 +1492,29 @@ class BaseAgent(ABC):
|
|
|
1459
1492
|
delta = event.delta
|
|
1460
1493
|
if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
|
|
1461
1494
|
if delta.content_delta:
|
|
1462
|
-
# For text parts,
|
|
1495
|
+
# For text parts, stream markdown with termflow
|
|
1463
1496
|
if event.index in text_parts:
|
|
1464
1497
|
# Print banner on first content
|
|
1465
1498
|
if event.index not in banner_printed:
|
|
1466
1499
|
_print_response_banner()
|
|
1467
1500
|
banner_printed.add(event.index)
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
# Update chunk counter in place (single line)
|
|
1473
|
-
count = token_count[event.index]
|
|
1474
|
-
console.print(
|
|
1475
|
-
f" ⏳ Receiving... {count} chunks ",
|
|
1476
|
-
end="\r",
|
|
1501
|
+
|
|
1502
|
+
# Add content to line buffer
|
|
1503
|
+
termflow_line_buffers[event.index] += (
|
|
1504
|
+
delta.content_delta
|
|
1477
1505
|
)
|
|
1506
|
+
|
|
1507
|
+
# Process complete lines
|
|
1508
|
+
parser = termflow_parsers[event.index]
|
|
1509
|
+
renderer = termflow_renderers[event.index]
|
|
1510
|
+
buffer = termflow_line_buffers[event.index]
|
|
1511
|
+
|
|
1512
|
+
while "\n" in buffer:
|
|
1513
|
+
line, buffer = buffer.split("\n", 1)
|
|
1514
|
+
events_to_render = parser.parse_line(line)
|
|
1515
|
+
renderer.render_all(events_to_render)
|
|
1516
|
+
|
|
1517
|
+
termflow_line_buffers[event.index] = buffer
|
|
1478
1518
|
else:
|
|
1479
1519
|
# For thinking parts, stream immediately (dim)
|
|
1480
1520
|
if event.index not in banner_printed:
|
|
@@ -1503,19 +1543,27 @@ class BaseAgent(ABC):
|
|
|
1503
1543
|
# PartEndEvent - finish the streaming with a newline
|
|
1504
1544
|
elif isinstance(event, PartEndEvent):
|
|
1505
1545
|
if event.index in streaming_parts:
|
|
1506
|
-
# For text parts,
|
|
1546
|
+
# For text parts, finalize termflow rendering
|
|
1507
1547
|
if event.index in text_parts:
|
|
1508
|
-
#
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1548
|
+
# Render any remaining buffered content
|
|
1549
|
+
if event.index in termflow_parsers:
|
|
1550
|
+
parser = termflow_parsers[event.index]
|
|
1551
|
+
renderer = termflow_renderers[event.index]
|
|
1552
|
+
remaining = termflow_line_buffers.get(event.index, "")
|
|
1553
|
+
|
|
1554
|
+
# Parse and render any remaining partial line
|
|
1555
|
+
if remaining.strip():
|
|
1556
|
+
events_to_render = parser.parse_line(remaining)
|
|
1557
|
+
renderer.render_all(events_to_render)
|
|
1558
|
+
|
|
1559
|
+
# Finalize the parser to close any open blocks
|
|
1560
|
+
final_events = parser.finalize()
|
|
1561
|
+
renderer.render_all(final_events)
|
|
1562
|
+
|
|
1563
|
+
# Clean up termflow state
|
|
1564
|
+
del termflow_parsers[event.index]
|
|
1565
|
+
del termflow_renderers[event.index]
|
|
1566
|
+
del termflow_line_buffers[event.index]
|
|
1519
1567
|
# For tool parts, clear the chunk counter line
|
|
1520
1568
|
elif event.index in tool_parts:
|
|
1521
1569
|
# Clear the chunk counter line by printing spaces and returning
|
|
@@ -9,11 +9,19 @@ serialization, avoiding httpx/Pydantic internals.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import base64
|
|
12
13
|
import json
|
|
13
|
-
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable, MutableMapping
|
|
14
17
|
|
|
15
18
|
import httpx
|
|
16
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Refresh token if it's older than 1 hour (3600 seconds)
|
|
23
|
+
TOKEN_MAX_AGE_SECONDS = 3600
|
|
24
|
+
|
|
17
25
|
try:
|
|
18
26
|
from anthropic import AsyncAnthropic
|
|
19
27
|
except ImportError: # pragma: no cover - optional dep
|
|
@@ -21,9 +29,108 @@ except ImportError: # pragma: no cover - optional dep
|
|
|
21
29
|
|
|
22
30
|
|
|
23
31
|
class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
32
|
+
def _get_jwt_age_seconds(self, token: str | None) -> float | None:
|
|
33
|
+
"""Decode a JWT and return its age in seconds.
|
|
34
|
+
|
|
35
|
+
Returns None if the token can't be decoded or has no timestamp claims.
|
|
36
|
+
Uses 'iat' (issued at) if available, otherwise calculates from 'exp'.
|
|
37
|
+
"""
|
|
38
|
+
if not token:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# JWT format: header.payload.signature
|
|
43
|
+
# We only need the payload (second part)
|
|
44
|
+
parts = token.split(".")
|
|
45
|
+
if len(parts) != 3:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Decode the payload (base64url encoded)
|
|
49
|
+
payload_b64 = parts[1]
|
|
50
|
+
# Add padding if needed (base64url doesn't require padding)
|
|
51
|
+
padding = 4 - len(payload_b64) % 4
|
|
52
|
+
if padding != 4:
|
|
53
|
+
payload_b64 += "=" * padding
|
|
54
|
+
|
|
55
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
56
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
57
|
+
|
|
58
|
+
now = time.time()
|
|
59
|
+
|
|
60
|
+
# Prefer 'iat' (issued at) claim if available
|
|
61
|
+
if "iat" in payload:
|
|
62
|
+
iat = float(payload["iat"])
|
|
63
|
+
age = now - iat
|
|
64
|
+
return age
|
|
65
|
+
|
|
66
|
+
# Fall back to calculating from 'exp' claim
|
|
67
|
+
# Assume tokens are typically valid for 1 hour
|
|
68
|
+
if "exp" in payload:
|
|
69
|
+
exp = float(payload["exp"])
|
|
70
|
+
# If exp is in the future, calculate how long until expiry
|
|
71
|
+
# and assume the token was issued 1 hour before expiry
|
|
72
|
+
time_until_exp = exp - now
|
|
73
|
+
# If token has less than 1 hour left, it's "old"
|
|
74
|
+
age = TOKEN_MAX_AGE_SECONDS - time_until_exp
|
|
75
|
+
return max(0, age)
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
logger.debug("Failed to decode JWT age: %s", exc)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def _extract_bearer_token(self, request: httpx.Request) -> str | None:
|
|
83
|
+
"""Extract the bearer token from request headers."""
|
|
84
|
+
auth_header = request.headers.get("Authorization") or request.headers.get(
|
|
85
|
+
"authorization"
|
|
86
|
+
)
|
|
87
|
+
if auth_header and auth_header.lower().startswith("bearer "):
|
|
88
|
+
return auth_header[7:] # Strip "Bearer " prefix
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def _should_refresh_token(self, request: httpx.Request) -> bool:
|
|
92
|
+
"""Check if the token in the request is older than 1 hour."""
|
|
93
|
+
token = self._extract_bearer_token(request)
|
|
94
|
+
if not token:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
age = self._get_jwt_age_seconds(token)
|
|
98
|
+
if age is None:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
should_refresh = age >= TOKEN_MAX_AGE_SECONDS
|
|
102
|
+
if should_refresh:
|
|
103
|
+
logger.info(
|
|
104
|
+
"JWT token is %.1f seconds old (>= %d), will refresh proactively",
|
|
105
|
+
age,
|
|
106
|
+
TOKEN_MAX_AGE_SECONDS,
|
|
107
|
+
)
|
|
108
|
+
return should_refresh
|
|
109
|
+
|
|
24
110
|
async def send(
|
|
25
111
|
self, request: httpx.Request, *args: Any, **kwargs: Any
|
|
26
112
|
) -> httpx.Response: # type: ignore[override]
|
|
113
|
+
# Proactive token refresh: check JWT age before every request
|
|
114
|
+
if not request.extensions.get("claude_oauth_refresh_attempted"):
|
|
115
|
+
try:
|
|
116
|
+
if self._should_refresh_token(request):
|
|
117
|
+
refreshed_token = self._refresh_claude_oauth_token()
|
|
118
|
+
if refreshed_token:
|
|
119
|
+
logger.info("Proactively refreshed token before request")
|
|
120
|
+
# Rebuild request with new token
|
|
121
|
+
headers = dict(request.headers)
|
|
122
|
+
self._update_auth_headers(headers, refreshed_token)
|
|
123
|
+
body_bytes = self._extract_body_bytes(request)
|
|
124
|
+
request = self.build_request(
|
|
125
|
+
method=request.method,
|
|
126
|
+
url=request.url,
|
|
127
|
+
headers=headers,
|
|
128
|
+
content=body_bytes,
|
|
129
|
+
)
|
|
130
|
+
request.extensions["claude_oauth_refresh_attempted"] = True
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
logger.debug("Error during proactive token refresh check: %s", exc)
|
|
133
|
+
|
|
27
134
|
try:
|
|
28
135
|
if request.url.path.endswith("/v1/messages"):
|
|
29
136
|
body_bytes = self._extract_body_bytes(request)
|
|
@@ -56,7 +163,47 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
56
163
|
except Exception:
|
|
57
164
|
# Swallow wrapper errors; do not break real calls.
|
|
58
165
|
pass
|
|
59
|
-
|
|
166
|
+
response = await super().send(request, *args, **kwargs)
|
|
167
|
+
try:
|
|
168
|
+
# Check for both 401 and 400 - Anthropic/Cloudflare may return 400 for auth errors
|
|
169
|
+
# Also check if it's a Cloudflare HTML error response
|
|
170
|
+
if response.status_code in (400, 401) and not request.extensions.get(
|
|
171
|
+
"claude_oauth_refresh_attempted"
|
|
172
|
+
):
|
|
173
|
+
# Determine if this is an auth error (including Cloudflare HTML errors)
|
|
174
|
+
is_auth_error = response.status_code == 401
|
|
175
|
+
|
|
176
|
+
if response.status_code == 400:
|
|
177
|
+
# Check if this is a Cloudflare HTML error
|
|
178
|
+
is_auth_error = self._is_cloudflare_html_error(response)
|
|
179
|
+
if is_auth_error:
|
|
180
|
+
logger.info(
|
|
181
|
+
"Detected Cloudflare 400 error (likely auth-related), attempting token refresh"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if is_auth_error:
|
|
185
|
+
refreshed_token = self._refresh_claude_oauth_token()
|
|
186
|
+
if refreshed_token:
|
|
187
|
+
logger.info("Token refreshed successfully, retrying request")
|
|
188
|
+
await response.aclose()
|
|
189
|
+
body_bytes = self._extract_body_bytes(request)
|
|
190
|
+
headers = dict(request.headers)
|
|
191
|
+
self._update_auth_headers(headers, refreshed_token)
|
|
192
|
+
retry_request = self.build_request(
|
|
193
|
+
method=request.method,
|
|
194
|
+
url=request.url,
|
|
195
|
+
headers=headers,
|
|
196
|
+
content=body_bytes,
|
|
197
|
+
)
|
|
198
|
+
retry_request.extensions["claude_oauth_refresh_attempted"] = (
|
|
199
|
+
True
|
|
200
|
+
)
|
|
201
|
+
return await super().send(retry_request, *args, **kwargs)
|
|
202
|
+
else:
|
|
203
|
+
logger.warning("Token refresh failed, returning original error")
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
logger.debug("Error during token refresh attempt: %s", exc)
|
|
206
|
+
return response
|
|
60
207
|
|
|
61
208
|
@staticmethod
|
|
62
209
|
def _extract_body_bytes(request: httpx.Request) -> bytes | None:
|
|
@@ -78,6 +225,65 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
78
225
|
|
|
79
226
|
return None
|
|
80
227
|
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _update_auth_headers(
|
|
230
|
+
headers: MutableMapping[str, str], access_token: str
|
|
231
|
+
) -> None:
|
|
232
|
+
bearer_value = f"Bearer {access_token}"
|
|
233
|
+
if "Authorization" in headers or "authorization" in headers:
|
|
234
|
+
headers["Authorization"] = bearer_value
|
|
235
|
+
elif "x-api-key" in headers or "X-API-Key" in headers:
|
|
236
|
+
headers["x-api-key"] = access_token
|
|
237
|
+
else:
|
|
238
|
+
headers["Authorization"] = bearer_value
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _is_cloudflare_html_error(response: httpx.Response) -> bool:
|
|
242
|
+
"""Check if this is a Cloudflare HTML error response.
|
|
243
|
+
|
|
244
|
+
Cloudflare often returns HTML error pages with status 400 when
|
|
245
|
+
there are authentication issues.
|
|
246
|
+
"""
|
|
247
|
+
# Check content type
|
|
248
|
+
content_type = response.headers.get("content-type", "")
|
|
249
|
+
if "text/html" not in content_type.lower():
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
# Check if body contains Cloudflare markers
|
|
253
|
+
try:
|
|
254
|
+
# Read response body if not already consumed
|
|
255
|
+
if hasattr(response, "_content") and response._content:
|
|
256
|
+
body = response._content.decode("utf-8", errors="ignore")
|
|
257
|
+
else:
|
|
258
|
+
# Try to read the text (this might be already consumed)
|
|
259
|
+
try:
|
|
260
|
+
body = response.text
|
|
261
|
+
except Exception:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
# Look for Cloudflare and 400 Bad Request markers
|
|
265
|
+
body_lower = body.lower()
|
|
266
|
+
return "cloudflare" in body_lower and "400 bad request" in body_lower
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
logger.debug("Error checking for Cloudflare error: %s", exc)
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def _refresh_claude_oauth_token(self) -> str | None:
|
|
272
|
+
try:
|
|
273
|
+
from code_puppy.plugins.claude_code_oauth.utils import refresh_access_token
|
|
274
|
+
|
|
275
|
+
logger.info("Attempting to refresh Claude Code OAuth token...")
|
|
276
|
+
refreshed_token = refresh_access_token(force=True)
|
|
277
|
+
if refreshed_token:
|
|
278
|
+
self._update_auth_headers(self.headers, refreshed_token)
|
|
279
|
+
logger.info("Successfully refreshed Claude Code OAuth token")
|
|
280
|
+
else:
|
|
281
|
+
logger.warning("Token refresh returned None")
|
|
282
|
+
return refreshed_token
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
logger.error("Exception during token refresh: %s", exc)
|
|
285
|
+
return None
|
|
286
|
+
|
|
81
287
|
@staticmethod
|
|
82
288
|
def _inject_cache_control(body: bytes) -> bytes | None:
|
|
83
289
|
try:
|
code_puppy/cli_runner.py
CHANGED
|
@@ -17,14 +17,12 @@ import traceback
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
from dbos import DBOS, DBOSConfig
|
|
20
|
-
from rich.console import Console
|
|
21
|
-
from rich.markdown import CodeBlock, Markdown
|
|
22
|
-
from rich.syntax import Syntax
|
|
23
|
-
from rich.text import Text
|
|
20
|
+
from rich.console import Console
|
|
24
21
|
|
|
25
22
|
from code_puppy import __version__, callbacks, plugins
|
|
26
23
|
from code_puppy.agents import get_current_agent
|
|
27
24
|
from code_puppy.command_line.attachments import parse_prompt_attachments
|
|
25
|
+
from code_puppy.command_line.clipboard import get_clipboard_manager
|
|
28
26
|
from code_puppy.config import (
|
|
29
27
|
AUTOSAVE_DIR,
|
|
30
28
|
COMMAND_HISTORY_FILE,
|
|
@@ -43,6 +41,7 @@ from code_puppy.keymap import (
|
|
|
43
41
|
)
|
|
44
42
|
from code_puppy.messaging import emit_info
|
|
45
43
|
from code_puppy.terminal_utils import (
|
|
44
|
+
print_truecolor_warning,
|
|
46
45
|
reset_unix_terminal,
|
|
47
46
|
reset_windows_terminal_ansi,
|
|
48
47
|
reset_windows_terminal_full,
|
|
@@ -91,7 +90,6 @@ async def main():
|
|
|
91
90
|
"command", nargs="*", help="Run a single command (deprecated, use -p instead)"
|
|
92
91
|
)
|
|
93
92
|
args = parser.parse_args()
|
|
94
|
-
from rich.console import Console
|
|
95
93
|
|
|
96
94
|
from code_puppy.messaging import (
|
|
97
95
|
RichConsoleRenderer,
|
|
@@ -146,6 +144,9 @@ async def main():
|
|
|
146
144
|
except ImportError:
|
|
147
145
|
emit_system_message("🐶 Code Puppy is Loading...")
|
|
148
146
|
|
|
147
|
+
# Truecolor warning moved to interactive_mode() so it prints LAST
|
|
148
|
+
# after all the help stuff - max visibility for the ugly red box!
|
|
149
|
+
|
|
149
150
|
available_port = find_available_port()
|
|
150
151
|
if available_port is None:
|
|
151
152
|
emit_error("No available ports in range 8090-9010!")
|
|
@@ -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
|
|
@@ -679,8 +699,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
679
699
|
save_command_to_history(task)
|
|
680
700
|
|
|
681
701
|
try:
|
|
682
|
-
prettier_code_blocks()
|
|
683
|
-
|
|
684
702
|
# No need to get agent directly - use manager's run methods
|
|
685
703
|
|
|
686
704
|
# Use our custom helper to enable attachment handling with spinner support
|
|
@@ -750,28 +768,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
750
768
|
pass
|
|
751
769
|
|
|
752
770
|
|
|
753
|
-
def prettier_code_blocks():
|
|
754
|
-
"""Configure Rich to use prettier code block rendering."""
|
|
755
|
-
|
|
756
|
-
class SimpleCodeBlock(CodeBlock):
|
|
757
|
-
def __rich_console__(
|
|
758
|
-
self, console: Console, options: ConsoleOptions
|
|
759
|
-
) -> RenderResult:
|
|
760
|
-
code = str(self.text).rstrip()
|
|
761
|
-
yield Text(self.lexer_name, style="dim")
|
|
762
|
-
syntax = Syntax(
|
|
763
|
-
code,
|
|
764
|
-
self.lexer_name,
|
|
765
|
-
theme=self.theme,
|
|
766
|
-
background_color="default",
|
|
767
|
-
line_numbers=True,
|
|
768
|
-
)
|
|
769
|
-
yield syntax
|
|
770
|
-
yield Text(f"/{self.lexer_name}", style="dim")
|
|
771
|
-
|
|
772
|
-
Markdown.elements["fence"] = SimpleCodeBlock
|
|
773
|
-
|
|
774
|
-
|
|
775
771
|
async def run_prompt_with_attachments(
|
|
776
772
|
agent,
|
|
777
773
|
raw_prompt: str,
|
|
@@ -785,6 +781,7 @@ async def run_prompt_with_attachments(
|
|
|
785
781
|
tuple: (result, task) where result is the agent response and task is the asyncio task
|
|
786
782
|
"""
|
|
787
783
|
import asyncio
|
|
784
|
+
import re
|
|
788
785
|
|
|
789
786
|
from code_puppy.messaging import emit_system_message, emit_warning
|
|
790
787
|
|
|
@@ -793,21 +790,41 @@ async def run_prompt_with_attachments(
|
|
|
793
790
|
for warning in processed_prompt.warnings:
|
|
794
791
|
emit_warning(warning)
|
|
795
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
|
|
796
801
|
summary_parts = []
|
|
797
802
|
if processed_prompt.attachments:
|
|
798
|
-
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)}")
|
|
799
806
|
if processed_prompt.link_attachments:
|
|
800
807
|
summary_parts.append(f"urls: {len(processed_prompt.link_attachments)}")
|
|
801
808
|
if summary_parts:
|
|
802
809
|
emit_system_message("Attachments detected -> " + ", ".join(summary_parts))
|
|
803
810
|
|
|
804
|
-
|
|
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:
|
|
805
819
|
emit_warning(
|
|
806
820
|
"Prompt is empty after removing attachments; add instructions and retry."
|
|
807
821
|
)
|
|
808
822
|
return None, None
|
|
809
823
|
|
|
824
|
+
# Combine file attachments with clipboard images
|
|
810
825
|
attachments = [attachment.content for attachment in processed_prompt.attachments]
|
|
826
|
+
attachments.extend(clipboard_images) # Add clipboard images
|
|
827
|
+
|
|
811
828
|
link_attachments = [link.url_part for link in processed_prompt.link_attachments]
|
|
812
829
|
|
|
813
830
|
# IMPORTANT: Set the shared console on the agent so that streaming output
|
|
@@ -819,7 +836,7 @@ async def run_prompt_with_attachments(
|
|
|
819
836
|
# Create the agent task first so we can track and cancel it
|
|
820
837
|
agent_task = asyncio.create_task(
|
|
821
838
|
agent.run_with_mcp(
|
|
822
|
-
|
|
839
|
+
cleaned_prompt, # Use cleaned prompt (clipboard placeholders removed)
|
|
823
840
|
attachments=attachments,
|
|
824
841
|
link_attachments=link_attachments,
|
|
825
842
|
)
|