codepp 0.0.437__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.
Files changed (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,846 @@
1
+ # ANSI color codes are no longer necessary because prompt_toolkit handles
2
+ # styling via the `Style` class. We keep them here commented-out in case
3
+ # someone needs raw ANSI later, but they are unused in the current code.
4
+ # RESET = '\033[0m'
5
+ # GREEN = '\033[1;32m'
6
+ # CYAN = '\033[1;36m'
7
+ # YELLOW = '\033[1;33m'
8
+ # BOLD = '\033[1m'
9
+ import asyncio
10
+ import os
11
+ import sys
12
+ from typing import Optional
13
+
14
+ from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.completion import Completer, Completion, merge_completers
16
+ from prompt_toolkit.filters import is_searching
17
+ from prompt_toolkit.formatted_text import FormattedText
18
+ from prompt_toolkit.history import FileHistory
19
+ from prompt_toolkit.key_binding import KeyBindings
20
+ from prompt_toolkit.keys import Keys
21
+ from prompt_toolkit.layout.processors import Processor, Transformation
22
+ from prompt_toolkit.styles import Style
23
+
24
+ from code_puppy.command_line.attachments import (
25
+ DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS,
26
+ DEFAULT_ACCEPTED_IMAGE_EXTENSIONS,
27
+ _detect_path_tokens,
28
+ _tokenise,
29
+ )
30
+ from code_puppy.command_line.clipboard import (
31
+ capture_clipboard_image_to_pending,
32
+ has_image_in_clipboard,
33
+ )
34
+ from code_puppy.command_line.command_registry import get_unique_commands
35
+ from code_puppy.command_line.file_path_completion import FilePathCompleter
36
+ from code_puppy.command_line.load_context_completion import LoadContextCompleter
37
+ from code_puppy.command_line.mcp_completion import MCPCompleter
38
+ from code_puppy.command_line.model_picker_completion import (
39
+ ModelNameCompleter,
40
+ get_active_model,
41
+ )
42
+ from code_puppy.command_line.pin_command_completion import PinCompleter, UnpinCompleter
43
+ from code_puppy.command_line.skills_completion import SkillsCompleter
44
+ from code_puppy.command_line.utils import list_directory
45
+ from code_puppy.config import (
46
+ COMMAND_HISTORY_FILE,
47
+ get_config_keys,
48
+ get_puppy_name,
49
+ get_value,
50
+ )
51
+
52
+
53
+ def _sanitize_for_encoding(text: str) -> str:
54
+ """Remove or replace characters that can't be safely encoded.
55
+
56
+ This handles:
57
+ - Lone surrogate characters (U+D800-U+DFFF) which are invalid in UTF-8
58
+ - Other problematic Unicode sequences from Windows copy-paste
59
+
60
+ Args:
61
+ text: The string to sanitize
62
+
63
+ Returns:
64
+ A cleaned string safe for UTF-8 encoding
65
+ """
66
+ # First, try to encode as UTF-8 to catch any problematic characters
67
+ try:
68
+ text.encode("utf-8")
69
+ return text # String is already valid UTF-8
70
+ except UnicodeEncodeError:
71
+ pass
72
+
73
+ # Replace surrogates and other problematic characters
74
+ # Use 'surrogatepass' to encode surrogates, then decode with 'replace' to clean them
75
+ try:
76
+ # Encode allowing surrogates, then decode replacing invalid sequences
77
+ cleaned = text.encode("utf-8", errors="surrogatepass").decode(
78
+ "utf-8", errors="replace"
79
+ )
80
+ return cleaned
81
+ except (UnicodeEncodeError, UnicodeDecodeError):
82
+ # Last resort: filter out all non-BMP and surrogate characters
83
+ return "".join(
84
+ char
85
+ for char in text
86
+ if ord(char) < 0xD800 or (ord(char) > 0xDFFF and ord(char) < 0x10000)
87
+ )
88
+
89
+
90
+ class SafeFileHistory(FileHistory):
91
+ """A FileHistory that handles encoding errors gracefully on Windows.
92
+
93
+ Windows terminals and copy-paste operations can introduce invalid
94
+ Unicode surrogate characters that cause UTF-8 encoding to fail.
95
+ This class sanitizes history entries before writing them to disk.
96
+ """
97
+
98
+ def store_string(self, string: str) -> None:
99
+ """Store a string in the history, sanitizing it first."""
100
+ sanitized = _sanitize_for_encoding(string)
101
+ try:
102
+ super().store_string(sanitized)
103
+ except (UnicodeEncodeError, UnicodeDecodeError, OSError) as e:
104
+ # If we still can't write, log the error but don't crash
105
+ # This can happen with particularly malformed input
106
+ # Note: Using sys.stderr here intentionally - this is a low-level
107
+ # warning that shouldn't use the messaging system
108
+ sys.stderr.write(f"Warning: Could not save to command history: {e}\n")
109
+
110
+
111
+ class SetCompleter(Completer):
112
+ def __init__(self, trigger: str = "/set"):
113
+ self.trigger = trigger
114
+
115
+ def get_completions(self, document, complete_event):
116
+ cursor_position = document.cursor_position
117
+ text_before_cursor = document.text_before_cursor
118
+ stripped_text_for_trigger_check = text_before_cursor.lstrip()
119
+
120
+ # If user types just /set (no space), suggest adding a space
121
+ if stripped_text_for_trigger_check == self.trigger:
122
+ from prompt_toolkit.formatted_text import FormattedText
123
+
124
+ yield Completion(
125
+ self.trigger + " ",
126
+ start_position=-len(self.trigger),
127
+ display=self.trigger + " ",
128
+ display_meta=FormattedText(
129
+ [("class:set-completer-meta", "set config key")]
130
+ ),
131
+ )
132
+ return
133
+
134
+ # Require a space after /set before showing completions
135
+ if not stripped_text_for_trigger_check.startswith(self.trigger + " "):
136
+ return
137
+
138
+ # Determine the part of the text that is relevant for this completer
139
+ # This handles cases like " /set foo" where the trigger isn't at the start of the string
140
+ actual_trigger_pos = text_before_cursor.find(self.trigger)
141
+
142
+ # Extract the input after /set and space (up to cursor)
143
+ trigger_end = actual_trigger_pos + len(self.trigger) + 1 # +1 for the space
144
+ text_after_trigger = text_before_cursor[trigger_end:cursor_position].lstrip()
145
+ start_position = -(len(text_after_trigger))
146
+
147
+ # --- SPECIAL HANDLING FOR 'model' KEY ---
148
+ if text_after_trigger == "model":
149
+ # Don't return any completions -- let ModelNameCompleter handle it
150
+ return
151
+
152
+ # Get config keys and sort them alphabetically for consistent display
153
+ config_keys = sorted(get_config_keys())
154
+
155
+ for key in config_keys:
156
+ if key == "model" or key == "puppy_token":
157
+ continue # exclude 'model' and 'puppy_token' from regular /set completions
158
+ if key.startswith(text_after_trigger):
159
+ prev_value = get_value(key)
160
+ value_part = f" = {prev_value}" if prev_value is not None else " = "
161
+ completion_text = f"{key}{value_part}"
162
+
163
+ yield Completion(
164
+ completion_text,
165
+ start_position=start_position,
166
+ display_meta="",
167
+ )
168
+
169
+
170
+ class AttachmentPlaceholderProcessor(Processor):
171
+ """Display friendly placeholders for recognised attachments."""
172
+
173
+ _PLACEHOLDER_STYLE = "class:attachment-placeholder"
174
+ # Skip expensive path detection for very long input (likely pasted content)
175
+ _MAX_TEXT_LENGTH_FOR_REALTIME = 500
176
+
177
+ def apply_transformation(self, transformation_input):
178
+ document = transformation_input.document
179
+ text = document.text
180
+ if not text:
181
+ return Transformation(list(transformation_input.fragments))
182
+
183
+ # Skip real-time path detection for long text to avoid slowdown
184
+ if len(text) > self._MAX_TEXT_LENGTH_FOR_REALTIME:
185
+ return Transformation(list(transformation_input.fragments))
186
+
187
+ detections, _warnings = _detect_path_tokens(text)
188
+ replacements: list[tuple[int, int, str]] = []
189
+ search_cursor = 0
190
+ ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
191
+ masked_text = text.replace(r"\ ", ESCAPE_MARKER)
192
+ token_view = list(_tokenise(masked_text))
193
+ for detection in detections:
194
+ display_text: str | None = None
195
+ if detection.path and detection.has_path():
196
+ suffix = detection.path.suffix.lower()
197
+ if suffix in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS:
198
+ display_text = f"[{suffix.lstrip('.') or 'image'} image]"
199
+ elif suffix in DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS:
200
+ display_text = f"[{suffix.lstrip('.') or 'file'} document]"
201
+ else:
202
+ display_text = "[file attachment]"
203
+ elif detection.link is not None:
204
+ display_text = "[link]"
205
+
206
+ if not display_text:
207
+ continue
208
+
209
+ # Use token-span for robust lookup (handles escaped spaces)
210
+ span_tokens = token_view[detection.start_index : detection.consumed_until]
211
+ raw_span = " ".join(span_tokens).replace(ESCAPE_MARKER, r"\ ")
212
+ index = text.find(raw_span, search_cursor)
213
+ span_len = len(raw_span)
214
+ if index == -1:
215
+ # Fallback to placeholder string
216
+ placeholder = detection.placeholder
217
+ index = text.find(placeholder, search_cursor)
218
+ span_len = len(placeholder)
219
+ if index == -1:
220
+ continue
221
+ replacements.append((index, index + span_len, display_text))
222
+ search_cursor = index + span_len
223
+
224
+ if not replacements:
225
+ return Transformation(list(transformation_input.fragments))
226
+
227
+ replacements.sort(key=lambda item: item[0])
228
+
229
+ new_fragments: list[tuple[str, str]] = []
230
+ source_to_display_map: list[int] = []
231
+ display_to_source_map: list[int] = []
232
+
233
+ source_index = 0
234
+ display_index = 0
235
+
236
+ def append_plain_segment(segment: str) -> None:
237
+ nonlocal source_index, display_index
238
+ if not segment:
239
+ return
240
+ new_fragments.append(("", segment))
241
+ for _ in segment:
242
+ source_to_display_map.append(display_index)
243
+ display_to_source_map.append(source_index)
244
+ source_index += 1
245
+ display_index += 1
246
+
247
+ for start, end, replacement_text in replacements:
248
+ if start > source_index:
249
+ append_plain_segment(text[source_index:start])
250
+
251
+ placeholder = replacement_text or ""
252
+ placeholder_start = display_index
253
+ if placeholder:
254
+ new_fragments.append((self._PLACEHOLDER_STYLE, placeholder))
255
+ for _ in placeholder:
256
+ display_to_source_map.append(start)
257
+ display_index += 1
258
+
259
+ for _ in text[source_index:end]:
260
+ source_to_display_map.append(
261
+ placeholder_start if placeholder else display_index
262
+ )
263
+ source_index += 1
264
+
265
+ if source_index < len(text):
266
+ append_plain_segment(text[source_index:])
267
+
268
+ def source_to_display(pos: int) -> int:
269
+ if pos < 0:
270
+ return 0
271
+ if pos < len(source_to_display_map):
272
+ return source_to_display_map[pos]
273
+ return display_index
274
+
275
+ def display_to_source(pos: int) -> int:
276
+ if pos < 0:
277
+ return 0
278
+ if pos < len(display_to_source_map):
279
+ return display_to_source_map[pos]
280
+ return len(source_to_display_map)
281
+
282
+ return Transformation(
283
+ new_fragments,
284
+ source_to_display=source_to_display,
285
+ display_to_source=display_to_source,
286
+ )
287
+
288
+
289
+ class CDCompleter(Completer):
290
+ def __init__(self, trigger: str = "/cd"):
291
+ self.trigger = trigger
292
+
293
+ def get_completions(self, document, complete_event):
294
+ text_before_cursor = document.text_before_cursor
295
+ stripped_text = text_before_cursor.lstrip()
296
+
297
+ # Require a space after /cd before showing completions (consistency with other completers)
298
+ if not stripped_text.startswith(self.trigger + " "):
299
+ return
300
+
301
+ # Extract the directory path after /cd and space (up to cursor)
302
+ trigger_pos = text_before_cursor.find(self.trigger)
303
+ trigger_end = trigger_pos + len(self.trigger) + 1 # +1 for the space
304
+ dir_path = text_before_cursor[trigger_end:].lstrip()
305
+ start_position = -(len(dir_path))
306
+
307
+ try:
308
+ prefix = os.path.expanduser(dir_path)
309
+ part = os.path.dirname(prefix) if os.path.dirname(prefix) else "."
310
+ dirs, _ = list_directory(part)
311
+ dirnames = [d for d in dirs if d.startswith(os.path.basename(prefix))]
312
+ base_dir = os.path.dirname(prefix)
313
+
314
+ # Preserve the user's original prefix (e.g., ~/ or relative paths)
315
+ # Extract what the user originally typed (with ~ or ./ preserved)
316
+ if dir_path.startswith("~"):
317
+ # User typed something with ~, preserve it
318
+ user_prefix = "~" + os.sep
319
+ # For suggestion, we replace the expanded base_dir back with ~/
320
+ original_prefix = dir_path.rstrip(os.sep)
321
+ else:
322
+ user_prefix = None
323
+ original_prefix = None
324
+
325
+ for d in dirnames:
326
+ # Build the completion text so we keep the already-typed directory parts.
327
+ if user_prefix and original_prefix:
328
+ # Restore ~ prefix
329
+ suggestion = user_prefix + d + os.sep
330
+ elif base_dir and base_dir != ".":
331
+ suggestion = os.path.join(base_dir, d)
332
+ else:
333
+ suggestion = d
334
+ # Append trailing slash so the user can continue tabbing into sub-dirs.
335
+ suggestion = suggestion.rstrip(os.sep) + os.sep
336
+ yield Completion(
337
+ suggestion,
338
+ start_position=start_position,
339
+ display=d + os.sep,
340
+ display_meta="Directory",
341
+ )
342
+ except Exception:
343
+ # Silently ignore errors (e.g., permission issues, non-existent dir)
344
+ pass
345
+
346
+
347
+ class AgentCompleter(Completer):
348
+ """
349
+ A completer that triggers on '/agent' to show available agents.
350
+
351
+ Usage: /agent <agent-name>
352
+ """
353
+
354
+ def __init__(self, trigger: str = "/agent"):
355
+ self.trigger = trigger
356
+
357
+ def get_completions(self, document, complete_event):
358
+ cursor_position = document.cursor_position
359
+ text_before_cursor = document.text_before_cursor
360
+ stripped_text = text_before_cursor.lstrip()
361
+
362
+ # Require a space after /agent before showing completions
363
+ if not stripped_text.startswith(self.trigger + " "):
364
+ return
365
+
366
+ # Extract the input after /agent and space (up to cursor)
367
+ trigger_pos = text_before_cursor.find(self.trigger)
368
+ trigger_end = trigger_pos + len(self.trigger) + 1 # +1 for the space
369
+ text_after_trigger = text_before_cursor[trigger_end:cursor_position].lstrip()
370
+ start_position = -(len(text_after_trigger))
371
+
372
+ # Load all available agent names
373
+ try:
374
+ from code_puppy.command_line.pin_command_completion import load_agent_names
375
+
376
+ agent_names = load_agent_names()
377
+ except Exception:
378
+ # If agent loading fails, return no completions
379
+ return
380
+
381
+ # Filter and yield agent completions
382
+ try:
383
+ from code_puppy.command_line.pin_command_completion import (
384
+ _get_agent_display_meta,
385
+ )
386
+ except ImportError:
387
+ _get_agent_display_meta = lambda x: "default" # noqa: E731
388
+
389
+ for agent_name in agent_names:
390
+ if agent_name.lower().startswith(text_after_trigger.lower()):
391
+ yield Completion(
392
+ agent_name,
393
+ start_position=start_position,
394
+ display=agent_name,
395
+ display_meta=_get_agent_display_meta(agent_name),
396
+ )
397
+
398
+
399
+ class SlashCompleter(Completer):
400
+ """
401
+ A completer that triggers on '/' at the beginning of the line
402
+ to show all available slash commands.
403
+ """
404
+
405
+ def get_completions(self, document, complete_event):
406
+ text_before_cursor = document.text_before_cursor
407
+ stripped_text = text_before_cursor.lstrip()
408
+
409
+ # Only trigger if '/' is the first non-whitespace character
410
+ if not stripped_text.startswith("/"):
411
+ return
412
+
413
+ # Get the text after the initial slash
414
+ if len(stripped_text) == 1:
415
+ # User just typed '/', show all commands
416
+ partial = ""
417
+ start_position = 0 # Don't replace anything, just insert at cursor
418
+ else:
419
+ # User is typing a command after the slash
420
+ partial = stripped_text[1:] # text after '/'
421
+ start_position = -(len(partial)) # Replace what was typed after '/'
422
+
423
+ # Load all available commands
424
+ try:
425
+ commands = get_unique_commands()
426
+ except Exception:
427
+ # If command loading fails, return no completions
428
+ return
429
+
430
+ # Collect all primary commands and their aliases for proper alphabetical sorting
431
+ all_completions = []
432
+
433
+ # Convert partial to lowercase for case-insensitive matching
434
+ partial_lower = partial.lower()
435
+
436
+ for cmd in commands:
437
+ # Add primary command (case-insensitive matching)
438
+ if cmd.name.lower().startswith(partial_lower):
439
+ all_completions.append(
440
+ {
441
+ "text": cmd.name,
442
+ "display": f"/{cmd.name}",
443
+ "meta": cmd.description,
444
+ "sort_key": cmd.name.lower(), # Case-insensitive sort
445
+ }
446
+ )
447
+
448
+ # Add all aliases (case-insensitive matching)
449
+ for alias in cmd.aliases:
450
+ if alias.lower().startswith(partial_lower):
451
+ all_completions.append(
452
+ {
453
+ "text": alias,
454
+ "display": f"/{alias} (alias for /{cmd.name})",
455
+ "meta": cmd.description,
456
+ "sort_key": alias.lower(), # Sort by alias name, not primary command
457
+ }
458
+ )
459
+
460
+ # Also include custom commands from plugins (like claude-code-auth)
461
+ try:
462
+ from code_puppy import callbacks, plugins
463
+
464
+ # Ensure plugins are loaded so custom commands are registered
465
+ plugins.load_plugin_callbacks()
466
+ custom_help_results = callbacks.on_custom_command_help()
467
+ for res in custom_help_results:
468
+ if not res:
469
+ continue
470
+ # Format 1: List of tuples (command_name, description)
471
+ if isinstance(res, list):
472
+ for item in res:
473
+ if isinstance(item, tuple) and len(item) == 2:
474
+ cmd_name = str(item[0])
475
+ description = str(item[1])
476
+ if cmd_name.lower().startswith(partial_lower):
477
+ all_completions.append(
478
+ {
479
+ "text": cmd_name,
480
+ "display": f"/{cmd_name}",
481
+ "meta": description,
482
+ "sort_key": cmd_name.lower(),
483
+ }
484
+ )
485
+ # Format 2: Single tuple (command_name, description)
486
+ elif isinstance(res, tuple) and len(res) == 2:
487
+ cmd_name = str(res[0])
488
+ description = str(res[1])
489
+ if cmd_name.lower().startswith(partial_lower):
490
+ all_completions.append(
491
+ {
492
+ "text": cmd_name,
493
+ "display": f"/{cmd_name}",
494
+ "meta": description,
495
+ "sort_key": cmd_name.lower(),
496
+ }
497
+ )
498
+ except Exception:
499
+ # If custom command loading fails, continue with registered commands only
500
+ pass
501
+
502
+ # Sort all completions alphabetically
503
+ all_completions.sort(key=lambda x: x["sort_key"])
504
+
505
+ # Yield the sorted completions
506
+ for completion in all_completions:
507
+ yield Completion(
508
+ completion["text"],
509
+ start_position=start_position,
510
+ display=completion["display"],
511
+ display_meta=completion["meta"],
512
+ )
513
+
514
+
515
+ def get_prompt_with_active_model(base: str = ">>> "):
516
+ from code_puppy.agents.agent_manager import get_current_agent
517
+
518
+ puppy = get_puppy_name()
519
+ global_model = get_active_model() or "(default)"
520
+
521
+ # Get current agent information
522
+ current_agent = get_current_agent()
523
+ agent_display = current_agent.display_name if current_agent else "code-puppy"
524
+
525
+ # Check if current agent has a pinned model
526
+ agent_model = None
527
+ if current_agent and hasattr(current_agent, "get_model_name"):
528
+ agent_model = current_agent.get_model_name()
529
+
530
+ # Determine which model to display
531
+ if agent_model and agent_model != global_model:
532
+ # Show both models when they differ
533
+ model_display = f"[{global_model} → {agent_model}]"
534
+ elif agent_model:
535
+ # Show only the agent model when pinned
536
+ model_display = f"[{agent_model}]"
537
+ else:
538
+ # Show only the global model when no agent model is pinned
539
+ model_display = f"[{global_model}]"
540
+
541
+ cwd = os.getcwd()
542
+ home = os.path.expanduser("~")
543
+ if cwd.startswith(home):
544
+ cwd_display = "~" + cwd[len(home) :]
545
+ else:
546
+ cwd_display = cwd
547
+ return FormattedText(
548
+ [
549
+ ("bold", "🐶 "),
550
+ ("class:puppy", f"{puppy}"),
551
+ ("", " "),
552
+ ("class:agent", f"[{agent_display}] "),
553
+ ("class:model", model_display + " "),
554
+ ("class:cwd", "(" + str(cwd_display) + ") "),
555
+ ("class:arrow", str(base)),
556
+ ]
557
+ )
558
+
559
+
560
+ async def get_input_with_combined_completion(
561
+ prompt_str=">>> ", history_file: Optional[str] = None
562
+ ) -> str:
563
+ # Use SafeFileHistory to handle encoding errors gracefully on Windows
564
+ history = SafeFileHistory(history_file) if history_file else None
565
+ completer = merge_completers(
566
+ [
567
+ FilePathCompleter(symbol="@"),
568
+ ModelNameCompleter(trigger="/model"),
569
+ ModelNameCompleter(trigger="/m"),
570
+ CDCompleter(trigger="/cd"),
571
+ SetCompleter(trigger="/set"),
572
+ LoadContextCompleter(trigger="/load_context"),
573
+ PinCompleter(trigger="/pin_model"),
574
+ UnpinCompleter(trigger="/unpin"),
575
+ AgentCompleter(trigger="/agent"),
576
+ AgentCompleter(trigger="/a"),
577
+ MCPCompleter(trigger="/mcp"),
578
+ SkillsCompleter(trigger="/skills"),
579
+ SlashCompleter(),
580
+ ]
581
+ )
582
+ # Add custom key bindings and multiline toggle
583
+ bindings = KeyBindings()
584
+
585
+ # Multiline mode state
586
+ multiline = {"enabled": False}
587
+
588
+ # Ctrl+X keybinding - exit with KeyboardInterrupt for shell command cancellation
589
+ @bindings.add(Keys.ControlX)
590
+ def _(event):
591
+ try:
592
+ event.app.exit(exception=KeyboardInterrupt)
593
+ except Exception:
594
+ # Ignore "Return value already set" errors when exit was already called
595
+ # This happens when user presses multiple exit keys in quick succession
596
+ pass
597
+
598
+ # Escape keybinding - exit with KeyboardInterrupt
599
+ @bindings.add(Keys.Escape)
600
+ def _(event):
601
+ try:
602
+ event.app.exit(exception=KeyboardInterrupt)
603
+ except Exception:
604
+ # Ignore "Return value already set" errors when exit was already called
605
+ pass
606
+
607
+ # NOTE: We intentionally do NOT override Ctrl+C here.
608
+ # prompt_toolkit's default Ctrl+C handler properly resets the terminal state on Windows.
609
+ # Overriding it with event.app.exit(exception=KeyboardInterrupt) can leave the terminal
610
+ # in a bad state where characters cannot be typed. Let prompt_toolkit handle Ctrl+C natively.
611
+
612
+ # Toggle multiline with Alt+M
613
+ @bindings.add(Keys.Escape, "m")
614
+ def _(event):
615
+ multiline["enabled"] = not multiline["enabled"]
616
+ status = "ON" if multiline["enabled"] else "OFF"
617
+ # Print status for user feedback (version-agnostic)
618
+ # Note: Using sys.stdout here for immediate feedback during input
619
+ sys.stdout.write(f"[multiline] {status}\n")
620
+ sys.stdout.flush()
621
+
622
+ # Also toggle multiline with F2 (more reliable across platforms)
623
+ @bindings.add("f2")
624
+ def _(event):
625
+ multiline["enabled"] = not multiline["enabled"]
626
+ status = "ON" if multiline["enabled"] else "OFF"
627
+ sys.stdout.write(f"[multiline] {status}\n")
628
+ sys.stdout.flush()
629
+
630
+ # Newline insert bindings — robust and explicit
631
+ # Ctrl+J (line feed) works in virtually all terminals; mark eager so it wins
632
+ @bindings.add("c-j", eager=True)
633
+ def _(event):
634
+ event.app.current_buffer.insert_text("\n")
635
+
636
+ # Also allow Ctrl+Enter for newline (terminal-dependent)
637
+ try:
638
+
639
+ @bindings.add("c-enter", eager=True)
640
+ def _(event):
641
+ event.app.current_buffer.insert_text("\n")
642
+ except Exception:
643
+ pass
644
+
645
+ # Enter behavior depends on multiline mode
646
+ @bindings.add("enter", filter=~is_searching, eager=True)
647
+ def _(event):
648
+ if multiline["enabled"]:
649
+ event.app.current_buffer.insert_text("\n")
650
+ else:
651
+ event.current_buffer.validate_and_handle()
652
+
653
+ # Backspace/Delete: trigger completions after deletion
654
+ # By default, complete_while_typing only triggers on character insertion,
655
+ # not deletion. This fixes completions not reappearing after backspace.
656
+ @bindings.add("c-h", eager=True) # Backspace (Ctrl+H)
657
+ @bindings.add("backspace", eager=True)
658
+ def handle_backspace_with_completion(event):
659
+ buffer = event.app.current_buffer
660
+ # Perform the deletion first
661
+ buffer.delete_before_cursor(count=1)
662
+ # Then trigger completion if text starts with '/'
663
+ text = buffer.text.lstrip()
664
+ if text.startswith("/"):
665
+ buffer.start_completion(select_first=False)
666
+
667
+ @bindings.add("delete", eager=True)
668
+ def handle_delete_with_completion(event):
669
+ buffer = event.app.current_buffer
670
+ # Perform the deletion first
671
+ buffer.delete(count=1)
672
+ # Then trigger completion if text starts with '/'
673
+ text = buffer.text.lstrip()
674
+ if text.startswith("/"):
675
+ buffer.start_completion(select_first=False)
676
+
677
+ # Handle bracketed paste - smart detection for text vs images.
678
+ # Most terminals (Windows included!) send Ctrl+V through bracketed paste.
679
+ # - If there's meaningful text content → paste as text (drag-and-drop file paths, copied text)
680
+ # - If text is empty/whitespace → check for clipboard image (image paste on Windows)
681
+ @bindings.add(Keys.BracketedPaste)
682
+ def handle_bracketed_paste(event):
683
+ """Handle bracketed paste - smart text vs image detection."""
684
+ pasted_data = event.data
685
+
686
+ # If we have meaningful text content, paste it (don't check for images)
687
+ # This handles drag-and-drop file paths and normal text paste
688
+ if pasted_data and pasted_data.strip():
689
+ # Normalize Windows line endings to Unix style
690
+ sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
691
+ event.app.current_buffer.insert_text(sanitized_data)
692
+ return
693
+
694
+ # No meaningful text - check if clipboard has an image (Windows image paste!)
695
+ try:
696
+ if has_image_in_clipboard():
697
+ placeholder = capture_clipboard_image_to_pending()
698
+ if placeholder:
699
+ event.app.current_buffer.insert_text(placeholder + " ")
700
+ event.app.output.bell()
701
+ return
702
+ except Exception:
703
+ pass
704
+
705
+ # Fallback: if there was whitespace-only data, paste it
706
+ if pasted_data:
707
+ sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
708
+ event.app.current_buffer.insert_text(sanitized_data)
709
+
710
+ # Fallback Ctrl+V for terminals without bracketed paste support
711
+ @bindings.add("c-v", eager=True)
712
+ def handle_smart_paste(event):
713
+ """Handle Ctrl+V - auto-detect image vs text in clipboard."""
714
+ try:
715
+ # Check for image first
716
+ if has_image_in_clipboard():
717
+ placeholder = capture_clipboard_image_to_pending()
718
+ if placeholder:
719
+ event.app.current_buffer.insert_text(placeholder + " ")
720
+ # The placeholder itself is visible feedback - no need for extra output
721
+ # Use bell for audible feedback (works in most terminals)
722
+ event.app.output.bell()
723
+ return # Don't also paste text
724
+ except Exception:
725
+ pass # Fall through to text paste on any error
726
+
727
+ # No image (or error) - do normal text paste
728
+ # prompt_toolkit doesn't have built-in paste, so we handle it manually
729
+ try:
730
+ import platform
731
+ import subprocess
732
+
733
+ text = None
734
+ system = platform.system()
735
+
736
+ if system == "Darwin": # macOS
737
+ result = subprocess.run(
738
+ ["pbpaste"], capture_output=True, text=True, timeout=2
739
+ )
740
+ if result.returncode == 0:
741
+ text = result.stdout
742
+ elif system == "Windows":
743
+ # Windows - use powershell
744
+ result = subprocess.run(
745
+ ["powershell", "-command", "Get-Clipboard"],
746
+ capture_output=True,
747
+ text=True,
748
+ timeout=2,
749
+ )
750
+ if result.returncode == 0:
751
+ text = result.stdout
752
+ else: # Linux
753
+ # Try xclip first, then xsel
754
+ for cmd in [
755
+ ["xclip", "-selection", "clipboard", "-o"],
756
+ ["xsel", "--clipboard", "--output"],
757
+ ]:
758
+ try:
759
+ result = subprocess.run(
760
+ cmd, capture_output=True, text=True, timeout=2
761
+ )
762
+ if result.returncode == 0:
763
+ text = result.stdout
764
+ break
765
+ except FileNotFoundError:
766
+ continue
767
+
768
+ if text:
769
+ # Normalize Windows line endings to Unix style
770
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
771
+ # Strip trailing newline that clipboard tools often add
772
+ text = text.rstrip("\n")
773
+ event.app.current_buffer.insert_text(text)
774
+ except Exception:
775
+ pass # Silently fail if text paste doesn't work
776
+
777
+ # F3 - dedicated image paste (shows error if no image)
778
+ @bindings.add("f3")
779
+ def handle_image_paste_f3(event):
780
+ """Handle F3 - paste image from clipboard (image-only, shows error if none)."""
781
+ try:
782
+ if has_image_in_clipboard():
783
+ placeholder = capture_clipboard_image_to_pending()
784
+ if placeholder:
785
+ event.app.current_buffer.insert_text(placeholder + " ")
786
+ # The placeholder itself is visible feedback
787
+ # Use bell for audible feedback (works in most terminals)
788
+ event.app.output.bell()
789
+ else:
790
+ # Insert a transient message that user can delete
791
+ event.app.current_buffer.insert_text("[⚠️ no image in clipboard] ")
792
+ event.app.output.bell()
793
+ except Exception:
794
+ event.app.current_buffer.insert_text("[❌ clipboard error] ")
795
+ event.app.output.bell()
796
+
797
+ session = PromptSession(
798
+ completer=completer,
799
+ history=history,
800
+ complete_while_typing=True,
801
+ key_bindings=bindings,
802
+ input_processors=[AttachmentPlaceholderProcessor()],
803
+ )
804
+ # If they pass a string, backward-compat: convert it to formatted_text
805
+ if isinstance(prompt_str, str):
806
+ from prompt_toolkit.formatted_text import FormattedText
807
+
808
+ prompt_str = FormattedText([(None, prompt_str)])
809
+ style = Style.from_dict(
810
+ {
811
+ # Keys must AVOID the 'class:' prefix – that prefix is used only when
812
+ # tagging tokens in `FormattedText`. See prompt_toolkit docs.
813
+ "puppy": "bold ansibrightcyan",
814
+ "owner": "bold ansibrightblue",
815
+ "agent": "bold ansibrightblue",
816
+ "model": "bold ansibrightcyan",
817
+ "cwd": "bold ansibrightgreen",
818
+ "arrow": "bold ansibrightblue",
819
+ "attachment-placeholder": "italic ansicyan",
820
+ }
821
+ )
822
+ text = await session.prompt_async(prompt_str, style=style)
823
+ # NOTE: We used to call update_model_in_input(text) here to handle /model and /m
824
+ # commands at the prompt level, but that prevented the command handler from running
825
+ # and emitting success messages. Now we let all /model commands fall through to
826
+ # the command handler in main.py for consistent handling.
827
+ return text
828
+
829
+
830
+ if __name__ == "__main__":
831
+ print("Type '@' for path-completion or '/model' to pick a model. Ctrl+D to exit.")
832
+
833
+ async def main():
834
+ while True:
835
+ try:
836
+ inp = await get_input_with_combined_completion(
837
+ get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE
838
+ )
839
+ print(f"You entered: {inp}")
840
+ except KeyboardInterrupt:
841
+ continue
842
+ except EOFError:
843
+ break
844
+ print("\nGoodbye!")
845
+
846
+ asyncio.run(main())