code-puppy 0.0.199__tar.gz → 0.0.201__tar.gz
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-0.0.199 → code_puppy-0.0.201}/PKG-INFO +1 -1
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/base_agent.py +62 -13
- code_puppy-0.0.201/code_puppy/command_line/attachments.py +375 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/prompt_toolkit_completion.py +119 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/main.py +90 -46
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/session_storage.py +74 -35
- {code_puppy-0.0.199 → code_puppy-0.0.201}/pyproject.toml +1 -1
- {code_puppy-0.0.199 → code_puppy-0.0.201}/.gitignore +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/LICENSE +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/README.md +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/__main__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_c_reviewer.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_code_puppy.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_code_reviewer.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_cpp_reviewer.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_creator_agent.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_golang_reviewer.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_javascript_reviewer.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_manager.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_python_reviewer.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_qa_expert.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_qa_kitten.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_security_auditor.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/agent_typescript_reviewer.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/agents/json_agent.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/callbacks.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/command_handler.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/file_path_completion.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/load_context_completion.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/add_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/base.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/handler.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/help_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/install_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/list_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/logs_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/remove_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/restart_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/search_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/start_all_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/start_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/status_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/stop_all_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/stop_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/test_command.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/utils.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/mcp/wizard_utils.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/model_picker_completion.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/motd.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/utils.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/config.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/http_utils.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/async_lifecycle.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/blocking_startup.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/captured_stdio_server.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/circuit_breaker.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/config_wizard.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/dashboard.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/error_isolation.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/examples/retry_example.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/health_monitor.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/managed_server.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/manager.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/registry.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/retry_manager.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/server_registry_catalog.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/status_tracker.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/mcp_/system_tools.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/message_queue.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/queue_console.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/renderers.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/spinner/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/spinner/console_spinner.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/spinner/spinner_base.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/messaging/spinner/textual_spinner.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/model_factory.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/models.json +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/plugins/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/plugins/example_custom_command/register_callbacks.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/reopenable_async_client.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/round_robin_model.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/status_display.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/summarization_agent.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/agent_tools.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/browser_control.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/browser_interactions.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/browser_locators.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/browser_navigation.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/browser_screenshot.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/browser_scripts.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/browser_workflows.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/camoufox_manager.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/browser/vqa_agent.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/command_runner.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/common.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/file_modifications.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/file_operations.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tools/tools_content.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/app.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/chat_view.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/command_history_modal.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/copy_button.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/custom_widgets.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/human_input_modal.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/input_area.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/sidebar.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/components/status_bar.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/messages.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/models/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/models/chat_message.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/models/command_history.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/models/enums.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/screens/__init__.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/screens/autosave_picker.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/screens/help.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/screens/mcp_install_wizard.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/screens/settings.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui/screens/tools.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/tui_state.py +0 -0
- {code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/version_checker.py +0 -0
@@ -6,12 +6,13 @@ import math
|
|
6
6
|
import signal
|
7
7
|
import uuid
|
8
8
|
from abc import ABC, abstractmethod
|
9
|
-
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
9
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Union
|
10
10
|
|
11
11
|
import mcp
|
12
12
|
import pydantic
|
13
13
|
import pydantic_ai.models
|
14
14
|
from pydantic_ai import Agent as PydanticAgent
|
15
|
+
from pydantic_ai import BinaryContent, DocumentUrl, ImageUrl
|
15
16
|
from pydantic_ai import RunContext, UsageLimitExceeded
|
16
17
|
from pydantic_ai.messages import (
|
17
18
|
ModelMessage,
|
@@ -180,6 +181,21 @@ class BaseAgent(ABC):
|
|
180
181
|
return get_global_model_name()
|
181
182
|
return pinned
|
182
183
|
|
184
|
+
def _clean_binaries(self, messages: List[ModelMessage]) -> List[ModelMessage]:
|
185
|
+
cleaned = []
|
186
|
+
for message in messages:
|
187
|
+
parts = []
|
188
|
+
for part in message.parts:
|
189
|
+
if hasattr(part, "content") and isinstance(part.content, list):
|
190
|
+
content = []
|
191
|
+
for item in part.content:
|
192
|
+
if not isinstance(item, BinaryContent):
|
193
|
+
content.append(item)
|
194
|
+
part.content = content
|
195
|
+
parts.append(part)
|
196
|
+
cleaned.append(message)
|
197
|
+
return cleaned
|
198
|
+
|
183
199
|
# Message history processing methods (moved from state_management.py and message_history_processor.py)
|
184
200
|
def _stringify_part(self, part: Any) -> str:
|
185
201
|
"""Create a stable string representation for a message part.
|
@@ -213,6 +229,12 @@ class BaseAgent(ABC):
|
|
213
229
|
)
|
214
230
|
elif isinstance(content, dict):
|
215
231
|
attributes.append(f"content={json.dumps(content, sort_keys=True)}")
|
232
|
+
elif isinstance(content, list):
|
233
|
+
for item in content:
|
234
|
+
if isinstance(item, str):
|
235
|
+
attributes.append(f"content={item}")
|
236
|
+
if isinstance(item, BinaryContent):
|
237
|
+
attributes.append(f"BinaryContent={hash(item.data)}")
|
216
238
|
else:
|
217
239
|
attributes.append(f"content={repr(content)}")
|
218
240
|
result = "|".join(attributes)
|
@@ -259,6 +281,13 @@ class BaseAgent(ABC):
|
|
259
281
|
result = json.dumps(part.content.model_dump())
|
260
282
|
elif isinstance(part.content, dict):
|
261
283
|
result = json.dumps(part.content)
|
284
|
+
elif isinstance(part.content, list):
|
285
|
+
result = ""
|
286
|
+
for item in part.content:
|
287
|
+
if isinstance(item, str):
|
288
|
+
result += item + "\n"
|
289
|
+
if isinstance(item, BinaryContent):
|
290
|
+
result += f"BinaryContent={hash(item.data)}\n"
|
262
291
|
else:
|
263
292
|
result = str(part.content)
|
264
293
|
|
@@ -606,6 +635,7 @@ class BaseAgent(ABC):
|
|
606
635
|
f"Final token count after processing: {final_token_count}",
|
607
636
|
message_group="token_context_status",
|
608
637
|
)
|
638
|
+
|
609
639
|
self.set_message_history(result_messages)
|
610
640
|
for m in summarized_messages:
|
611
641
|
self.add_compacted_message_hash(self.hash_message(m))
|
@@ -874,28 +904,47 @@ class BaseAgent(ABC):
|
|
874
904
|
self.message_history_processor(ctx, _message_history)
|
875
905
|
return self.get_message_history()
|
876
906
|
|
877
|
-
async def run_with_mcp(
|
878
|
-
|
879
|
-
|
880
|
-
|
881
|
-
|
882
|
-
|
907
|
+
async def run_with_mcp(
|
908
|
+
self,
|
909
|
+
prompt: str,
|
910
|
+
*,
|
911
|
+
attachments: Optional[Sequence[BinaryContent]] = None,
|
912
|
+
link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
|
913
|
+
**kwargs,
|
914
|
+
) -> Any:
|
915
|
+
"""Run the agent with MCP servers, attachments, and full cancellation support.
|
883
916
|
|
884
917
|
Args:
|
885
|
-
prompt:
|
886
|
-
|
887
|
-
|
918
|
+
prompt: Primary user prompt text (may be empty when attachments present).
|
919
|
+
attachments: Local binary payloads (e.g., dragged images) to include.
|
920
|
+
link_attachments: Remote assets (image/document URLs) to include.
|
921
|
+
**kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
|
888
922
|
|
889
923
|
Returns:
|
890
|
-
The agent's response
|
924
|
+
The agent's response.
|
891
925
|
|
892
926
|
Raises:
|
893
|
-
asyncio.CancelledError: When execution is cancelled by user
|
927
|
+
asyncio.CancelledError: When execution is cancelled by user.
|
894
928
|
"""
|
895
929
|
group_id = str(uuid.uuid4())
|
896
930
|
# Avoid double-loading: reuse existing agent if already built
|
897
931
|
pydantic_agent = self._code_generation_agent or self.reload_code_generation_agent()
|
898
932
|
|
933
|
+
# Build combined prompt payload when attachments are provided.
|
934
|
+
attachment_parts: List[Any] = []
|
935
|
+
if attachments:
|
936
|
+
attachment_parts.extend(list(attachments))
|
937
|
+
if link_attachments:
|
938
|
+
attachment_parts.extend(list(link_attachments))
|
939
|
+
|
940
|
+
if attachment_parts:
|
941
|
+
prompt_payload: Union[str, List[Any]] = []
|
942
|
+
if prompt:
|
943
|
+
prompt_payload.append(prompt)
|
944
|
+
prompt_payload.extend(attachment_parts)
|
945
|
+
else:
|
946
|
+
prompt_payload = prompt
|
947
|
+
|
899
948
|
async def run_agent_task():
|
900
949
|
try:
|
901
950
|
self.set_message_history(
|
@@ -903,7 +952,7 @@ class BaseAgent(ABC):
|
|
903
952
|
)
|
904
953
|
usage_limits = pydantic_ai.agent._usage.UsageLimits(request_limit=get_message_limit())
|
905
954
|
result_ = await pydantic_agent.run(
|
906
|
-
|
955
|
+
prompt_payload,
|
907
956
|
message_history=self.get_message_history(),
|
908
957
|
usage_limits=usage_limits,
|
909
958
|
**kwargs,
|
@@ -0,0 +1,375 @@
|
|
1
|
+
"""Helpers for parsing file attachments from interactive prompts."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import mimetypes
|
6
|
+
import os
|
7
|
+
import shlex
|
8
|
+
from dataclasses import dataclass
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Iterable, List, Sequence
|
11
|
+
|
12
|
+
from pydantic_ai import BinaryContent, DocumentUrl, ImageUrl
|
13
|
+
|
14
|
+
SUPPORTED_INLINE_SCHEMES = {"http", "https"}
|
15
|
+
|
16
|
+
# Allow common extensions people drag in the terminal.
|
17
|
+
DEFAULT_ACCEPTED_IMAGE_EXTENSIONS = {
|
18
|
+
".png",
|
19
|
+
".jpg",
|
20
|
+
".jpeg",
|
21
|
+
".gif",
|
22
|
+
".bmp",
|
23
|
+
".webp",
|
24
|
+
".tiff",
|
25
|
+
}
|
26
|
+
DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS = set()
|
27
|
+
|
28
|
+
|
29
|
+
@dataclass
|
30
|
+
class PromptAttachment:
|
31
|
+
"""Represents a binary attachment parsed from the input prompt."""
|
32
|
+
|
33
|
+
placeholder: str
|
34
|
+
content: BinaryContent
|
35
|
+
|
36
|
+
|
37
|
+
@dataclass
|
38
|
+
class PromptLinkAttachment:
|
39
|
+
"""Represents a URL attachment supported by pydantic-ai."""
|
40
|
+
|
41
|
+
placeholder: str
|
42
|
+
url_part: ImageUrl | DocumentUrl
|
43
|
+
|
44
|
+
|
45
|
+
@dataclass
|
46
|
+
class ProcessedPrompt:
|
47
|
+
"""Container for parsed input prompt and attachments."""
|
48
|
+
|
49
|
+
prompt: str
|
50
|
+
attachments: List[PromptAttachment]
|
51
|
+
link_attachments: List[PromptLinkAttachment]
|
52
|
+
warnings: List[str]
|
53
|
+
|
54
|
+
|
55
|
+
class AttachmentParsingError(RuntimeError):
|
56
|
+
"""Raised when we fail to load a user-provided attachment."""
|
57
|
+
|
58
|
+
|
59
|
+
def _is_probable_path(token: str) -> bool:
|
60
|
+
"""Heuristically determine whether a token is a local filesystem path."""
|
61
|
+
|
62
|
+
if not token:
|
63
|
+
return False
|
64
|
+
if token.startswith("#"):
|
65
|
+
return False
|
66
|
+
# Windows drive letters or Unix absolute/relative paths
|
67
|
+
if token.startswith(("/", "~", "./", "../")):
|
68
|
+
return True
|
69
|
+
if len(token) >= 2 and token[1] == ":":
|
70
|
+
return True
|
71
|
+
# Things like `path/to/file.png`
|
72
|
+
return os.sep in token or "\"" in token
|
73
|
+
|
74
|
+
|
75
|
+
def _unescape_dragged_path(token: str) -> str:
|
76
|
+
"""Convert backslash-escaped spaces used by drag-and-drop to literal spaces."""
|
77
|
+
# Shell/terminal escaping typically produces '\ ' sequences
|
78
|
+
return token.replace(r"\ ", " ")
|
79
|
+
|
80
|
+
|
81
|
+
def _normalise_path(token: str) -> Path:
|
82
|
+
"""Expand user shortcuts and resolve relative components without touching fs."""
|
83
|
+
# First unescape any drag-and-drop backslash spaces before other expansions
|
84
|
+
unescaped = _unescape_dragged_path(token)
|
85
|
+
expanded = os.path.expanduser(unescaped)
|
86
|
+
try:
|
87
|
+
# This will not resolve against symlinks because we do not call resolve()
|
88
|
+
return Path(expanded).absolute()
|
89
|
+
except Exception as exc:
|
90
|
+
raise AttachmentParsingError(f"Invalid path '{token}': {exc}") from exc
|
91
|
+
|
92
|
+
|
93
|
+
def _determine_media_type(path: Path) -> str:
|
94
|
+
"""Best-effort media type detection for images only."""
|
95
|
+
|
96
|
+
mime, _ = mimetypes.guess_type(path.name)
|
97
|
+
if mime:
|
98
|
+
return mime
|
99
|
+
if path.suffix.lower() in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS:
|
100
|
+
return "image/png"
|
101
|
+
return "application/octet-stream"
|
102
|
+
|
103
|
+
|
104
|
+
def _load_binary(path: Path) -> bytes:
|
105
|
+
try:
|
106
|
+
return path.read_bytes()
|
107
|
+
except FileNotFoundError as exc:
|
108
|
+
raise AttachmentParsingError(f"Attachment not found: {path}") from exc
|
109
|
+
except PermissionError as exc:
|
110
|
+
raise AttachmentParsingError(f"Cannot read attachment (permission denied): {path}") from exc
|
111
|
+
except OSError as exc:
|
112
|
+
raise AttachmentParsingError(f"Failed to read attachment {path}: {exc}") from exc
|
113
|
+
|
114
|
+
|
115
|
+
def _tokenise(prompt: str) -> Iterable[str]:
|
116
|
+
"""Split the prompt preserving quoted segments using shell-like semantics."""
|
117
|
+
|
118
|
+
if not prompt:
|
119
|
+
return []
|
120
|
+
try:
|
121
|
+
# On Windows, avoid POSIX escaping so backslashes are preserved
|
122
|
+
posix_mode = os.name != "nt"
|
123
|
+
return shlex.split(prompt, posix=posix_mode)
|
124
|
+
except ValueError:
|
125
|
+
# Fallback naive split when shlex fails (e.g. unmatched quotes)
|
126
|
+
return prompt.split()
|
127
|
+
|
128
|
+
|
129
|
+
def _strip_attachment_token(token: str) -> str:
|
130
|
+
"""Trim surrounding whitespace/punctuation terminals tack onto paths."""
|
131
|
+
|
132
|
+
return token.strip().strip(",;:()[]{}")
|
133
|
+
|
134
|
+
|
135
|
+
def _candidate_paths(
|
136
|
+
tokens: Sequence[str],
|
137
|
+
start: int,
|
138
|
+
max_span: int = 5,
|
139
|
+
) -> Iterable[tuple[str, int]]:
|
140
|
+
"""Yield space-joined token slices to reconstruct paths with spaces."""
|
141
|
+
|
142
|
+
collected: list[str] = []
|
143
|
+
for offset, raw in enumerate(tokens[start : start + max_span]):
|
144
|
+
collected.append(raw)
|
145
|
+
yield " ".join(collected), start + offset + 1
|
146
|
+
|
147
|
+
|
148
|
+
def _is_supported_extension(path: Path) -> bool:
|
149
|
+
suffix = path.suffix.lower()
|
150
|
+
return suffix in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS | DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS
|
151
|
+
|
152
|
+
|
153
|
+
def _parse_link(token: str) -> PromptLinkAttachment | None:
|
154
|
+
if "://" not in token:
|
155
|
+
return None
|
156
|
+
scheme = token.split(":", 1)[0].lower()
|
157
|
+
if scheme not in SUPPORTED_INLINE_SCHEMES:
|
158
|
+
return None
|
159
|
+
if token.lower().endswith(".pdf"):
|
160
|
+
return PromptLinkAttachment(
|
161
|
+
placeholder=token,
|
162
|
+
url_part=DocumentUrl(url=token),
|
163
|
+
)
|
164
|
+
return PromptLinkAttachment(
|
165
|
+
placeholder=token,
|
166
|
+
url_part=ImageUrl(url=token),
|
167
|
+
)
|
168
|
+
|
169
|
+
|
170
|
+
@dataclass
|
171
|
+
class _DetectedPath:
|
172
|
+
placeholder: str
|
173
|
+
path: Path | None
|
174
|
+
start_index: int
|
175
|
+
consumed_until: int
|
176
|
+
unsupported: bool = False
|
177
|
+
link: PromptLinkAttachment | None = None
|
178
|
+
|
179
|
+
def has_path(self) -> bool:
|
180
|
+
return self.path is not None and not self.unsupported
|
181
|
+
|
182
|
+
|
183
|
+
def _detect_path_tokens(prompt: str) -> tuple[list[_DetectedPath], list[str]]:
|
184
|
+
# Preserve backslash-spaces from drag-and-drop before shlex tokenization
|
185
|
+
# Replace '\ ' with a marker that shlex won't split, then restore later
|
186
|
+
ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
|
187
|
+
masked_prompt = prompt.replace(r"\ ", ESCAPE_MARKER)
|
188
|
+
tokens = list(_tokenise(masked_prompt))
|
189
|
+
# Restore escaped spaces in individual tokens
|
190
|
+
tokens = [t.replace(ESCAPE_MARKER, " ") for t in tokens]
|
191
|
+
|
192
|
+
detections: list[_DetectedPath] = []
|
193
|
+
warnings: list[str] = []
|
194
|
+
|
195
|
+
index = 0
|
196
|
+
while index < len(tokens):
|
197
|
+
token = tokens[index]
|
198
|
+
|
199
|
+
link_attachment = _parse_link(token)
|
200
|
+
if link_attachment:
|
201
|
+
detections.append(
|
202
|
+
_DetectedPath(
|
203
|
+
placeholder=token,
|
204
|
+
path=None,
|
205
|
+
start_index=index,
|
206
|
+
consumed_until=index + 1,
|
207
|
+
link=link_attachment,
|
208
|
+
)
|
209
|
+
)
|
210
|
+
index += 1
|
211
|
+
continue
|
212
|
+
|
213
|
+
stripped_token = _strip_attachment_token(token)
|
214
|
+
if not _is_probable_path(stripped_token):
|
215
|
+
index += 1
|
216
|
+
continue
|
217
|
+
|
218
|
+
start_index = index
|
219
|
+
consumed_until = index + 1
|
220
|
+
candidate_path_token = stripped_token
|
221
|
+
# For placeholder: try to reconstruct escaped representation; if none, use raw token
|
222
|
+
original_tokens_for_slice = list(_tokenise(masked_prompt))[index:consumed_until]
|
223
|
+
candidate_placeholder = "".join(
|
224
|
+
ot.replace(ESCAPE_MARKER, r"\ ") if ESCAPE_MARKER in ot else ot
|
225
|
+
for ot in original_tokens_for_slice
|
226
|
+
)
|
227
|
+
# If placeholder seems identical to raw token, just use the raw token
|
228
|
+
if candidate_placeholder == token.replace(" ", r"\ "):
|
229
|
+
candidate_placeholder = token
|
230
|
+
|
231
|
+
try:
|
232
|
+
path = _normalise_path(candidate_path_token)
|
233
|
+
except AttachmentParsingError as exc:
|
234
|
+
warnings.append(str(exc))
|
235
|
+
index = consumed_until
|
236
|
+
continue
|
237
|
+
|
238
|
+
if not path.exists() or not path.is_file():
|
239
|
+
found_span = False
|
240
|
+
last_path = path
|
241
|
+
for joined, end_index in _candidate_paths(tokens, index):
|
242
|
+
stripped_joined = _strip_attachment_token(joined)
|
243
|
+
if not _is_probable_path(stripped_joined):
|
244
|
+
continue
|
245
|
+
candidate_path_token = stripped_joined
|
246
|
+
candidate_placeholder = joined
|
247
|
+
consumed_until = end_index
|
248
|
+
try:
|
249
|
+
last_path = _normalise_path(candidate_path_token)
|
250
|
+
except AttachmentParsingError as exc:
|
251
|
+
warnings.append(str(exc))
|
252
|
+
found_span = False
|
253
|
+
break
|
254
|
+
if last_path.exists() and last_path.is_file():
|
255
|
+
path = last_path
|
256
|
+
found_span = True
|
257
|
+
# We'll rebuild escaped placeholder after this block
|
258
|
+
break
|
259
|
+
if not found_span:
|
260
|
+
warnings.append(f"Attachment ignored (not a file): {path}")
|
261
|
+
index += 1
|
262
|
+
continue
|
263
|
+
# Reconstruct escaped placeholder for multi-token paths
|
264
|
+
original_tokens_for_path = tokens[index:consumed_until]
|
265
|
+
escaped_placeholder = " ".join(original_tokens_for_path).replace(" ", r"\ ")
|
266
|
+
candidate_placeholder = escaped_placeholder
|
267
|
+
if not _is_supported_extension(path):
|
268
|
+
detections.append(
|
269
|
+
_DetectedPath(
|
270
|
+
placeholder=candidate_placeholder,
|
271
|
+
path=path,
|
272
|
+
start_index=start_index,
|
273
|
+
consumed_until=consumed_until,
|
274
|
+
unsupported=True,
|
275
|
+
)
|
276
|
+
)
|
277
|
+
index = consumed_until
|
278
|
+
continue
|
279
|
+
|
280
|
+
# Reconstruct escaped placeholder for exact replacement later
|
281
|
+
# For unquoted spaces, keep the original literal token from the prompt
|
282
|
+
# so replacement matches precisely
|
283
|
+
escaped_placeholder = candidate_placeholder
|
284
|
+
|
285
|
+
detections.append(
|
286
|
+
_DetectedPath(
|
287
|
+
placeholder=candidate_placeholder,
|
288
|
+
path=path,
|
289
|
+
start_index=start_index,
|
290
|
+
consumed_until=consumed_until,
|
291
|
+
)
|
292
|
+
)
|
293
|
+
index = consumed_until
|
294
|
+
|
295
|
+
return detections, warnings
|
296
|
+
|
297
|
+
|
298
|
+
def parse_prompt_attachments(prompt: str) -> ProcessedPrompt:
|
299
|
+
"""Extract attachments from the prompt returning cleaned text and metadata."""
|
300
|
+
|
301
|
+
attachments: List[PromptAttachment] = []
|
302
|
+
|
303
|
+
detections, detection_warnings = _detect_path_tokens(prompt)
|
304
|
+
warnings: List[str] = list(detection_warnings)
|
305
|
+
|
306
|
+
link_attachments = [d.link for d in detections if d.link is not None]
|
307
|
+
|
308
|
+
for detection in detections:
|
309
|
+
if detection.link is not None and detection.path is None:
|
310
|
+
continue
|
311
|
+
if detection.path is None:
|
312
|
+
continue
|
313
|
+
if detection.unsupported:
|
314
|
+
warnings.append(
|
315
|
+
f"Unsupported attachment type: {detection.path.suffix or detection.path.name}"
|
316
|
+
)
|
317
|
+
continue
|
318
|
+
|
319
|
+
try:
|
320
|
+
media_type = _determine_media_type(detection.path)
|
321
|
+
data = _load_binary(detection.path)
|
322
|
+
except AttachmentParsingError as exc:
|
323
|
+
warnings.append(str(exc))
|
324
|
+
continue
|
325
|
+
attachments.append(
|
326
|
+
PromptAttachment(
|
327
|
+
placeholder=detection.placeholder,
|
328
|
+
content=BinaryContent(data=data, media_type=media_type),
|
329
|
+
)
|
330
|
+
)
|
331
|
+
|
332
|
+
# Rebuild cleaned_prompt by skipping tokens consumed as file paths.
|
333
|
+
# This preserves original punctuation and spacing for non-attachment tokens.
|
334
|
+
ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
|
335
|
+
masked = prompt.replace(r"\ ", ESCAPE_MARKER)
|
336
|
+
tokens = list(_tokenise(masked))
|
337
|
+
|
338
|
+
# Build exact token spans for file attachments (supported or unsupported)
|
339
|
+
# Skip spans for: supported files (path present and not unsupported) and links.
|
340
|
+
spans = [
|
341
|
+
(d.start_index, d.consumed_until)
|
342
|
+
for d in detections
|
343
|
+
if (d.path is not None and not d.unsupported) or (d.link is not None and d.path is None)
|
344
|
+
]
|
345
|
+
cleaned_parts: list[str] = []
|
346
|
+
i = 0
|
347
|
+
while i < len(tokens):
|
348
|
+
span = next((s for s in spans if s[0] <= i < s[1]), None)
|
349
|
+
if span is not None:
|
350
|
+
i = span[1]
|
351
|
+
continue
|
352
|
+
cleaned_parts.append(tokens[i].replace(ESCAPE_MARKER, " "))
|
353
|
+
i += 1
|
354
|
+
|
355
|
+
cleaned_prompt = " ".join(cleaned_parts).strip()
|
356
|
+
cleaned_prompt = " ".join(cleaned_prompt.split())
|
357
|
+
|
358
|
+
if cleaned_prompt == "" and attachments:
|
359
|
+
cleaned_prompt = "Describe the attached files in detail."
|
360
|
+
|
361
|
+
return ProcessedPrompt(
|
362
|
+
prompt=cleaned_prompt,
|
363
|
+
attachments=attachments,
|
364
|
+
link_attachments=link_attachments,
|
365
|
+
warnings=warnings,
|
366
|
+
)
|
367
|
+
|
368
|
+
|
369
|
+
__all__ = [
|
370
|
+
"ProcessedPrompt",
|
371
|
+
"PromptAttachment",
|
372
|
+
"PromptLinkAttachment",
|
373
|
+
"AttachmentParsingError",
|
374
|
+
"parse_prompt_attachments",
|
375
|
+
]
|
{code_puppy-0.0.199 → code_puppy-0.0.201}/code_puppy/command_line/prompt_toolkit_completion.py
RENAMED
@@ -17,6 +17,7 @@ from prompt_toolkit.history import FileHistory
|
|
17
17
|
from prompt_toolkit.filters import is_searching
|
18
18
|
from prompt_toolkit.key_binding import KeyBindings
|
19
19
|
from prompt_toolkit.keys import Keys
|
20
|
+
from prompt_toolkit.layout.processors import Processor, Transformation
|
20
21
|
from prompt_toolkit.styles import Style
|
21
22
|
|
22
23
|
from code_puppy.command_line.file_path_completion import FilePathCompleter
|
@@ -33,6 +34,11 @@ from code_puppy.config import (
|
|
33
34
|
get_puppy_name,
|
34
35
|
get_value,
|
35
36
|
)
|
37
|
+
from code_puppy.command_line.attachments import (
|
38
|
+
DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS,
|
39
|
+
DEFAULT_ACCEPTED_IMAGE_EXTENSIONS,
|
40
|
+
_detect_path_tokens, _tokenise,
|
41
|
+
)
|
36
42
|
|
37
43
|
|
38
44
|
class SetCompleter(Completer):
|
@@ -98,6 +104,117 @@ class SetCompleter(Completer):
|
|
98
104
|
)
|
99
105
|
|
100
106
|
|
107
|
+
class AttachmentPlaceholderProcessor(Processor):
|
108
|
+
"""Display friendly placeholders for recognised attachments."""
|
109
|
+
|
110
|
+
_PLACEHOLDER_STYLE = "class:attachment-placeholder"
|
111
|
+
|
112
|
+
def apply_transformation(self, transformation_input):
|
113
|
+
document = transformation_input.document
|
114
|
+
text = document.text
|
115
|
+
if not text:
|
116
|
+
return Transformation(list(transformation_input.fragments))
|
117
|
+
|
118
|
+
detections, _warnings = _detect_path_tokens(text)
|
119
|
+
replacements: list[tuple[int, int, str]] = []
|
120
|
+
search_cursor = 0
|
121
|
+
ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
|
122
|
+
masked_text = text.replace(r"\ ", ESCAPE_MARKER)
|
123
|
+
token_view = list(_tokenise(masked_text))
|
124
|
+
for detection in detections:
|
125
|
+
display_text: str | None = None
|
126
|
+
if detection.path and detection.has_path():
|
127
|
+
suffix = detection.path.suffix.lower()
|
128
|
+
if suffix in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS:
|
129
|
+
display_text = f"[{suffix.lstrip('.') or 'image'} image]"
|
130
|
+
elif suffix in DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS:
|
131
|
+
display_text = f"[{suffix.lstrip('.') or 'file'} document]"
|
132
|
+
else:
|
133
|
+
display_text = "[file attachment]"
|
134
|
+
elif detection.link is not None:
|
135
|
+
display_text = "[link]"
|
136
|
+
|
137
|
+
if not display_text:
|
138
|
+
continue
|
139
|
+
|
140
|
+
# Use token-span for robust lookup (handles escaped spaces)
|
141
|
+
span_tokens = token_view[detection.start_index:detection.consumed_until]
|
142
|
+
raw_span = " ".join(span_tokens).replace(ESCAPE_MARKER, r"\ ")
|
143
|
+
index = text.find(raw_span, search_cursor)
|
144
|
+
span_len = len(raw_span)
|
145
|
+
if index == -1:
|
146
|
+
# Fallback to placeholder string
|
147
|
+
placeholder = detection.placeholder
|
148
|
+
index = text.find(placeholder, search_cursor)
|
149
|
+
span_len = len(placeholder)
|
150
|
+
if index == -1:
|
151
|
+
continue
|
152
|
+
replacements.append((index, index + span_len, display_text))
|
153
|
+
search_cursor = index + span_len
|
154
|
+
|
155
|
+
if not replacements:
|
156
|
+
return Transformation(list(transformation_input.fragments))
|
157
|
+
|
158
|
+
replacements.sort(key=lambda item: item[0])
|
159
|
+
|
160
|
+
new_fragments: list[tuple[str, str]] = []
|
161
|
+
source_to_display_map: list[int] = []
|
162
|
+
display_to_source_map: list[int] = []
|
163
|
+
|
164
|
+
source_index = 0
|
165
|
+
display_index = 0
|
166
|
+
|
167
|
+
def append_plain_segment(segment: str) -> None:
|
168
|
+
nonlocal source_index, display_index
|
169
|
+
if not segment:
|
170
|
+
return
|
171
|
+
new_fragments.append(("", segment))
|
172
|
+
for _ in segment:
|
173
|
+
source_to_display_map.append(display_index)
|
174
|
+
display_to_source_map.append(source_index)
|
175
|
+
source_index += 1
|
176
|
+
display_index += 1
|
177
|
+
|
178
|
+
for start, end, replacement_text in replacements:
|
179
|
+
if start > source_index:
|
180
|
+
append_plain_segment(text[source_index:start])
|
181
|
+
|
182
|
+
placeholder = replacement_text or ""
|
183
|
+
placeholder_start = display_index
|
184
|
+
if placeholder:
|
185
|
+
new_fragments.append((self._PLACEHOLDER_STYLE, placeholder))
|
186
|
+
for _ in placeholder:
|
187
|
+
display_to_source_map.append(start)
|
188
|
+
display_index += 1
|
189
|
+
|
190
|
+
for _ in text[source_index:end]:
|
191
|
+
source_to_display_map.append(placeholder_start if placeholder else display_index)
|
192
|
+
source_index += 1
|
193
|
+
|
194
|
+
if source_index < len(text):
|
195
|
+
append_plain_segment(text[source_index:])
|
196
|
+
|
197
|
+
def source_to_display(pos: int) -> int:
|
198
|
+
if pos < 0:
|
199
|
+
return 0
|
200
|
+
if pos < len(source_to_display_map):
|
201
|
+
return source_to_display_map[pos]
|
202
|
+
return display_index
|
203
|
+
|
204
|
+
def display_to_source(pos: int) -> int:
|
205
|
+
if pos < 0:
|
206
|
+
return 0
|
207
|
+
if pos < len(display_to_source_map):
|
208
|
+
return display_to_source_map[pos]
|
209
|
+
return len(source_to_display_map)
|
210
|
+
|
211
|
+
return Transformation(
|
212
|
+
new_fragments,
|
213
|
+
source_to_display=source_to_display,
|
214
|
+
display_to_source=display_to_source,
|
215
|
+
)
|
216
|
+
|
217
|
+
|
101
218
|
class CDCompleter(Completer):
|
102
219
|
def __init__(self, trigger: str = "/cd"):
|
103
220
|
self.trigger = trigger
|
@@ -247,6 +364,7 @@ async def get_input_with_combined_completion(
|
|
247
364
|
history=history,
|
248
365
|
complete_while_typing=True,
|
249
366
|
key_bindings=bindings,
|
367
|
+
input_processors=[AttachmentPlaceholderProcessor()],
|
250
368
|
)
|
251
369
|
# If they pass a string, backward-compat: convert it to formatted_text
|
252
370
|
if isinstance(prompt_str, str):
|
@@ -263,6 +381,7 @@ async def get_input_with_combined_completion(
|
|
263
381
|
"model": "bold cyan",
|
264
382
|
"cwd": "bold green",
|
265
383
|
"arrow": "bold yellow",
|
384
|
+
"attachment-placeholder": "italic cyan",
|
266
385
|
}
|
267
386
|
)
|
268
387
|
text = await session.prompt_async(prompt_str, style=style)
|