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.
@@ -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 idx, msg in enumerate(reversed(messages[1:])): # Skip the first message
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
- text_buffer[event.index] = [] # Initialize buffer
1441
- token_count[event.index] = 0 # Initialize token counter
1442
- # Buffer initial content if present
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
- text_buffer[event.index].append(part.content)
1445
- # Count chunks (each part counts as 1)
1446
- token_count[event.index] += 1
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, show token counter then render at end
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
- # Accumulate text for final markdown render
1469
- text_buffer[event.index].append(delta.content_delta)
1470
- # Count chunks received
1471
- token_count[event.index] += 1
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, clear counter line and render markdown
1546
+ # For text parts, finalize termflow rendering
1507
1547
  if event.index in text_parts:
1508
- # Clear the chunk counter line by printing spaces and returning
1509
- console.print(" " * 50, end="\r")
1510
- # Render the final markdown nicely
1511
- if event.index in text_buffer:
1512
- try:
1513
- final_content = "".join(text_buffer[event.index])
1514
- if final_content.strip():
1515
- console.print(Markdown(final_content))
1516
- except Exception:
1517
- pass
1518
- del text_buffer[event.index]
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
- from typing import Any, Callable
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
- return await super().send(request, *args, **kwargs)
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, ConsoleOptions, RenderResult
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"binary files: {len(processed_prompt.attachments)}")
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
- if not processed_prompt.prompt:
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
- processed_prompt.prompt,
839
+ cleaned_prompt, # Use cleaned prompt (clipboard placeholders removed)
823
840
  attachments=attachments,
824
841
  link_attachments=link_attachments,
825
842
  )