code-puppy 0.0.336__py3-none-any.whl → 0.0.348__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 +41 -224
- code_puppy/agents/event_stream_handler.py +257 -0
- code_puppy/claude_cache_client.py +208 -2
- code_puppy/cli_runner.py +53 -35
- code_puppy/command_line/add_model_menu.py +8 -9
- 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/mcp/catalog_server_installer.py +5 -6
- code_puppy/command_line/mcp/custom_server_form.py +54 -19
- code_puppy/command_line/mcp/custom_server_installer.py +8 -9
- code_puppy/command_line/mcp/handler.py +0 -2
- code_puppy/command_line/mcp/help_command.py +1 -5
- code_puppy/command_line/mcp/start_command.py +36 -18
- code_puppy/command_line/onboarding_slides.py +0 -1
- code_puppy/command_line/prompt_toolkit_completion.py +124 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/http_utils.py +93 -130
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/managed_server.py +49 -24
- code_puppy/mcp_/manager.py +81 -52
- code_puppy/messaging/message_queue.py +11 -23
- 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/agent_tools.py +66 -13
- 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.348.data}/data/code_puppy/models.json +2 -2
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/METADATA +19 -71
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/RECORD +39 -38
- code_puppy/command_line/mcp/add_command.py +0 -170
- {code_puppy-0.0.336.data → code_puppy-0.0.348.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.336.dist-info → code_puppy-0.0.348.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,33 +790,54 @@ 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
|
-
# IMPORTANT: Set the shared console
|
|
830
|
+
# IMPORTANT: Set the shared console for streaming output so it
|
|
814
831
|
# uses the same console as the spinner. This prevents Live display conflicts
|
|
815
832
|
# that cause line duplication during markdown streaming.
|
|
816
|
-
|
|
817
|
-
|
|
833
|
+
from code_puppy.agents.event_stream_handler import set_streaming_console
|
|
834
|
+
|
|
835
|
+
set_streaming_console(spinner_console)
|
|
818
836
|
|
|
819
837
|
# Create the agent task first so we can track and cancel it
|
|
820
838
|
agent_task = asyncio.create_task(
|
|
821
839
|
agent.run_with_mcp(
|
|
822
|
-
|
|
840
|
+
cleaned_prompt, # Use cleaned prompt (clipboard placeholders removed)
|
|
823
841
|
attachments=attachments,
|
|
824
842
|
link_attachments=link_attachments,
|
|
825
843
|
)
|
|
@@ -17,6 +17,7 @@ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
|
|
|
17
17
|
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
18
18
|
from prompt_toolkit.widgets import Frame
|
|
19
19
|
|
|
20
|
+
from code_puppy.command_line.utils import safe_input
|
|
20
21
|
from code_puppy.config import EXTRA_MODELS_FILE, set_config_value
|
|
21
22
|
from code_puppy.messaging import emit_error, emit_info, emit_warning
|
|
22
23
|
from code_puppy.models_dev_parser import ModelInfo, ModelsDevRegistry, ProviderInfo
|
|
@@ -724,8 +725,8 @@ class AddModelMenu:
|
|
|
724
725
|
emit_info(f" {hint}")
|
|
725
726
|
|
|
726
727
|
try:
|
|
727
|
-
# Use
|
|
728
|
-
value =
|
|
728
|
+
# Use safe_input for cross-platform compatibility (Windows fix)
|
|
729
|
+
value = safe_input(f" Enter {env_var} (or press Enter to skip): ")
|
|
729
730
|
|
|
730
731
|
if not value:
|
|
731
732
|
emit_warning(
|
|
@@ -785,7 +786,7 @@ class AddModelMenu:
|
|
|
785
786
|
)
|
|
786
787
|
|
|
787
788
|
try:
|
|
788
|
-
model_name =
|
|
789
|
+
model_name = safe_input(" Model ID: ")
|
|
789
790
|
|
|
790
791
|
if not model_name:
|
|
791
792
|
emit_warning("No model name provided, cancelled.")
|
|
@@ -795,7 +796,7 @@ class AddModelMenu:
|
|
|
795
796
|
emit_info("\n Enter the context window size (in tokens).")
|
|
796
797
|
emit_info(" Common sizes: 8192, 32768, 128000, 200000, 1000000\n")
|
|
797
798
|
|
|
798
|
-
context_input =
|
|
799
|
+
context_input = safe_input(" Context size [128000]: ")
|
|
799
800
|
|
|
800
801
|
if not context_input:
|
|
801
802
|
context_length = 128000 # Default
|
|
@@ -1045,11 +1046,9 @@ class AddModelMenu:
|
|
|
1045
1046
|
f" It will be very limited for coding tasks."
|
|
1046
1047
|
)
|
|
1047
1048
|
try:
|
|
1048
|
-
confirm = (
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
.lower()
|
|
1052
|
-
)
|
|
1049
|
+
confirm = safe_input(
|
|
1050
|
+
"\n Are you sure you want to add this model? (y/N): "
|
|
1051
|
+
).lower()
|
|
1053
1052
|
if confirm not in ("y", "yes"):
|
|
1054
1053
|
emit_info("Model addition cancelled.")
|
|
1055
1054
|
return False
|
|
@@ -69,12 +69,21 @@ def _get_session_entries(base_dir: Path) -> List[Tuple[str, dict]]:
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def _extract_last_user_message(history: list) -> str:
|
|
72
|
-
"""Extract the most recent user message from history.
|
|
72
|
+
"""Extract the most recent user message from history.
|
|
73
|
+
|
|
74
|
+
Joins all content parts from the message since messages can have
|
|
75
|
+
multiple parts (e.g., text + attachments, multi-part prompts).
|
|
76
|
+
"""
|
|
73
77
|
# Walk backwards through history to find last user message
|
|
74
78
|
for msg in reversed(history):
|
|
79
|
+
content_parts = []
|
|
75
80
|
for part in msg.parts:
|
|
76
81
|
if hasattr(part, "content"):
|
|
77
|
-
|
|
82
|
+
content = part.content
|
|
83
|
+
if isinstance(content, str) and content.strip():
|
|
84
|
+
content_parts.append(content)
|
|
85
|
+
if content_parts:
|
|
86
|
+
return "\n\n".join(content_parts)
|
|
78
87
|
return "[No messages found]"
|
|
79
88
|
|
|
80
89
|
|
|
@@ -298,19 +307,13 @@ def _render_message_browser_panel(
|
|
|
298
307
|
# Don't override Rich's ANSI styling - use empty style
|
|
299
308
|
text_color = ""
|
|
300
309
|
|
|
301
|
-
#
|
|
302
|
-
message_lines = rendered.split("\n")
|
|
303
|
-
is_truncated = len(rendered.split("\n")) > 35
|
|
310
|
+
# Show full message without truncation
|
|
311
|
+
message_lines = rendered.split("\n")
|
|
304
312
|
|
|
305
313
|
for line in message_lines:
|
|
306
314
|
lines.append((text_color, f" {line}"))
|
|
307
315
|
lines.append(("", "\n"))
|
|
308
316
|
|
|
309
|
-
if is_truncated:
|
|
310
|
-
lines.append(("", "\n"))
|
|
311
|
-
lines.append(("fg:yellow", " ... truncated (message too long)"))
|
|
312
|
-
lines.append(("", "\n"))
|
|
313
|
-
|
|
314
317
|
except Exception as e:
|
|
315
318
|
lines.append(("fg:red", f" Error rendering message: {e}"))
|
|
316
319
|
lines.append(("", "\n"))
|
|
@@ -359,7 +362,7 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
|
|
|
359
362
|
lines.append(("", "\n\n"))
|
|
360
363
|
|
|
361
364
|
lines.append(("bold", " Last Message:"))
|
|
362
|
-
lines.append(("fg:ansibrightblack", " (press 'e' to browse
|
|
365
|
+
lines.append(("fg:ansibrightblack", " (press 'e' to browse full history)"))
|
|
363
366
|
lines.append(("", "\n"))
|
|
364
367
|
|
|
365
368
|
# Try to load and preview the last message
|
|
@@ -367,15 +370,11 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
|
|
|
367
370
|
history = load_session(session_name, base_dir)
|
|
368
371
|
last_message = _extract_last_user_message(history)
|
|
369
372
|
|
|
370
|
-
#
|
|
371
|
-
original_lines = last_message.split("\n") if last_message else []
|
|
372
|
-
is_long = len(original_lines) > 30
|
|
373
|
-
|
|
374
|
-
# Render markdown with rich but strip ANSI codes
|
|
373
|
+
# Render markdown with rich
|
|
375
374
|
console = Console(
|
|
376
375
|
file=StringIO(),
|
|
377
376
|
legacy_windows=False,
|
|
378
|
-
no_color=False,
|
|
377
|
+
no_color=False,
|
|
379
378
|
force_terminal=False,
|
|
380
379
|
width=76,
|
|
381
380
|
)
|
|
@@ -383,19 +382,14 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
|
|
|
383
382
|
console.print(md)
|
|
384
383
|
rendered = console.file.getvalue()
|
|
385
384
|
|
|
386
|
-
#
|
|
387
|
-
message_lines = rendered.split("\n")
|
|
385
|
+
# Show full message without truncation
|
|
386
|
+
message_lines = rendered.split("\n")
|
|
388
387
|
|
|
389
388
|
for line in message_lines:
|
|
390
389
|
# Rich already rendered the markdown, just display it dimmed
|
|
391
390
|
lines.append(("fg:ansibrightblack", f" {line}"))
|
|
392
391
|
lines.append(("", "\n"))
|
|
393
392
|
|
|
394
|
-
if is_long:
|
|
395
|
-
lines.append(("", "\n"))
|
|
396
|
-
lines.append(("fg:yellow", " ... truncated"))
|
|
397
|
-
lines.append(("", "\n"))
|
|
398
|
-
|
|
399
393
|
except Exception as e:
|
|
400
394
|
lines.append(("fg:red", f" Error loading preview: {e}"))
|
|
401
395
|
lines.append(("", "\n"))
|