code-puppy 0.0.199__tar.gz → 0.0.200__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.
Files changed (130) hide show
  1. {code_puppy-0.0.199 → code_puppy-0.0.200}/PKG-INFO +1 -1
  2. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/base_agent.py +62 -13
  3. code_puppy-0.0.200/code_puppy/command_line/attachments.py +375 -0
  4. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/prompt_toolkit_completion.py +119 -0
  5. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/main.py +90 -46
  6. {code_puppy-0.0.199 → code_puppy-0.0.200}/pyproject.toml +1 -1
  7. {code_puppy-0.0.199 → code_puppy-0.0.200}/.gitignore +0 -0
  8. {code_puppy-0.0.199 → code_puppy-0.0.200}/LICENSE +0 -0
  9. {code_puppy-0.0.199 → code_puppy-0.0.200}/README.md +0 -0
  10. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/__init__.py +0 -0
  11. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/__main__.py +0 -0
  12. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/__init__.py +0 -0
  13. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_c_reviewer.py +0 -0
  14. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_code_puppy.py +0 -0
  15. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_code_reviewer.py +0 -0
  16. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_cpp_reviewer.py +0 -0
  17. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_creator_agent.py +0 -0
  18. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_golang_reviewer.py +0 -0
  19. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_javascript_reviewer.py +0 -0
  20. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_manager.py +0 -0
  21. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_python_reviewer.py +0 -0
  22. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_qa_expert.py +0 -0
  23. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_qa_kitten.py +0 -0
  24. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_security_auditor.py +0 -0
  25. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/agent_typescript_reviewer.py +0 -0
  26. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/agents/json_agent.py +0 -0
  27. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/callbacks.py +0 -0
  28. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/__init__.py +0 -0
  29. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/command_handler.py +0 -0
  30. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/file_path_completion.py +0 -0
  31. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/load_context_completion.py +0 -0
  32. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/__init__.py +0 -0
  33. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/add_command.py +0 -0
  34. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/base.py +0 -0
  35. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/handler.py +0 -0
  36. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/help_command.py +0 -0
  37. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/install_command.py +0 -0
  38. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/list_command.py +0 -0
  39. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/logs_command.py +0 -0
  40. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/remove_command.py +0 -0
  41. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/restart_command.py +0 -0
  42. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/search_command.py +0 -0
  43. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/start_all_command.py +0 -0
  44. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/start_command.py +0 -0
  45. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/status_command.py +0 -0
  46. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/stop_all_command.py +0 -0
  47. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/stop_command.py +0 -0
  48. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/test_command.py +0 -0
  49. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/utils.py +0 -0
  50. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/mcp/wizard_utils.py +0 -0
  51. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/model_picker_completion.py +0 -0
  52. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/motd.py +0 -0
  53. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/command_line/utils.py +0 -0
  54. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/config.py +0 -0
  55. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/http_utils.py +0 -0
  56. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/__init__.py +0 -0
  57. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/async_lifecycle.py +0 -0
  58. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/blocking_startup.py +0 -0
  59. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/captured_stdio_server.py +0 -0
  60. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/circuit_breaker.py +0 -0
  61. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/config_wizard.py +0 -0
  62. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/dashboard.py +0 -0
  63. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/error_isolation.py +0 -0
  64. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/examples/retry_example.py +0 -0
  65. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/health_monitor.py +0 -0
  66. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/managed_server.py +0 -0
  67. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/manager.py +0 -0
  68. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/registry.py +0 -0
  69. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/retry_manager.py +0 -0
  70. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/server_registry_catalog.py +0 -0
  71. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/status_tracker.py +0 -0
  72. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/mcp_/system_tools.py +0 -0
  73. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/__init__.py +0 -0
  74. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/message_queue.py +0 -0
  75. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/queue_console.py +0 -0
  76. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/renderers.py +0 -0
  77. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/spinner/__init__.py +0 -0
  78. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/spinner/console_spinner.py +0 -0
  79. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/spinner/spinner_base.py +0 -0
  80. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/messaging/spinner/textual_spinner.py +0 -0
  81. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/model_factory.py +0 -0
  82. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/models.json +0 -0
  83. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/plugins/__init__.py +0 -0
  84. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/plugins/example_custom_command/register_callbacks.py +0 -0
  85. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/reopenable_async_client.py +0 -0
  86. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/round_robin_model.py +0 -0
  87. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/session_storage.py +0 -0
  88. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/status_display.py +0 -0
  89. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/summarization_agent.py +0 -0
  90. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/__init__.py +0 -0
  91. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/agent_tools.py +0 -0
  92. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/__init__.py +0 -0
  93. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_control.py +0 -0
  94. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_interactions.py +0 -0
  95. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_locators.py +0 -0
  96. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_navigation.py +0 -0
  97. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_screenshot.py +0 -0
  98. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_scripts.py +0 -0
  99. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_workflows.py +0 -0
  100. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/camoufox_manager.py +0 -0
  101. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/browser/vqa_agent.py +0 -0
  102. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/command_runner.py +0 -0
  103. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/common.py +0 -0
  104. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/file_modifications.py +0 -0
  105. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/file_operations.py +0 -0
  106. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tools/tools_content.py +0 -0
  107. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/__init__.py +0 -0
  108. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/app.py +0 -0
  109. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/__init__.py +0 -0
  110. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/chat_view.py +0 -0
  111. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/command_history_modal.py +0 -0
  112. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/copy_button.py +0 -0
  113. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/custom_widgets.py +0 -0
  114. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/human_input_modal.py +0 -0
  115. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/input_area.py +0 -0
  116. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/sidebar.py +0 -0
  117. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/components/status_bar.py +0 -0
  118. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/messages.py +0 -0
  119. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/models/__init__.py +0 -0
  120. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/models/chat_message.py +0 -0
  121. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/models/command_history.py +0 -0
  122. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/models/enums.py +0 -0
  123. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/screens/__init__.py +0 -0
  124. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/screens/autosave_picker.py +0 -0
  125. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/screens/help.py +0 -0
  126. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/screens/mcp_install_wizard.py +0 -0
  127. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/screens/settings.py +0 -0
  128. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui/screens/tools.py +0 -0
  129. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/tui_state.py +0 -0
  130. {code_puppy-0.0.199 → code_puppy-0.0.200}/code_puppy/version_checker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.199
3
+ Version: 0.0.200
4
4
  Summary: Code generation agent
5
5
  Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
6
6
  Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
@@ -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(self, prompt: str, **kwargs) -> Any:
878
- """
879
- Run the agent with MCP servers and full cancellation support.
880
-
881
- This method ensures we're always using the current agent instance
882
- and handles Ctrl+C interruption properly by creating a cancellable task.
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: The user prompt to process
886
- usage_limits: Optional usage limits for the agent
887
- **kwargs: Additional arguments to pass to agent.run (e.g., message_history)
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
- prompt,
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
+ ]
@@ -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)