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