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