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
code_puppy/tools/common.py
CHANGED
|
@@ -1,16 +1,44 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import fnmatch
|
|
3
|
+
import functools
|
|
2
4
|
import hashlib
|
|
5
|
+
import logging
|
|
3
6
|
import os
|
|
7
|
+
import sys
|
|
4
8
|
import time
|
|
5
9
|
from pathlib import Path
|
|
6
|
-
from typing import Optional, Tuple
|
|
10
|
+
from typing import Any, Callable, Optional, Tuple
|
|
7
11
|
|
|
12
|
+
from prompt_toolkit import Application
|
|
13
|
+
from prompt_toolkit.formatted_text import HTML
|
|
14
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
15
|
+
from prompt_toolkit.layout import Layout, Window
|
|
16
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
8
17
|
from rapidfuzz.distance import JaroWinkler
|
|
9
18
|
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.prompt import Prompt
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
# Syntax highlighting imports for "syntax" diff mode
|
|
24
|
+
try:
|
|
25
|
+
from pygments import lex
|
|
26
|
+
from pygments.lexers import TextLexer, get_lexer_by_name
|
|
27
|
+
from pygments.token import Token
|
|
28
|
+
|
|
29
|
+
PYGMENTS_AVAILABLE = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
PYGMENTS_AVAILABLE = False
|
|
10
32
|
|
|
11
33
|
# Import our queue-based console system
|
|
12
34
|
try:
|
|
13
|
-
from code_puppy.messaging import
|
|
35
|
+
from code_puppy.messaging import (
|
|
36
|
+
emit_error,
|
|
37
|
+
emit_info,
|
|
38
|
+
emit_success,
|
|
39
|
+
emit_warning,
|
|
40
|
+
get_queue_console,
|
|
41
|
+
)
|
|
14
42
|
|
|
15
43
|
# Use queue console by default, but allow fallback
|
|
16
44
|
NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
|
|
@@ -23,11 +51,59 @@ except ImportError:
|
|
|
23
51
|
NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
|
|
24
52
|
console = Console(no_color=NO_COLOR)
|
|
25
53
|
|
|
54
|
+
# Provide fallback emit functions
|
|
55
|
+
def emit_error(msg: str) -> None:
|
|
56
|
+
console.print(f"[bold red]{msg}[/bold red]")
|
|
57
|
+
|
|
58
|
+
def emit_info(msg: str) -> None:
|
|
59
|
+
console.print(msg)
|
|
60
|
+
|
|
61
|
+
def emit_success(msg: str) -> None:
|
|
62
|
+
console.print(f"[bold green]{msg}[/bold green]")
|
|
63
|
+
|
|
64
|
+
def emit_warning(msg: str) -> None:
|
|
65
|
+
console.print(f"[bold yellow]{msg}[/bold yellow]")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def should_suppress_browser() -> bool:
|
|
69
|
+
"""Check if browsers should be suppressed (headless mode).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
True if browsers should be suppressed, False if they can open normally
|
|
73
|
+
|
|
74
|
+
This respects multiple headless mode controls:
|
|
75
|
+
- HEADLESS=true environment variable (suppresses ALL browsers)
|
|
76
|
+
- BROWSER_HEADLESS=true environment variable (for browser automation)
|
|
77
|
+
- CI=true environment variable (continuous integration)
|
|
78
|
+
- PYTEST_CURRENT_TEST environment variable (running under pytest)
|
|
79
|
+
"""
|
|
80
|
+
# Explicit headless mode
|
|
81
|
+
if os.getenv("HEADLESS", "").lower() == "true":
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
# Browser-specific headless mode
|
|
85
|
+
if os.getenv("BROWSER_HEADLESS", "").lower() == "true":
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
# Continuous integration environments
|
|
89
|
+
if os.getenv("CI", "").lower() == "true":
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
# Running under pytest
|
|
93
|
+
if "PYTEST_CURRENT_TEST" in os.environ:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
# Default to allowing browsers
|
|
97
|
+
return False
|
|
98
|
+
|
|
26
99
|
|
|
27
100
|
# -------------------
|
|
28
101
|
# Shared ignore patterns/helpers
|
|
102
|
+
# Split into directory vs file patterns so tools can choose appropriately
|
|
103
|
+
# - list_files should ignore only directories (still show binary files inside non-ignored dirs)
|
|
104
|
+
# - grep should ignore both directories and files (avoid grepping binaries)
|
|
29
105
|
# -------------------
|
|
30
|
-
|
|
106
|
+
DIR_IGNORE_PATTERNS = [
|
|
31
107
|
# Version control
|
|
32
108
|
"**/.git/**",
|
|
33
109
|
"**/.git",
|
|
@@ -304,6 +380,10 @@ IGNORE_PATTERNS = [
|
|
|
304
380
|
"**/*.save",
|
|
305
381
|
# Hidden files (but be careful with this one)
|
|
306
382
|
"**/.*", # Commented out as it might be too aggressive
|
|
383
|
+
# Directory-only section ends here
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
FILE_IGNORE_PATTERNS = [
|
|
307
387
|
# Binary image formats
|
|
308
388
|
"**/*.png",
|
|
309
389
|
"**/*.jpg",
|
|
@@ -354,6 +434,9 @@ IGNORE_PATTERNS = [
|
|
|
354
434
|
"**/*.sqlite3",
|
|
355
435
|
]
|
|
356
436
|
|
|
437
|
+
# Backwards compatibility for any imports still referring to IGNORE_PATTERNS
|
|
438
|
+
IGNORE_PATTERNS = DIR_IGNORE_PATTERNS + FILE_IGNORE_PATTERNS
|
|
439
|
+
|
|
357
440
|
|
|
358
441
|
def should_ignore_path(path: str) -> bool:
|
|
359
442
|
"""Return True if *path* matches any pattern in IGNORE_PATTERNS."""
|
|
@@ -389,6 +472,890 @@ def should_ignore_path(path: str) -> bool:
|
|
|
389
472
|
return False
|
|
390
473
|
|
|
391
474
|
|
|
475
|
+
def should_ignore_dir_path(path: str) -> bool:
|
|
476
|
+
"""Return True if path matches any directory ignore pattern (directories only)."""
|
|
477
|
+
path_obj = Path(path)
|
|
478
|
+
for pattern in DIR_IGNORE_PATTERNS:
|
|
479
|
+
try:
|
|
480
|
+
if path_obj.match(pattern):
|
|
481
|
+
return True
|
|
482
|
+
except ValueError:
|
|
483
|
+
if fnmatch.fnmatch(path, pattern):
|
|
484
|
+
return True
|
|
485
|
+
if "**" in pattern:
|
|
486
|
+
simplified = pattern.replace("**/", "").replace("/**", "")
|
|
487
|
+
parts = path_obj.parts
|
|
488
|
+
for i in range(len(parts)):
|
|
489
|
+
subpath = Path(*parts[i:])
|
|
490
|
+
if fnmatch.fnmatch(str(subpath), simplified):
|
|
491
|
+
return True
|
|
492
|
+
if fnmatch.fnmatch(parts[i], simplified):
|
|
493
|
+
return True
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ============================================================================
|
|
498
|
+
# SYNTAX HIGHLIGHTING FOR DIFFS ("syntax" mode)
|
|
499
|
+
# ============================================================================
|
|
500
|
+
|
|
501
|
+
# Monokai color scheme - because we have taste 🎨
|
|
502
|
+
TOKEN_COLORS = (
|
|
503
|
+
{
|
|
504
|
+
Token.Keyword: "#f92672" if PYGMENTS_AVAILABLE else "magenta",
|
|
505
|
+
Token.Name.Builtin: "#66d9ef" if PYGMENTS_AVAILABLE else "cyan",
|
|
506
|
+
Token.Name.Function: "#a6e22e" if PYGMENTS_AVAILABLE else "green",
|
|
507
|
+
Token.String: "#e6db74" if PYGMENTS_AVAILABLE else "yellow",
|
|
508
|
+
Token.Number: "#ae81ff" if PYGMENTS_AVAILABLE else "magenta",
|
|
509
|
+
Token.Comment: "#75715e" if PYGMENTS_AVAILABLE else "bright_black",
|
|
510
|
+
Token.Operator: "#f92672" if PYGMENTS_AVAILABLE else "magenta",
|
|
511
|
+
}
|
|
512
|
+
if PYGMENTS_AVAILABLE
|
|
513
|
+
else {}
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
EXTENSION_TO_LEXER_NAME = {
|
|
517
|
+
".py": "python",
|
|
518
|
+
".js": "javascript",
|
|
519
|
+
".jsx": "jsx",
|
|
520
|
+
".ts": "typescript",
|
|
521
|
+
".tsx": "tsx",
|
|
522
|
+
".java": "java",
|
|
523
|
+
".c": "c",
|
|
524
|
+
".h": "c",
|
|
525
|
+
".cpp": "cpp",
|
|
526
|
+
".hpp": "cpp",
|
|
527
|
+
".cc": "cpp",
|
|
528
|
+
".cxx": "cpp",
|
|
529
|
+
".cs": "csharp",
|
|
530
|
+
".rs": "rust",
|
|
531
|
+
".go": "go",
|
|
532
|
+
".rb": "ruby",
|
|
533
|
+
".php": "php",
|
|
534
|
+
".html": "html",
|
|
535
|
+
".htm": "html",
|
|
536
|
+
".css": "css",
|
|
537
|
+
".scss": "scss",
|
|
538
|
+
".json": "json",
|
|
539
|
+
".yaml": "yaml",
|
|
540
|
+
".yml": "yaml",
|
|
541
|
+
".md": "markdown",
|
|
542
|
+
".sh": "bash",
|
|
543
|
+
".bash": "bash",
|
|
544
|
+
".sql": "sql",
|
|
545
|
+
".txt": "text",
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _get_lexer_for_extension(extension: str):
|
|
550
|
+
"""Get the appropriate Pygments lexer for a file extension.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
extension: File extension (with or without leading dot)
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
A Pygments lexer instance or None if Pygments not available
|
|
557
|
+
"""
|
|
558
|
+
if not PYGMENTS_AVAILABLE:
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
# Normalize extension to have leading dot and be lowercase
|
|
562
|
+
if not extension.startswith("."):
|
|
563
|
+
extension = f".{extension}"
|
|
564
|
+
extension = extension.lower()
|
|
565
|
+
|
|
566
|
+
lexer_name = EXTENSION_TO_LEXER_NAME.get(extension, "text")
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
return get_lexer_by_name(lexer_name)
|
|
570
|
+
except Exception:
|
|
571
|
+
# Fallback to plain text if lexer not found
|
|
572
|
+
return TextLexer()
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _get_token_color(token_type) -> str:
|
|
576
|
+
"""Get color for a token type from our Monokai scheme.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
token_type: Pygments token type
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
Hex color string or color name
|
|
583
|
+
"""
|
|
584
|
+
if not PYGMENTS_AVAILABLE:
|
|
585
|
+
return "#cccccc"
|
|
586
|
+
|
|
587
|
+
for ttype, color in TOKEN_COLORS.items():
|
|
588
|
+
if token_type in ttype:
|
|
589
|
+
return color
|
|
590
|
+
return "#cccccc" # Default light-grey for unmatched tokens
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _highlight_code_line(code: str, bg_color: str | None, lexer) -> Text:
|
|
594
|
+
"""Highlight a line of code with syntax highlighting and optional background color.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
code: The code string to highlight
|
|
598
|
+
bg_color: Background color in hex format, or None for no background
|
|
599
|
+
lexer: Pygments lexer instance to use
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Rich Text object with styling applied
|
|
603
|
+
"""
|
|
604
|
+
if not PYGMENTS_AVAILABLE or lexer is None:
|
|
605
|
+
# Fallback: just return text with optional background
|
|
606
|
+
if bg_color:
|
|
607
|
+
return Text(code, style=f"on {bg_color}")
|
|
608
|
+
return Text(code)
|
|
609
|
+
|
|
610
|
+
text = Text()
|
|
611
|
+
|
|
612
|
+
for token_type, value in lex(code, lexer):
|
|
613
|
+
# Strip trailing newlines that Pygments adds
|
|
614
|
+
# Pygments lexer always adds a \n at the end of the last token
|
|
615
|
+
value = value.rstrip("\n")
|
|
616
|
+
|
|
617
|
+
# Skip if the value is now empty (was only whitespace/newlines)
|
|
618
|
+
if not value:
|
|
619
|
+
continue
|
|
620
|
+
|
|
621
|
+
fg_color = _get_token_color(token_type)
|
|
622
|
+
# Apply foreground color and optional background
|
|
623
|
+
if bg_color:
|
|
624
|
+
text.append(value, style=f"{fg_color} on {bg_color}")
|
|
625
|
+
else:
|
|
626
|
+
text.append(value, style=fg_color)
|
|
627
|
+
|
|
628
|
+
return text
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _extract_file_extension_from_diff(diff_text: str) -> str:
|
|
632
|
+
"""Extract file extension from diff headers.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
diff_text: Unified diff text
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
File extension (e.g., '.py') or '.txt' as fallback
|
|
639
|
+
"""
|
|
640
|
+
import re
|
|
641
|
+
|
|
642
|
+
# Look for +++ b/filename.ext or --- a/filename.ext headers
|
|
643
|
+
pattern = r"^(?:\+\+\+|---) [ab]/.*?(\.[a-zA-Z0-9]+)$"
|
|
644
|
+
|
|
645
|
+
for line in diff_text.split("\n")[:10]: # Check first 10 lines
|
|
646
|
+
match = re.search(pattern, line)
|
|
647
|
+
if match:
|
|
648
|
+
return match.group(1)
|
|
649
|
+
|
|
650
|
+
return ".txt" # Fallback to plain text
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ============================================================================
|
|
654
|
+
# COLOR PAIR OPTIMIZATION (for "highlighted" mode)
|
|
655
|
+
# ============================================================================
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def brighten_hex(hex_color: str, factor: float) -> str:
|
|
659
|
+
"""
|
|
660
|
+
Darken a hex color by multiplying each RGB channel by `factor`.
|
|
661
|
+
factor=1.0 -> no change
|
|
662
|
+
factor=0.0 -> black
|
|
663
|
+
factor=0.18 -> good for diff backgrounds (recommended)
|
|
664
|
+
"""
|
|
665
|
+
hex_color = hex_color.lstrip("#")
|
|
666
|
+
if len(hex_color) != 6:
|
|
667
|
+
raise ValueError(f"Expected #RRGGBB, got {hex_color!r}")
|
|
668
|
+
|
|
669
|
+
r = int(hex_color[0:2], 16)
|
|
670
|
+
g = int(hex_color[2:4], 16)
|
|
671
|
+
b = int(hex_color[4:6], 16)
|
|
672
|
+
|
|
673
|
+
r = max(0, min(255, int(r * (1 + factor))))
|
|
674
|
+
g = max(0, min(255, int(g * (1 + factor))))
|
|
675
|
+
b = max(0, min(255, int(b * (1 + factor))))
|
|
676
|
+
|
|
677
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _format_diff_with_syntax_highlighting(
|
|
681
|
+
diff_text: str,
|
|
682
|
+
addition_color: str | None = None,
|
|
683
|
+
deletion_color: str | None = None,
|
|
684
|
+
) -> Text:
|
|
685
|
+
"""Format diff with full syntax highlighting using Pygments.
|
|
686
|
+
|
|
687
|
+
This renders diffs with:
|
|
688
|
+
- Syntax highlighting for code tokens
|
|
689
|
+
- Colored backgrounds for context/added/removed lines
|
|
690
|
+
- Monokai color scheme
|
|
691
|
+
- Optional custom colors for additions/deletions
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
diff_text: Raw unified diff text
|
|
695
|
+
addition_color: Optional custom color for added lines (default: green)
|
|
696
|
+
deletion_color: Optional custom color for deleted lines (default: red)
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
Rich Text object with syntax highlighting (can be passed to emit_info)
|
|
700
|
+
"""
|
|
701
|
+
if not PYGMENTS_AVAILABLE:
|
|
702
|
+
return Text(diff_text)
|
|
703
|
+
|
|
704
|
+
# Extract file extension from diff headers
|
|
705
|
+
extension = _extract_file_extension_from_diff(diff_text)
|
|
706
|
+
lexer = _get_lexer_for_extension(extension)
|
|
707
|
+
|
|
708
|
+
# Generate background colors from foreground colors
|
|
709
|
+
add_fg = brighten_hex(addition_color, 0.6)
|
|
710
|
+
del_fg = brighten_hex(deletion_color, 0.6)
|
|
711
|
+
|
|
712
|
+
# Background colors for different line types
|
|
713
|
+
# Context lines have no background (None) for clean, minimal diffs
|
|
714
|
+
bg_colors = {
|
|
715
|
+
"removed": deletion_color,
|
|
716
|
+
"added": addition_color,
|
|
717
|
+
"context": None, # No background for unchanged lines
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
lines = diff_text.split("\n")
|
|
721
|
+
# Remove trailing empty line if it exists (from trailing \n in diff)
|
|
722
|
+
if lines and lines[-1] == "":
|
|
723
|
+
lines = lines[:-1]
|
|
724
|
+
result = Text()
|
|
725
|
+
|
|
726
|
+
for i, line in enumerate(lines):
|
|
727
|
+
if not line:
|
|
728
|
+
# Empty line - just add a newline if not the last line
|
|
729
|
+
if i < len(lines) - 1:
|
|
730
|
+
result.append("\n")
|
|
731
|
+
continue
|
|
732
|
+
|
|
733
|
+
# Skip diff headers - they're redundant noise since we show the filename in the banner
|
|
734
|
+
if line.startswith(("---", "+++", "@@", "diff ", "index ")):
|
|
735
|
+
continue
|
|
736
|
+
else:
|
|
737
|
+
# Determine line type and extract code content
|
|
738
|
+
if line.startswith("-"):
|
|
739
|
+
line_type = "removed"
|
|
740
|
+
code = line[1:] # Remove the '-' prefix
|
|
741
|
+
marker_style = f"bold {del_fg} on {bg_colors[line_type]}"
|
|
742
|
+
prefix = "- "
|
|
743
|
+
elif line.startswith("+"):
|
|
744
|
+
line_type = "added"
|
|
745
|
+
code = line[1:] # Remove the '+' prefix
|
|
746
|
+
marker_style = f"bold {add_fg} on {bg_colors[line_type]}"
|
|
747
|
+
prefix = "+ "
|
|
748
|
+
else:
|
|
749
|
+
line_type = "context"
|
|
750
|
+
code = line[1:] if line.startswith(" ") else line
|
|
751
|
+
# Context lines have no background - clean and minimal
|
|
752
|
+
marker_style = "" # No special styling for context markers
|
|
753
|
+
prefix = " "
|
|
754
|
+
|
|
755
|
+
# Add the marker prefix
|
|
756
|
+
if marker_style: # Only apply style if we have one
|
|
757
|
+
result.append(prefix, style=marker_style)
|
|
758
|
+
else:
|
|
759
|
+
result.append(prefix)
|
|
760
|
+
|
|
761
|
+
# Add syntax-highlighted code
|
|
762
|
+
highlighted = _highlight_code_line(code, bg_colors[line_type], lexer)
|
|
763
|
+
result.append_text(highlighted)
|
|
764
|
+
|
|
765
|
+
# Add newline after each line except the last
|
|
766
|
+
if i < len(lines) - 1:
|
|
767
|
+
result.append("\n")
|
|
768
|
+
|
|
769
|
+
return result
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def format_diff_with_colors(diff_text: str) -> Text:
|
|
773
|
+
"""Format diff text with beautiful syntax highlighting.
|
|
774
|
+
|
|
775
|
+
This is the canonical diff formatting function used across the codebase.
|
|
776
|
+
It applies user-configurable color coding with full syntax highlighting using Pygments.
|
|
777
|
+
|
|
778
|
+
The function respects user preferences from config:
|
|
779
|
+
- get_diff_addition_color(): Color for added lines (markers and backgrounds)
|
|
780
|
+
- get_diff_deletion_color(): Color for deleted lines (markers and backgrounds)
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
diff_text: Raw diff text to format
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
Rich Text object with syntax highlighting
|
|
787
|
+
"""
|
|
788
|
+
from code_puppy.config import (
|
|
789
|
+
get_diff_addition_color,
|
|
790
|
+
get_diff_deletion_color,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
if not diff_text or not diff_text.strip():
|
|
794
|
+
return Text("-- no diff available --", style="dim")
|
|
795
|
+
|
|
796
|
+
addition_base_color = get_diff_addition_color()
|
|
797
|
+
deletion_base_color = get_diff_deletion_color()
|
|
798
|
+
|
|
799
|
+
# Always use beautiful syntax highlighting!
|
|
800
|
+
if not PYGMENTS_AVAILABLE:
|
|
801
|
+
emit_warning("Pygments not available, diffs will look plain")
|
|
802
|
+
# Return plain text as fallback
|
|
803
|
+
return Text(diff_text)
|
|
804
|
+
|
|
805
|
+
# Return Text object with custom colors - emit_info handles this correctly
|
|
806
|
+
return _format_diff_with_syntax_highlighting(
|
|
807
|
+
diff_text,
|
|
808
|
+
addition_color=addition_base_color,
|
|
809
|
+
deletion_color=deletion_base_color,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
async def arrow_select_async(
|
|
814
|
+
message: str,
|
|
815
|
+
choices: list[str],
|
|
816
|
+
preview_callback: Optional[Callable[[int], str]] = None,
|
|
817
|
+
) -> str:
|
|
818
|
+
"""Async version: Show an arrow-key navigable selector with optional preview.
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
message: The prompt message to display
|
|
822
|
+
choices: List of choice strings
|
|
823
|
+
preview_callback: Optional callback that takes the selected index and returns
|
|
824
|
+
preview text to display below the choices
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
The selected choice string
|
|
828
|
+
|
|
829
|
+
Raises:
|
|
830
|
+
KeyboardInterrupt: If user cancels with Ctrl-C
|
|
831
|
+
"""
|
|
832
|
+
import html
|
|
833
|
+
|
|
834
|
+
selected_index = [0] # Mutable container for selected index
|
|
835
|
+
result = [None] # Mutable container for result
|
|
836
|
+
|
|
837
|
+
def get_formatted_text():
|
|
838
|
+
"""Generate the formatted text for display."""
|
|
839
|
+
# Escape XML special characters to prevent parsing errors
|
|
840
|
+
safe_message = html.escape(message)
|
|
841
|
+
lines = [f"<b>{safe_message}</b>", ""]
|
|
842
|
+
for i, choice in enumerate(choices):
|
|
843
|
+
safe_choice = html.escape(choice)
|
|
844
|
+
if i == selected_index[0]:
|
|
845
|
+
lines.append(f"<ansigreen>❯ {safe_choice}</ansigreen>")
|
|
846
|
+
else:
|
|
847
|
+
lines.append(f" {safe_choice}")
|
|
848
|
+
lines.append("")
|
|
849
|
+
|
|
850
|
+
# Add preview section if callback provided
|
|
851
|
+
if preview_callback is not None:
|
|
852
|
+
preview_text = preview_callback(selected_index[0])
|
|
853
|
+
if preview_text:
|
|
854
|
+
import textwrap
|
|
855
|
+
|
|
856
|
+
# Box width (excluding borders and padding)
|
|
857
|
+
box_width = 60
|
|
858
|
+
border_top = (
|
|
859
|
+
"<ansiyellow>┌─ Preview "
|
|
860
|
+
+ "─" * (box_width - 10)
|
|
861
|
+
+ "┐</ansiyellow>"
|
|
862
|
+
)
|
|
863
|
+
border_bottom = "<ansiyellow>└" + "─" * box_width + "┘</ansiyellow>"
|
|
864
|
+
|
|
865
|
+
lines.append(border_top)
|
|
866
|
+
|
|
867
|
+
# Wrap text to fit within box width (minus padding)
|
|
868
|
+
wrapped_lines = textwrap.wrap(preview_text, width=box_width - 2)
|
|
869
|
+
|
|
870
|
+
# If no wrapped lines (empty text), add empty line
|
|
871
|
+
if not wrapped_lines:
|
|
872
|
+
wrapped_lines = [""]
|
|
873
|
+
|
|
874
|
+
for wrapped_line in wrapped_lines:
|
|
875
|
+
safe_preview = html.escape(wrapped_line)
|
|
876
|
+
# Pad line to box width for consistent appearance
|
|
877
|
+
padded_line = safe_preview.ljust(box_width - 2)
|
|
878
|
+
lines.append(f"<dim>│ {padded_line} │</dim>")
|
|
879
|
+
|
|
880
|
+
lines.append(border_bottom)
|
|
881
|
+
lines.append("")
|
|
882
|
+
|
|
883
|
+
lines.append("<ansicyan>(Use ↑↓ arrows to select, Enter to confirm)</ansicyan>")
|
|
884
|
+
return HTML("\n".join(lines))
|
|
885
|
+
|
|
886
|
+
# Key bindings
|
|
887
|
+
kb = KeyBindings()
|
|
888
|
+
|
|
889
|
+
@kb.add("up")
|
|
890
|
+
def move_up(event):
|
|
891
|
+
selected_index[0] = (selected_index[0] - 1) % len(choices)
|
|
892
|
+
event.app.invalidate() # Force redraw to update preview
|
|
893
|
+
|
|
894
|
+
@kb.add("down")
|
|
895
|
+
def move_down(event):
|
|
896
|
+
selected_index[0] = (selected_index[0] + 1) % len(choices)
|
|
897
|
+
event.app.invalidate() # Force redraw to update preview
|
|
898
|
+
|
|
899
|
+
@kb.add("enter")
|
|
900
|
+
def accept(event):
|
|
901
|
+
result[0] = choices[selected_index[0]]
|
|
902
|
+
event.app.exit()
|
|
903
|
+
|
|
904
|
+
@kb.add("c-c") # Ctrl-C
|
|
905
|
+
def cancel(event):
|
|
906
|
+
result[0] = None
|
|
907
|
+
event.app.exit()
|
|
908
|
+
|
|
909
|
+
# Layout
|
|
910
|
+
control = FormattedTextControl(get_formatted_text)
|
|
911
|
+
layout = Layout(Window(content=control))
|
|
912
|
+
|
|
913
|
+
# Application
|
|
914
|
+
app = Application(
|
|
915
|
+
layout=layout,
|
|
916
|
+
key_bindings=kb,
|
|
917
|
+
full_screen=False,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# Flush output before prompt_toolkit takes control
|
|
921
|
+
sys.stdout.flush()
|
|
922
|
+
sys.stderr.flush()
|
|
923
|
+
|
|
924
|
+
# Run the app asynchronously
|
|
925
|
+
await app.run_async()
|
|
926
|
+
|
|
927
|
+
if result[0] is None:
|
|
928
|
+
raise KeyboardInterrupt()
|
|
929
|
+
|
|
930
|
+
return result[0]
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def arrow_select(message: str, choices: list[str]) -> str:
|
|
934
|
+
"""Show an arrow-key navigable selector (synchronous version).
|
|
935
|
+
|
|
936
|
+
Args:
|
|
937
|
+
message: The prompt message to display
|
|
938
|
+
choices: List of choice strings
|
|
939
|
+
|
|
940
|
+
Returns:
|
|
941
|
+
The selected choice string
|
|
942
|
+
|
|
943
|
+
Raises:
|
|
944
|
+
KeyboardInterrupt: If user cancels with Ctrl-C
|
|
945
|
+
"""
|
|
946
|
+
import asyncio
|
|
947
|
+
|
|
948
|
+
selected_index = [0] # Mutable container for selected index
|
|
949
|
+
result = [None] # Mutable container for result
|
|
950
|
+
|
|
951
|
+
def get_formatted_text():
|
|
952
|
+
"""Generate the formatted text for display."""
|
|
953
|
+
lines = [f"<b>{message}</b>", ""]
|
|
954
|
+
for i, choice in enumerate(choices):
|
|
955
|
+
if i == selected_index[0]:
|
|
956
|
+
lines.append(f"<ansigreen>❯ {choice}</ansigreen>")
|
|
957
|
+
else:
|
|
958
|
+
lines.append(f" {choice}")
|
|
959
|
+
lines.append("")
|
|
960
|
+
lines.append("<ansicyan>(Use ↑↓ arrows to select, Enter to confirm)</ansicyan>")
|
|
961
|
+
return HTML("\n".join(lines))
|
|
962
|
+
|
|
963
|
+
# Key bindings
|
|
964
|
+
kb = KeyBindings()
|
|
965
|
+
|
|
966
|
+
@kb.add("up")
|
|
967
|
+
def move_up(event):
|
|
968
|
+
selected_index[0] = (selected_index[0] - 1) % len(choices)
|
|
969
|
+
event.app.invalidate() # Force redraw to update preview
|
|
970
|
+
|
|
971
|
+
@kb.add("down")
|
|
972
|
+
def move_down(event):
|
|
973
|
+
selected_index[0] = (selected_index[0] + 1) % len(choices)
|
|
974
|
+
event.app.invalidate() # Force redraw to update preview
|
|
975
|
+
|
|
976
|
+
@kb.add("enter")
|
|
977
|
+
def accept(event):
|
|
978
|
+
result[0] = choices[selected_index[0]]
|
|
979
|
+
event.app.exit()
|
|
980
|
+
|
|
981
|
+
@kb.add("c-c") # Ctrl-C
|
|
982
|
+
def cancel(event):
|
|
983
|
+
result[0] = None
|
|
984
|
+
event.app.exit()
|
|
985
|
+
|
|
986
|
+
# Layout
|
|
987
|
+
control = FormattedTextControl(get_formatted_text)
|
|
988
|
+
layout = Layout(Window(content=control))
|
|
989
|
+
|
|
990
|
+
# Application
|
|
991
|
+
app = Application(
|
|
992
|
+
layout=layout,
|
|
993
|
+
key_bindings=kb,
|
|
994
|
+
full_screen=False,
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
# Flush output before prompt_toolkit takes control
|
|
998
|
+
sys.stdout.flush()
|
|
999
|
+
sys.stderr.flush()
|
|
1000
|
+
|
|
1001
|
+
# Check if we're already in an async context
|
|
1002
|
+
try:
|
|
1003
|
+
asyncio.get_running_loop()
|
|
1004
|
+
# We're in an async context - can't use app.run()
|
|
1005
|
+
# Caller should use arrow_select_async instead
|
|
1006
|
+
raise RuntimeError(
|
|
1007
|
+
"arrow_select() called from async context. Use arrow_select_async() instead."
|
|
1008
|
+
)
|
|
1009
|
+
except RuntimeError as e:
|
|
1010
|
+
if "no running event loop" in str(e).lower():
|
|
1011
|
+
# No event loop, safe to use app.run()
|
|
1012
|
+
app.run()
|
|
1013
|
+
else:
|
|
1014
|
+
# Re-raise if it's our error message
|
|
1015
|
+
raise
|
|
1016
|
+
|
|
1017
|
+
if result[0] is None:
|
|
1018
|
+
raise KeyboardInterrupt()
|
|
1019
|
+
|
|
1020
|
+
return result[0]
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def get_user_approval(
|
|
1024
|
+
title: str,
|
|
1025
|
+
content: Text | str,
|
|
1026
|
+
preview: str | None = None,
|
|
1027
|
+
border_style: str = "dim white",
|
|
1028
|
+
puppy_name: str | None = None,
|
|
1029
|
+
) -> tuple[bool, str | None]:
|
|
1030
|
+
"""Show a beautiful approval panel with arrow-key selector.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
title: Title for the panel (e.g., "File Operation", "Shell Command")
|
|
1034
|
+
content: Main content to display (Rich Text object or string)
|
|
1035
|
+
preview: Optional preview content (like a diff)
|
|
1036
|
+
border_style: Border color/style for the panel
|
|
1037
|
+
puppy_name: Name of the assistant (defaults to config value)
|
|
1038
|
+
|
|
1039
|
+
Returns:
|
|
1040
|
+
Tuple of (confirmed: bool, user_feedback: str | None)
|
|
1041
|
+
- confirmed: True if approved, False if rejected
|
|
1042
|
+
- user_feedback: Optional feedback text if user provided it
|
|
1043
|
+
"""
|
|
1044
|
+
import time
|
|
1045
|
+
|
|
1046
|
+
from code_puppy.tools.command_runner import set_awaiting_user_input
|
|
1047
|
+
|
|
1048
|
+
if puppy_name is None:
|
|
1049
|
+
from code_puppy.config import get_puppy_name
|
|
1050
|
+
|
|
1051
|
+
puppy_name = get_puppy_name().title()
|
|
1052
|
+
|
|
1053
|
+
# Build panel content
|
|
1054
|
+
if isinstance(content, str):
|
|
1055
|
+
panel_content = Text(content)
|
|
1056
|
+
else:
|
|
1057
|
+
panel_content = content
|
|
1058
|
+
|
|
1059
|
+
# Add preview if provided
|
|
1060
|
+
if preview:
|
|
1061
|
+
panel_content.append("\n\n", style="")
|
|
1062
|
+
panel_content.append("Preview of changes:", style="bold underline")
|
|
1063
|
+
panel_content.append("\n", style="")
|
|
1064
|
+
formatted_preview = format_diff_with_colors(preview)
|
|
1065
|
+
|
|
1066
|
+
# Handle both string (text mode) and Text object (highlight mode)
|
|
1067
|
+
if isinstance(formatted_preview, Text):
|
|
1068
|
+
preview_text = formatted_preview
|
|
1069
|
+
else:
|
|
1070
|
+
preview_text = Text.from_markup(formatted_preview)
|
|
1071
|
+
|
|
1072
|
+
panel_content.append(preview_text)
|
|
1073
|
+
|
|
1074
|
+
# Mark that we showed a diff preview
|
|
1075
|
+
try:
|
|
1076
|
+
from code_puppy.plugins.file_permission_handler.register_callbacks import (
|
|
1077
|
+
set_diff_already_shown,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
set_diff_already_shown(True)
|
|
1081
|
+
except ImportError:
|
|
1082
|
+
pass
|
|
1083
|
+
|
|
1084
|
+
# Create panel
|
|
1085
|
+
panel = Panel(
|
|
1086
|
+
panel_content,
|
|
1087
|
+
title=f"[bold white]{title}[/bold white]",
|
|
1088
|
+
border_style=border_style,
|
|
1089
|
+
padding=(1, 2),
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
# Pause spinners BEFORE showing panel
|
|
1093
|
+
set_awaiting_user_input(True)
|
|
1094
|
+
# Also explicitly pause spinners to ensure they're fully stopped
|
|
1095
|
+
try:
|
|
1096
|
+
from code_puppy.messaging.spinner import pause_all_spinners
|
|
1097
|
+
|
|
1098
|
+
pause_all_spinners()
|
|
1099
|
+
except (ImportError, Exception):
|
|
1100
|
+
pass
|
|
1101
|
+
|
|
1102
|
+
time.sleep(0.3) # Let spinners fully stop
|
|
1103
|
+
|
|
1104
|
+
# Display panel
|
|
1105
|
+
local_console = Console()
|
|
1106
|
+
emit_info("")
|
|
1107
|
+
local_console.print(panel)
|
|
1108
|
+
emit_info("")
|
|
1109
|
+
|
|
1110
|
+
# Flush and buffer before selector
|
|
1111
|
+
sys.stdout.flush()
|
|
1112
|
+
sys.stderr.flush()
|
|
1113
|
+
time.sleep(0.1)
|
|
1114
|
+
|
|
1115
|
+
user_feedback = None
|
|
1116
|
+
confirmed = False
|
|
1117
|
+
|
|
1118
|
+
try:
|
|
1119
|
+
# Final flush
|
|
1120
|
+
sys.stdout.flush()
|
|
1121
|
+
|
|
1122
|
+
# Show arrow-key selector
|
|
1123
|
+
choice = arrow_select(
|
|
1124
|
+
"💭 What would you like to do?",
|
|
1125
|
+
[
|
|
1126
|
+
"✓ Approve",
|
|
1127
|
+
"✗ Reject",
|
|
1128
|
+
f"💬 Reject with feedback (tell {puppy_name} what to change)",
|
|
1129
|
+
],
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
if choice == "✓ Approve":
|
|
1133
|
+
confirmed = True
|
|
1134
|
+
elif choice == "✗ Reject":
|
|
1135
|
+
confirmed = False
|
|
1136
|
+
else:
|
|
1137
|
+
# User wants to provide feedback
|
|
1138
|
+
confirmed = False
|
|
1139
|
+
emit_info("")
|
|
1140
|
+
emit_info(f"Tell {puppy_name} what to change:")
|
|
1141
|
+
user_feedback = Prompt.ask(
|
|
1142
|
+
"[bold green]➤[/bold green]",
|
|
1143
|
+
default="",
|
|
1144
|
+
).strip()
|
|
1145
|
+
|
|
1146
|
+
if not user_feedback:
|
|
1147
|
+
user_feedback = None
|
|
1148
|
+
|
|
1149
|
+
except (KeyboardInterrupt, EOFError):
|
|
1150
|
+
emit_error("Cancelled by user")
|
|
1151
|
+
confirmed = False
|
|
1152
|
+
|
|
1153
|
+
finally:
|
|
1154
|
+
set_awaiting_user_input(False)
|
|
1155
|
+
|
|
1156
|
+
# Force Rich console to reset display state to prevent artifacts
|
|
1157
|
+
try:
|
|
1158
|
+
# Clear Rich's internal display state to prevent artifacts
|
|
1159
|
+
local_console.file.write("\r") # Return to start of line
|
|
1160
|
+
local_console.file.write("\x1b[K") # Clear current line
|
|
1161
|
+
local_console.file.flush()
|
|
1162
|
+
except Exception:
|
|
1163
|
+
pass
|
|
1164
|
+
|
|
1165
|
+
# Ensure streams are flushed
|
|
1166
|
+
sys.stdout.flush()
|
|
1167
|
+
sys.stderr.flush()
|
|
1168
|
+
|
|
1169
|
+
# Show result BEFORE resuming spinners (no puppy litter!)
|
|
1170
|
+
emit_info("")
|
|
1171
|
+
if not confirmed:
|
|
1172
|
+
if user_feedback:
|
|
1173
|
+
emit_error("Rejected with feedback!")
|
|
1174
|
+
emit_warning(f'Telling {puppy_name}: "{user_feedback}"')
|
|
1175
|
+
else:
|
|
1176
|
+
emit_error("Rejected.")
|
|
1177
|
+
else:
|
|
1178
|
+
emit_success("Approved!")
|
|
1179
|
+
|
|
1180
|
+
# NOW resume spinners after showing the result
|
|
1181
|
+
try:
|
|
1182
|
+
from code_puppy.messaging.spinner import resume_all_spinners
|
|
1183
|
+
|
|
1184
|
+
resume_all_spinners()
|
|
1185
|
+
except (ImportError, Exception):
|
|
1186
|
+
pass
|
|
1187
|
+
|
|
1188
|
+
return confirmed, user_feedback
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
async def get_user_approval_async(
|
|
1192
|
+
title: str,
|
|
1193
|
+
content: Text | str,
|
|
1194
|
+
preview: str | None = None,
|
|
1195
|
+
border_style: str = "dim white",
|
|
1196
|
+
puppy_name: str | None = None,
|
|
1197
|
+
) -> tuple[bool, str | None]:
|
|
1198
|
+
"""Async version of get_user_approval - show a beautiful approval panel with arrow-key selector.
|
|
1199
|
+
|
|
1200
|
+
Args:
|
|
1201
|
+
title: Title for the panel (e.g., "File Operation", "Shell Command")
|
|
1202
|
+
content: Main content to display (Rich Text object or string)
|
|
1203
|
+
preview: Optional preview content (like a diff)
|
|
1204
|
+
border_style: Border color/style for the panel
|
|
1205
|
+
puppy_name: Name of the assistant (defaults to config value)
|
|
1206
|
+
|
|
1207
|
+
Returns:
|
|
1208
|
+
Tuple of (confirmed: bool, user_feedback: str | None)
|
|
1209
|
+
- confirmed: True if approved, False if rejected
|
|
1210
|
+
- user_feedback: Optional feedback text if user provided it
|
|
1211
|
+
"""
|
|
1212
|
+
import asyncio
|
|
1213
|
+
|
|
1214
|
+
from code_puppy.tools.command_runner import set_awaiting_user_input
|
|
1215
|
+
|
|
1216
|
+
if puppy_name is None:
|
|
1217
|
+
from code_puppy.config import get_puppy_name
|
|
1218
|
+
|
|
1219
|
+
puppy_name = get_puppy_name().title()
|
|
1220
|
+
|
|
1221
|
+
# Build panel content
|
|
1222
|
+
if isinstance(content, str):
|
|
1223
|
+
panel_content = Text(content)
|
|
1224
|
+
else:
|
|
1225
|
+
panel_content = content
|
|
1226
|
+
|
|
1227
|
+
# Add preview if provided
|
|
1228
|
+
if preview:
|
|
1229
|
+
panel_content.append("\n\n", style="")
|
|
1230
|
+
panel_content.append("Preview of changes:", style="bold underline")
|
|
1231
|
+
panel_content.append("\n", style="")
|
|
1232
|
+
formatted_preview = format_diff_with_colors(preview)
|
|
1233
|
+
|
|
1234
|
+
# Handle both string (text mode) and Text object (highlight mode)
|
|
1235
|
+
if isinstance(formatted_preview, Text):
|
|
1236
|
+
preview_text = formatted_preview
|
|
1237
|
+
else:
|
|
1238
|
+
preview_text = Text.from_markup(formatted_preview)
|
|
1239
|
+
|
|
1240
|
+
panel_content.append(preview_text)
|
|
1241
|
+
|
|
1242
|
+
# Mark that we showed a diff preview
|
|
1243
|
+
try:
|
|
1244
|
+
from code_puppy.plugins.file_permission_handler.register_callbacks import (
|
|
1245
|
+
set_diff_already_shown,
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
set_diff_already_shown(True)
|
|
1249
|
+
except ImportError:
|
|
1250
|
+
pass
|
|
1251
|
+
|
|
1252
|
+
# Create panel
|
|
1253
|
+
panel = Panel(
|
|
1254
|
+
panel_content,
|
|
1255
|
+
title=f"[bold white]{title}[/bold white]",
|
|
1256
|
+
border_style=border_style,
|
|
1257
|
+
padding=(1, 2),
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
# Pause spinners BEFORE showing panel
|
|
1261
|
+
set_awaiting_user_input(True)
|
|
1262
|
+
# Also explicitly pause spinners to ensure they're fully stopped
|
|
1263
|
+
try:
|
|
1264
|
+
from code_puppy.messaging.spinner import pause_all_spinners
|
|
1265
|
+
|
|
1266
|
+
pause_all_spinners()
|
|
1267
|
+
except (ImportError, Exception):
|
|
1268
|
+
pass
|
|
1269
|
+
|
|
1270
|
+
await asyncio.sleep(0.3) # Let spinners fully stop
|
|
1271
|
+
|
|
1272
|
+
# Display panel
|
|
1273
|
+
local_console = Console()
|
|
1274
|
+
emit_info("")
|
|
1275
|
+
local_console.print(panel)
|
|
1276
|
+
emit_info("")
|
|
1277
|
+
|
|
1278
|
+
# Flush and buffer before selector
|
|
1279
|
+
sys.stdout.flush()
|
|
1280
|
+
sys.stderr.flush()
|
|
1281
|
+
await asyncio.sleep(0.1)
|
|
1282
|
+
|
|
1283
|
+
user_feedback = None
|
|
1284
|
+
confirmed = False
|
|
1285
|
+
|
|
1286
|
+
try:
|
|
1287
|
+
# Final flush
|
|
1288
|
+
sys.stdout.flush()
|
|
1289
|
+
|
|
1290
|
+
# Show arrow-key selector (ASYNC VERSION)
|
|
1291
|
+
choice = await arrow_select_async(
|
|
1292
|
+
"💭 What would you like to do?",
|
|
1293
|
+
[
|
|
1294
|
+
"✓ Approve",
|
|
1295
|
+
"✗ Reject",
|
|
1296
|
+
f"💬 Reject with feedback (tell {puppy_name} what to change)",
|
|
1297
|
+
],
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
if choice == "✓ Approve":
|
|
1301
|
+
confirmed = True
|
|
1302
|
+
elif choice == "✗ Reject":
|
|
1303
|
+
confirmed = False
|
|
1304
|
+
else:
|
|
1305
|
+
# User wants to provide feedback
|
|
1306
|
+
confirmed = False
|
|
1307
|
+
emit_info("")
|
|
1308
|
+
emit_info(f"Tell {puppy_name} what to change:")
|
|
1309
|
+
user_feedback = Prompt.ask(
|
|
1310
|
+
"[bold green]➤[/bold green]",
|
|
1311
|
+
default="",
|
|
1312
|
+
).strip()
|
|
1313
|
+
|
|
1314
|
+
if not user_feedback:
|
|
1315
|
+
user_feedback = None
|
|
1316
|
+
|
|
1317
|
+
except (KeyboardInterrupt, EOFError):
|
|
1318
|
+
emit_error("Cancelled by user")
|
|
1319
|
+
confirmed = False
|
|
1320
|
+
|
|
1321
|
+
finally:
|
|
1322
|
+
set_awaiting_user_input(False)
|
|
1323
|
+
|
|
1324
|
+
# Force Rich console to reset display state to prevent artifacts
|
|
1325
|
+
try:
|
|
1326
|
+
# Clear Rich's internal display state to prevent artifacts
|
|
1327
|
+
local_console.file.write("\r") # Return to start of line
|
|
1328
|
+
local_console.file.write("\x1b[K") # Clear current line
|
|
1329
|
+
local_console.file.flush()
|
|
1330
|
+
except Exception:
|
|
1331
|
+
pass
|
|
1332
|
+
|
|
1333
|
+
# Ensure streams are flushed
|
|
1334
|
+
sys.stdout.flush()
|
|
1335
|
+
sys.stderr.flush()
|
|
1336
|
+
|
|
1337
|
+
# Show result BEFORE resuming spinners (no puppy litter!)
|
|
1338
|
+
emit_info("")
|
|
1339
|
+
if not confirmed:
|
|
1340
|
+
if user_feedback:
|
|
1341
|
+
emit_error("Rejected with feedback!")
|
|
1342
|
+
emit_warning(f'Telling {puppy_name}: "{user_feedback}"')
|
|
1343
|
+
else:
|
|
1344
|
+
emit_error("Rejected.")
|
|
1345
|
+
else:
|
|
1346
|
+
emit_success("Approved!")
|
|
1347
|
+
|
|
1348
|
+
# NOW resume spinners after showing the result
|
|
1349
|
+
try:
|
|
1350
|
+
from code_puppy.messaging.spinner import resume_all_spinners
|
|
1351
|
+
|
|
1352
|
+
resume_all_spinners()
|
|
1353
|
+
except (ImportError, Exception):
|
|
1354
|
+
pass
|
|
1355
|
+
|
|
1356
|
+
return confirmed, user_feedback
|
|
1357
|
+
|
|
1358
|
+
|
|
392
1359
|
def _find_best_window(
|
|
393
1360
|
haystack_lines: list[str],
|
|
394
1361
|
needle: str,
|
|
@@ -413,9 +1380,10 @@ def _find_best_window(
|
|
|
413
1380
|
best_span = (i, i + win_size)
|
|
414
1381
|
best_window = window
|
|
415
1382
|
|
|
416
|
-
|
|
417
|
-
console.log(
|
|
418
|
-
console.log(
|
|
1383
|
+
# Debug logging
|
|
1384
|
+
console.log(best_span)
|
|
1385
|
+
console.log(best_window)
|
|
1386
|
+
console.log(best_score)
|
|
419
1387
|
return best_span, best_score
|
|
420
1388
|
|
|
421
1389
|
|
|
@@ -441,3 +1409,175 @@ def generate_group_id(tool_name: str, extra_context: str = "") -> str:
|
|
|
441
1409
|
short_hash = hash_obj.hexdigest()[:8]
|
|
442
1410
|
|
|
443
1411
|
return f"{tool_name}_{short_hash}"
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
# =============================================================================
|
|
1415
|
+
# TOOL CALLBACK WRAPPER
|
|
1416
|
+
# =============================================================================
|
|
1417
|
+
|
|
1418
|
+
logger = logging.getLogger(__name__)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def with_tool_callbacks(tool_name: str) -> Callable:
|
|
1422
|
+
"""Decorator that wraps tool functions with pre/post callback hooks.
|
|
1423
|
+
|
|
1424
|
+
This decorator enables plugins to hook into tool execution for:
|
|
1425
|
+
- Logging and analytics
|
|
1426
|
+
- Pre-execution validation or modification
|
|
1427
|
+
- Post-execution result processing
|
|
1428
|
+
- Performance monitoring
|
|
1429
|
+
|
|
1430
|
+
Args:
|
|
1431
|
+
tool_name: The name of the tool being wrapped (e.g., 'edit_file', 'list_files')
|
|
1432
|
+
|
|
1433
|
+
Returns:
|
|
1434
|
+
A decorator function that wraps the tool with callbacks.
|
|
1435
|
+
|
|
1436
|
+
Example:
|
|
1437
|
+
@with_tool_callbacks('my_tool')
|
|
1438
|
+
async def my_tool_impl(ctx, **kwargs):
|
|
1439
|
+
return result
|
|
1440
|
+
"""
|
|
1441
|
+
|
|
1442
|
+
def decorator(func: Callable) -> Callable:
|
|
1443
|
+
@functools.wraps(func)
|
|
1444
|
+
async def async_wrapper(*args, **kwargs) -> Any:
|
|
1445
|
+
# Extract context from args if available (usually first arg is RunContext)
|
|
1446
|
+
context = None
|
|
1447
|
+
tool_args = kwargs.copy()
|
|
1448
|
+
|
|
1449
|
+
# Try to get session context
|
|
1450
|
+
try:
|
|
1451
|
+
from code_puppy.messaging import get_session_context
|
|
1452
|
+
|
|
1453
|
+
context = get_session_context()
|
|
1454
|
+
except ImportError:
|
|
1455
|
+
pass
|
|
1456
|
+
|
|
1457
|
+
# Fire pre-tool callback (non-blocking)
|
|
1458
|
+
try:
|
|
1459
|
+
from code_puppy import callbacks
|
|
1460
|
+
|
|
1461
|
+
asyncio.create_task(
|
|
1462
|
+
callbacks.on_pre_tool_call(tool_name, tool_args, context)
|
|
1463
|
+
)
|
|
1464
|
+
except ImportError:
|
|
1465
|
+
logger.debug("callbacks module not available for pre_tool_call")
|
|
1466
|
+
except Exception as e:
|
|
1467
|
+
logger.debug(f"Error in pre_tool_call callback: {e}")
|
|
1468
|
+
|
|
1469
|
+
# Execute the tool and measure duration
|
|
1470
|
+
start_time = time.perf_counter()
|
|
1471
|
+
result = None
|
|
1472
|
+
error = None
|
|
1473
|
+
|
|
1474
|
+
try:
|
|
1475
|
+
result = await func(*args, **kwargs)
|
|
1476
|
+
return result
|
|
1477
|
+
except Exception as e:
|
|
1478
|
+
error = e
|
|
1479
|
+
raise
|
|
1480
|
+
finally:
|
|
1481
|
+
end_time = time.perf_counter()
|
|
1482
|
+
duration_ms = (end_time - start_time) * 1000
|
|
1483
|
+
|
|
1484
|
+
# Fire post-tool callback (non-blocking)
|
|
1485
|
+
final_result = result if error is None else {"error": str(error)}
|
|
1486
|
+
try:
|
|
1487
|
+
from code_puppy import callbacks
|
|
1488
|
+
|
|
1489
|
+
asyncio.create_task(
|
|
1490
|
+
callbacks.on_post_tool_call(
|
|
1491
|
+
tool_name, tool_args, final_result, duration_ms, context
|
|
1492
|
+
)
|
|
1493
|
+
)
|
|
1494
|
+
except ImportError:
|
|
1495
|
+
logger.debug("callbacks module not available for post_tool_call")
|
|
1496
|
+
except Exception as e:
|
|
1497
|
+
logger.debug(f"Error in post_tool_call callback: {e}")
|
|
1498
|
+
|
|
1499
|
+
@functools.wraps(func)
|
|
1500
|
+
def sync_wrapper(*args, **kwargs) -> Any:
|
|
1501
|
+
"""Sync wrapper for non-async tool functions."""
|
|
1502
|
+
# Extract context
|
|
1503
|
+
context = None
|
|
1504
|
+
tool_args = kwargs.copy()
|
|
1505
|
+
|
|
1506
|
+
try:
|
|
1507
|
+
from code_puppy.messaging import get_session_context
|
|
1508
|
+
|
|
1509
|
+
context = get_session_context()
|
|
1510
|
+
except ImportError:
|
|
1511
|
+
pass
|
|
1512
|
+
|
|
1513
|
+
# For sync functions, we can't use asyncio.create_task directly
|
|
1514
|
+
# Instead, we'll try to schedule it if there's a running loop
|
|
1515
|
+
def fire_pre_callback():
|
|
1516
|
+
try:
|
|
1517
|
+
from code_puppy import callbacks
|
|
1518
|
+
|
|
1519
|
+
loop = asyncio.get_running_loop()
|
|
1520
|
+
asyncio.run_coroutine_threadsafe(
|
|
1521
|
+
callbacks.on_pre_tool_call(tool_name, tool_args, context),
|
|
1522
|
+
loop,
|
|
1523
|
+
)
|
|
1524
|
+
except RuntimeError:
|
|
1525
|
+
# No running loop - skip async callback
|
|
1526
|
+
pass
|
|
1527
|
+
except ImportError:
|
|
1528
|
+
pass
|
|
1529
|
+
except Exception as e:
|
|
1530
|
+
logger.debug(f"Error in sync pre_tool_call: {e}")
|
|
1531
|
+
|
|
1532
|
+
fire_pre_callback()
|
|
1533
|
+
|
|
1534
|
+
# Execute the tool
|
|
1535
|
+
start_time = time.perf_counter()
|
|
1536
|
+
result = None
|
|
1537
|
+
error = None
|
|
1538
|
+
|
|
1539
|
+
try:
|
|
1540
|
+
result = func(*args, **kwargs)
|
|
1541
|
+
return result
|
|
1542
|
+
except Exception as e:
|
|
1543
|
+
error = e
|
|
1544
|
+
raise
|
|
1545
|
+
finally:
|
|
1546
|
+
end_time = time.perf_counter()
|
|
1547
|
+
duration_ms = (end_time - start_time) * 1000
|
|
1548
|
+
|
|
1549
|
+
# Fire post-tool callback
|
|
1550
|
+
final_result = result if error is None else {"error": str(error)}
|
|
1551
|
+
|
|
1552
|
+
def fire_post_callback():
|
|
1553
|
+
try:
|
|
1554
|
+
from code_puppy import callbacks
|
|
1555
|
+
|
|
1556
|
+
loop = asyncio.get_running_loop()
|
|
1557
|
+
asyncio.run_coroutine_threadsafe(
|
|
1558
|
+
callbacks.on_post_tool_call(
|
|
1559
|
+
tool_name,
|
|
1560
|
+
tool_args,
|
|
1561
|
+
final_result,
|
|
1562
|
+
duration_ms,
|
|
1563
|
+
context,
|
|
1564
|
+
),
|
|
1565
|
+
loop,
|
|
1566
|
+
)
|
|
1567
|
+
except RuntimeError:
|
|
1568
|
+
# No running loop - skip async callback
|
|
1569
|
+
pass
|
|
1570
|
+
except ImportError:
|
|
1571
|
+
pass
|
|
1572
|
+
except Exception as e:
|
|
1573
|
+
logger.debug(f"Error in sync post_tool_call: {e}")
|
|
1574
|
+
|
|
1575
|
+
fire_post_callback()
|
|
1576
|
+
|
|
1577
|
+
# Return appropriate wrapper based on function type
|
|
1578
|
+
if asyncio.iscoroutinefunction(func):
|
|
1579
|
+
return async_wrapper
|
|
1580
|
+
else:
|
|
1581
|
+
return sync_wrapper
|
|
1582
|
+
|
|
1583
|
+
return decorator
|