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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {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
|
-
|
|
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
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
147
|
+
if text_after_trigger == "model":
|
|
81
148
|
# Don't return any completions -- let ModelNameCompleter handle it
|
|
82
149
|
return
|
|
83
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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(
|
|
118
|
-
base_dir = os.path.dirname(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
580
|
+
# Add custom key bindings and multiline toggle
|
|
197
581
|
bindings = KeyBindings()
|
|
198
582
|
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
204
|
-
@bindings.add(
|
|
596
|
+
# Escape keybinding - exit with KeyboardInterrupt
|
|
597
|
+
@bindings.add(Keys.Escape)
|
|
205
598
|
def _(event):
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
#
|
|
210
|
-
@bindings.add("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
239
|
-
"owner": "bold
|
|
240
|
-
"agent": "bold
|
|
241
|
-
"model": "bold
|
|
242
|
-
"cwd": "bold
|
|
243
|
-
"arrow": "bold
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|