code-puppy 0.0.337__py3-none-any.whl → 0.0.339__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 +55 -30
- code_puppy/claude_cache_client.py +46 -2
- code_puppy/cli_runner.py +45 -32
- 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/model_factory.py +16 -0
- code_puppy/models.json +2 -2
- code_puppy/plugins/claude_code_oauth/utils.py +126 -7
- code_puppy/terminal_utils.py +128 -1
- {code_puppy-0.0.337.data → code_puppy-0.0.339.data}/data/code_puppy/models.json +2 -2
- {code_puppy-0.0.337.dist-info → code_puppy-0.0.339.dist-info}/METADATA +19 -71
- {code_puppy-0.0.337.dist-info → code_puppy-0.0.339.dist-info}/RECORD +17 -16
- {code_puppy-0.0.337.data → code_puppy-0.0.339.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.337.dist-info → code_puppy-0.0.339.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.337.dist-info → code_puppy-0.0.339.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.337.dist-info → code_puppy-0.0.339.dist-info}/licenses/LICENSE +0 -0
code_puppy/agents/base_agent.py
CHANGED
|
@@ -1353,7 +1353,6 @@ class BaseAgent(ABC):
|
|
|
1353
1353
|
ToolCallPartDelta,
|
|
1354
1354
|
)
|
|
1355
1355
|
from rich.console import Console
|
|
1356
|
-
from rich.markdown import Markdown
|
|
1357
1356
|
from rich.markup import escape
|
|
1358
1357
|
|
|
1359
1358
|
from code_puppy.messaging.spinner import pause_all_spinners
|
|
@@ -1375,10 +1374,17 @@ class BaseAgent(ABC):
|
|
|
1375
1374
|
text_parts: set[int] = set() # Track which parts are text
|
|
1376
1375
|
tool_parts: set[int] = set() # Track which parts are tool calls
|
|
1377
1376
|
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
1377
|
token_count: dict[int, int] = {} # Track token count per text/tool part
|
|
1380
1378
|
did_stream_anything = False # Track if we streamed any content
|
|
1381
1379
|
|
|
1380
|
+
# Termflow streaming state for text parts
|
|
1381
|
+
from termflow import Parser as TermflowParser
|
|
1382
|
+
from termflow import Renderer as TermflowRenderer
|
|
1383
|
+
|
|
1384
|
+
termflow_parsers: dict[int, TermflowParser] = {}
|
|
1385
|
+
termflow_renderers: dict[int, TermflowRenderer] = {}
|
|
1386
|
+
termflow_line_buffers: dict[int, str] = {} # Buffer incomplete lines
|
|
1387
|
+
|
|
1382
1388
|
def _print_thinking_banner() -> None:
|
|
1383
1389
|
"""Print the THINKING banner with spinner pause and line clear."""
|
|
1384
1390
|
nonlocal did_stream_anything
|
|
@@ -1437,13 +1443,17 @@ class BaseAgent(ABC):
|
|
|
1437
1443
|
elif isinstance(part, TextPart):
|
|
1438
1444
|
streaming_parts.add(event.index)
|
|
1439
1445
|
text_parts.add(event.index)
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1446
|
+
# Initialize termflow streaming for this text part
|
|
1447
|
+
termflow_parsers[event.index] = TermflowParser()
|
|
1448
|
+
termflow_renderers[event.index] = TermflowRenderer(
|
|
1449
|
+
output=console.file, width=console.width
|
|
1450
|
+
)
|
|
1451
|
+
termflow_line_buffers[event.index] = ""
|
|
1452
|
+
# Handle initial content if present
|
|
1443
1453
|
if part.content and part.content.strip():
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1454
|
+
_print_response_banner()
|
|
1455
|
+
banner_printed.add(event.index)
|
|
1456
|
+
termflow_line_buffers[event.index] = part.content
|
|
1447
1457
|
elif isinstance(part, ToolCallPart):
|
|
1448
1458
|
streaming_parts.add(event.index)
|
|
1449
1459
|
tool_parts.add(event.index)
|
|
@@ -1459,22 +1469,29 @@ class BaseAgent(ABC):
|
|
|
1459
1469
|
delta = event.delta
|
|
1460
1470
|
if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
|
|
1461
1471
|
if delta.content_delta:
|
|
1462
|
-
# For text parts,
|
|
1472
|
+
# For text parts, stream markdown with termflow
|
|
1463
1473
|
if event.index in text_parts:
|
|
1464
1474
|
# Print banner on first content
|
|
1465
1475
|
if event.index not in banner_printed:
|
|
1466
1476
|
_print_response_banner()
|
|
1467
1477
|
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",
|
|
1478
|
+
|
|
1479
|
+
# Add content to line buffer
|
|
1480
|
+
termflow_line_buffers[event.index] += (
|
|
1481
|
+
delta.content_delta
|
|
1477
1482
|
)
|
|
1483
|
+
|
|
1484
|
+
# Process complete lines
|
|
1485
|
+
parser = termflow_parsers[event.index]
|
|
1486
|
+
renderer = termflow_renderers[event.index]
|
|
1487
|
+
buffer = termflow_line_buffers[event.index]
|
|
1488
|
+
|
|
1489
|
+
while "\n" in buffer:
|
|
1490
|
+
line, buffer = buffer.split("\n", 1)
|
|
1491
|
+
events_to_render = parser.parse_line(line)
|
|
1492
|
+
renderer.render_all(events_to_render)
|
|
1493
|
+
|
|
1494
|
+
termflow_line_buffers[event.index] = buffer
|
|
1478
1495
|
else:
|
|
1479
1496
|
# For thinking parts, stream immediately (dim)
|
|
1480
1497
|
if event.index not in banner_printed:
|
|
@@ -1503,19 +1520,27 @@ class BaseAgent(ABC):
|
|
|
1503
1520
|
# PartEndEvent - finish the streaming with a newline
|
|
1504
1521
|
elif isinstance(event, PartEndEvent):
|
|
1505
1522
|
if event.index in streaming_parts:
|
|
1506
|
-
# For text parts,
|
|
1523
|
+
# For text parts, finalize termflow rendering
|
|
1507
1524
|
if event.index in text_parts:
|
|
1508
|
-
#
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1525
|
+
# Render any remaining buffered content
|
|
1526
|
+
if event.index in termflow_parsers:
|
|
1527
|
+
parser = termflow_parsers[event.index]
|
|
1528
|
+
renderer = termflow_renderers[event.index]
|
|
1529
|
+
remaining = termflow_line_buffers.get(event.index, "")
|
|
1530
|
+
|
|
1531
|
+
# Parse and render any remaining partial line
|
|
1532
|
+
if remaining.strip():
|
|
1533
|
+
events_to_render = parser.parse_line(remaining)
|
|
1534
|
+
renderer.render_all(events_to_render)
|
|
1535
|
+
|
|
1536
|
+
# Finalize the parser to close any open blocks
|
|
1537
|
+
final_events = parser.finalize()
|
|
1538
|
+
renderer.render_all(final_events)
|
|
1539
|
+
|
|
1540
|
+
# Clean up termflow state
|
|
1541
|
+
del termflow_parsers[event.index]
|
|
1542
|
+
del termflow_renderers[event.index]
|
|
1543
|
+
del termflow_line_buffers[event.index]
|
|
1519
1544
|
# For tool parts, clear the chunk counter line
|
|
1520
1545
|
elif event.index in tool_parts:
|
|
1521
1546
|
# Clear the chunk counter line by printing spaces and returning
|
|
@@ -10,7 +10,7 @@ serialization, avoiding httpx/Pydantic internals.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
-
from typing import Any, Callable
|
|
13
|
+
from typing import Any, Callable, MutableMapping
|
|
14
14
|
|
|
15
15
|
import httpx
|
|
16
16
|
|
|
@@ -56,7 +56,28 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
56
56
|
except Exception:
|
|
57
57
|
# Swallow wrapper errors; do not break real calls.
|
|
58
58
|
pass
|
|
59
|
-
|
|
59
|
+
response = await super().send(request, *args, **kwargs)
|
|
60
|
+
try:
|
|
61
|
+
if response.status_code == 401 and not request.extensions.get(
|
|
62
|
+
"claude_oauth_refresh_attempted"
|
|
63
|
+
):
|
|
64
|
+
refreshed_token = self._refresh_claude_oauth_token()
|
|
65
|
+
if refreshed_token:
|
|
66
|
+
await response.aclose()
|
|
67
|
+
body_bytes = self._extract_body_bytes(request)
|
|
68
|
+
headers = dict(request.headers)
|
|
69
|
+
self._update_auth_headers(headers, refreshed_token)
|
|
70
|
+
retry_request = self.build_request(
|
|
71
|
+
method=request.method,
|
|
72
|
+
url=request.url,
|
|
73
|
+
headers=headers,
|
|
74
|
+
content=body_bytes,
|
|
75
|
+
)
|
|
76
|
+
retry_request.extensions["claude_oauth_refresh_attempted"] = True
|
|
77
|
+
return await super().send(retry_request, *args, **kwargs)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
return response
|
|
60
81
|
|
|
61
82
|
@staticmethod
|
|
62
83
|
def _extract_body_bytes(request: httpx.Request) -> bytes | None:
|
|
@@ -78,6 +99,29 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
78
99
|
|
|
79
100
|
return None
|
|
80
101
|
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _update_auth_headers(
|
|
104
|
+
headers: MutableMapping[str, str], access_token: str
|
|
105
|
+
) -> None:
|
|
106
|
+
bearer_value = f"Bearer {access_token}"
|
|
107
|
+
if "Authorization" in headers or "authorization" in headers:
|
|
108
|
+
headers["Authorization"] = bearer_value
|
|
109
|
+
elif "x-api-key" in headers or "X-API-Key" in headers:
|
|
110
|
+
headers["x-api-key"] = access_token
|
|
111
|
+
else:
|
|
112
|
+
headers["Authorization"] = bearer_value
|
|
113
|
+
|
|
114
|
+
def _refresh_claude_oauth_token(self) -> str | None:
|
|
115
|
+
try:
|
|
116
|
+
from code_puppy.plugins.claude_code_oauth.utils import refresh_access_token
|
|
117
|
+
|
|
118
|
+
refreshed_token = refresh_access_token(force=True)
|
|
119
|
+
if refreshed_token:
|
|
120
|
+
self._update_auth_headers(self.headers, refreshed_token)
|
|
121
|
+
return refreshed_token
|
|
122
|
+
except Exception:
|
|
123
|
+
return None
|
|
124
|
+
|
|
81
125
|
@staticmethod
|
|
82
126
|
def _inject_cache_control(body: bytes) -> bytes | None:
|
|
83
127
|
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
|
+
# Check for truecolor support and warn if not available
|
|
148
|
+
print_truecolor_warning(display_console)
|
|
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."
|
|
@@ -567,6 +575,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
567
575
|
|
|
568
576
|
# Check for clear command (supports both `clear` and `/clear`)
|
|
569
577
|
if task.strip().lower() in ("clear", "/clear"):
|
|
578
|
+
from code_puppy.command_line.clipboard import get_clipboard_manager
|
|
570
579
|
from code_puppy.messaging import (
|
|
571
580
|
emit_info,
|
|
572
581
|
emit_system_message,
|
|
@@ -579,6 +588,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
579
588
|
emit_warning("Conversation history cleared!")
|
|
580
589
|
emit_system_message("The agent will not remember previous interactions.")
|
|
581
590
|
emit_info(f"Auto-save session rotated to: {new_session_id}")
|
|
591
|
+
|
|
592
|
+
# Also clear pending clipboard images
|
|
593
|
+
clipboard_manager = get_clipboard_manager()
|
|
594
|
+
clipboard_count = clipboard_manager.get_pending_count()
|
|
595
|
+
clipboard_manager.clear_pending()
|
|
596
|
+
if clipboard_count > 0:
|
|
597
|
+
emit_info(f"Cleared {clipboard_count} pending clipboard image(s)")
|
|
582
598
|
continue
|
|
583
599
|
|
|
584
600
|
# Parse attachments first so leading paths aren't misread as commands
|
|
@@ -679,8 +695,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
679
695
|
save_command_to_history(task)
|
|
680
696
|
|
|
681
697
|
try:
|
|
682
|
-
prettier_code_blocks()
|
|
683
|
-
|
|
684
698
|
# No need to get agent directly - use manager's run methods
|
|
685
699
|
|
|
686
700
|
# Use our custom helper to enable attachment handling with spinner support
|
|
@@ -750,28 +764,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
750
764
|
pass
|
|
751
765
|
|
|
752
766
|
|
|
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
767
|
async def run_prompt_with_attachments(
|
|
776
768
|
agent,
|
|
777
769
|
raw_prompt: str,
|
|
@@ -785,6 +777,7 @@ async def run_prompt_with_attachments(
|
|
|
785
777
|
tuple: (result, task) where result is the agent response and task is the asyncio task
|
|
786
778
|
"""
|
|
787
779
|
import asyncio
|
|
780
|
+
import re
|
|
788
781
|
|
|
789
782
|
from code_puppy.messaging import emit_system_message, emit_warning
|
|
790
783
|
|
|
@@ -793,21 +786,41 @@ async def run_prompt_with_attachments(
|
|
|
793
786
|
for warning in processed_prompt.warnings:
|
|
794
787
|
emit_warning(warning)
|
|
795
788
|
|
|
789
|
+
# Get clipboard images and merge with file attachments
|
|
790
|
+
clipboard_manager = get_clipboard_manager()
|
|
791
|
+
clipboard_images = clipboard_manager.get_pending_images()
|
|
792
|
+
|
|
793
|
+
# Clear pending clipboard images after retrieval
|
|
794
|
+
clipboard_manager.clear_pending()
|
|
795
|
+
|
|
796
|
+
# Build summary of all attachments
|
|
796
797
|
summary_parts = []
|
|
797
798
|
if processed_prompt.attachments:
|
|
798
|
-
summary_parts.append(f"
|
|
799
|
+
summary_parts.append(f"files: {len(processed_prompt.attachments)}")
|
|
800
|
+
if clipboard_images:
|
|
801
|
+
summary_parts.append(f"clipboard images: {len(clipboard_images)}")
|
|
799
802
|
if processed_prompt.link_attachments:
|
|
800
803
|
summary_parts.append(f"urls: {len(processed_prompt.link_attachments)}")
|
|
801
804
|
if summary_parts:
|
|
802
805
|
emit_system_message("Attachments detected -> " + ", ".join(summary_parts))
|
|
803
806
|
|
|
804
|
-
|
|
807
|
+
# Clean up clipboard placeholders from the prompt text
|
|
808
|
+
cleaned_prompt = processed_prompt.prompt
|
|
809
|
+
if clipboard_images and cleaned_prompt:
|
|
810
|
+
cleaned_prompt = re.sub(
|
|
811
|
+
r"\[📋 clipboard image \d+\]\s*", "", cleaned_prompt
|
|
812
|
+
).strip()
|
|
813
|
+
|
|
814
|
+
if not cleaned_prompt:
|
|
805
815
|
emit_warning(
|
|
806
816
|
"Prompt is empty after removing attachments; add instructions and retry."
|
|
807
817
|
)
|
|
808
818
|
return None, None
|
|
809
819
|
|
|
820
|
+
# Combine file attachments with clipboard images
|
|
810
821
|
attachments = [attachment.content for attachment in processed_prompt.attachments]
|
|
822
|
+
attachments.extend(clipboard_images) # Add clipboard images
|
|
823
|
+
|
|
811
824
|
link_attachments = [link.url_part for link in processed_prompt.link_attachments]
|
|
812
825
|
|
|
813
826
|
# IMPORTANT: Set the shared console on the agent so that streaming output
|
|
@@ -819,7 +832,7 @@ async def run_prompt_with_attachments(
|
|
|
819
832
|
# Create the agent task first so we can track and cancel it
|
|
820
833
|
agent_task = asyncio.create_task(
|
|
821
834
|
agent.run_with_mcp(
|
|
822
|
-
|
|
835
|
+
cleaned_prompt, # Use cleaned prompt (clipboard placeholders removed)
|
|
823
836
|
attachments=attachments,
|
|
824
837
|
link_attachments=link_attachments,
|
|
825
838
|
)
|