code-puppy 0.0.214__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 (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  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 +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -8,6 +8,7 @@
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
@@ -26,13 +27,19 @@ from code_puppy.command_line.attachments import (
26
27
  _detect_path_tokens,
27
28
  _tokenise,
28
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
29
35
  from code_puppy.command_line.file_path_completion import FilePathCompleter
30
36
  from code_puppy.command_line.load_context_completion import LoadContextCompleter
37
+ from code_puppy.command_line.mcp_completion import MCPCompleter
31
38
  from code_puppy.command_line.model_picker_completion import (
32
39
  ModelNameCompleter,
33
40
  get_active_model,
34
- update_model_in_input,
35
41
  )
42
+ from code_puppy.command_line.pin_command_completion import PinCompleter, UnpinCompleter
36
43
  from code_puppy.command_line.utils import list_directory
37
44
  from code_puppy.config import (
38
45
  COMMAND_HISTORY_FILE,
@@ -42,65 +49,119 @@ from code_puppy.config import (
42
49
  )
43
50
 
44
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
+
45
110
  class SetCompleter(Completer):
46
111
  def __init__(self, trigger: str = "/set"):
47
112
  self.trigger = trigger
48
113
 
49
114
  def get_completions(self, document, complete_event):
115
+ cursor_position = document.cursor_position
50
116
  text_before_cursor = document.text_before_cursor
51
117
  stripped_text_for_trigger_check = text_before_cursor.lstrip()
52
118
 
53
- 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 + " "):
54
135
  return
55
136
 
56
137
  # Determine the part of the text that is relevant for this completer
57
138
  # This handles cases like " /set foo" where the trigger isn't at the start of the string
58
139
  actual_trigger_pos = text_before_cursor.find(self.trigger)
59
- effective_input = text_before_cursor[
60
- actual_trigger_pos:
61
- ] # e.g., "/set keypart" or "/set "
62
-
63
- tokens = effective_input.split()
64
-
65
- # Case 1: Input is exactly the trigger (e.g., "/set") and nothing more (not even a trailing space on effective_input).
66
- # Suggest adding a space.
67
- if (
68
- len(tokens) == 1
69
- and tokens[0] == self.trigger
70
- and not effective_input.endswith(" ")
71
- ):
72
- yield Completion(
73
- text=self.trigger + " ", # Text to insert
74
- start_position=-len(tokens[0]), # Replace the trigger itself
75
- display=self.trigger + " ", # Visual display
76
- display_meta="set config key",
77
- )
78
- return
79
140
 
80
- # Case 2: Input is trigger + space (e.g., "/set ") or trigger + partial key (e.g., "/set partial")
81
- base_to_complete = ""
82
- if len(tokens) > 1: # e.g., ["/set", "partialkey"]
83
- base_to_complete = tokens[1]
84
- # If len(tokens) == 1, it implies effective_input was like "/set ", so base_to_complete remains ""
85
- # 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))
86
145
 
87
146
  # --- SPECIAL HANDLING FOR 'model' KEY ---
88
- if base_to_complete == "model":
147
+ if text_after_trigger == "model":
89
148
  # Don't return any completions -- let ModelNameCompleter handle it
90
149
  return
91
- 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:
92
155
  if key == "model" or key == "puppy_token":
93
156
  continue # exclude 'model' and 'puppy_token' from regular /set completions
94
- if key.startswith(base_to_complete):
157
+ if key.startswith(text_after_trigger):
95
158
  prev_value = get_value(key)
96
159
  value_part = f" = {prev_value}" if prev_value is not None else " = "
97
160
  completion_text = f"{key}{value_part}"
98
161
 
99
162
  yield Completion(
100
163
  completion_text,
101
- start_position=-len(
102
- base_to_complete
103
- ), # Correctly replace only the typed part of the key
164
+ start_position=start_position,
104
165
  display_meta="",
105
166
  )
106
167
 
@@ -229,23 +290,43 @@ class CDCompleter(Completer):
229
290
  self.trigger = trigger
230
291
 
231
292
  def get_completions(self, document, complete_event):
232
- text = document.text_before_cursor
233
- 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 + " "):
234
298
  return
235
- tokens = text.strip().split()
236
- if len(tokens) == 1:
237
- base = ""
238
- else:
239
- 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
+
240
306
  try:
241
- prefix = os.path.expanduser(base)
307
+ prefix = os.path.expanduser(dir_path)
242
308
  part = os.path.dirname(prefix) if os.path.dirname(prefix) else "."
243
309
  dirs, _ = list_directory(part)
244
- dirnames = [d for d in dirs if d.startswith(os.path.basename(base))]
245
- 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
+
246
324
  for d in dirnames:
247
325
  # Build the completion text so we keep the already-typed directory parts.
248
- 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 != ".":
249
330
  suggestion = os.path.join(base_dir, d)
250
331
  else:
251
332
  suggestion = d
@@ -253,7 +334,7 @@ class CDCompleter(Completer):
253
334
  suggestion = suggestion.rstrip(os.sep) + os.sep
254
335
  yield Completion(
255
336
  suggestion,
256
- start_position=-len(base),
337
+ start_position=start_position,
257
338
  display=d + os.sep,
258
339
  display_meta="Directory",
259
340
  )
@@ -262,6 +343,174 @@ class CDCompleter(Completer):
262
343
  pass
263
344
 
264
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
+
265
514
  def get_prompt_with_active_model(base: str = ">>> "):
266
515
  from code_puppy.agents.agent_manager import get_current_agent
267
516
 
@@ -310,14 +559,22 @@ def get_prompt_with_active_model(base: str = ">>> "):
310
559
  async def get_input_with_combined_completion(
311
560
  prompt_str=">>> ", history_file: Optional[str] = None
312
561
  ) -> str:
313
- 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
314
564
  completer = merge_completers(
315
565
  [
316
566
  FilePathCompleter(symbol="@"),
317
567
  ModelNameCompleter(trigger="/model"),
568
+ ModelNameCompleter(trigger="/m"),
318
569
  CDCompleter(trigger="/cd"),
319
570
  SetCompleter(trigger="/set"),
320
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(),
321
578
  ]
322
579
  )
323
580
  # Add custom key bindings and multiline toggle
@@ -326,20 +583,47 @@ async def get_input_with_combined_completion(
326
583
  # Multiline mode state
327
584
  multiline = {"enabled": False}
328
585
 
586
+ # Ctrl+X keybinding - exit with KeyboardInterrupt for shell command cancellation
587
+ @bindings.add(Keys.ControlX)
588
+ def _(event):
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
595
+
596
+ # Escape keybinding - exit with KeyboardInterrupt
597
+ @bindings.add(Keys.Escape)
598
+ def _(event):
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.
609
+
329
610
  # Toggle multiline with Alt+M
330
611
  @bindings.add(Keys.Escape, "m")
331
612
  def _(event):
332
613
  multiline["enabled"] = not multiline["enabled"]
333
614
  status = "ON" if multiline["enabled"] else "OFF"
334
615
  # Print status for user feedback (version-agnostic)
335
- print(f"[multiline] {status}", flush=True)
616
+ # Note: Using sys.stdout here for immediate feedback during input
617
+ sys.stdout.write(f"[multiline] {status}\n")
618
+ sys.stdout.flush()
336
619
 
337
620
  # Also toggle multiline with F2 (more reliable across platforms)
338
621
  @bindings.add("f2")
339
622
  def _(event):
340
623
  multiline["enabled"] = not multiline["enabled"]
341
624
  status = "ON" if multiline["enabled"] else "OFF"
342
- print(f"[multiline] {status}", flush=True)
625
+ sys.stdout.write(f"[multiline] {status}\n")
626
+ sys.stdout.flush()
343
627
 
344
628
  # Newline insert bindings — robust and explicit
345
629
  # Ctrl+J (line feed) works in virtually all terminals; mark eager so it wins
@@ -364,10 +648,125 @@ async def get_input_with_combined_completion(
364
648
  else:
365
649
  event.current_buffer.validate_and_handle()
366
650
 
367
- @bindings.add(Keys.Escape)
368
- def _(event):
369
- """Cancel the current prompt when the user presses the ESC key alone."""
370
- event.app.exit(exception=KeyboardInterrupt)
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()
371
770
 
372
771
  session = PromptSession(
373
772
  completer=completer,
@@ -385,19 +784,20 @@ async def get_input_with_combined_completion(
385
784
  {
386
785
  # Keys must AVOID the 'class:' prefix – that prefix is used only when
387
786
  # tagging tokens in `FormattedText`. See prompt_toolkit docs.
388
- "puppy": "bold magenta",
389
- "owner": "bold white",
390
- "agent": "bold blue",
391
- "model": "bold cyan",
392
- "cwd": "bold green",
393
- "arrow": "bold yellow",
394
- "attachment-placeholder": "italic cyan",
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",
395
794
  }
396
795
  )
397
796
  text = await session.prompt_async(prompt_str, style=style)
398
- possibly_stripped = update_model_in_input(text)
399
- if possibly_stripped is not None:
400
- 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.
401
801
  return text
402
802
 
403
803