code-puppy 0.0.214__py3-none-any.whl → 0.0.366__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +2 -0
- code_puppy/agents/agent_c_reviewer.py +59 -6
- code_puppy/agents/agent_code_puppy.py +7 -1
- code_puppy/agents/agent_code_reviewer.py +12 -2
- code_puppy/agents/agent_cpp_reviewer.py +73 -6
- code_puppy/agents/agent_creator_agent.py +45 -4
- code_puppy/agents/agent_golang_reviewer.py +92 -3
- code_puppy/agents/agent_javascript_reviewer.py +101 -8
- code_puppy/agents/agent_manager.py +81 -4
- 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 +28 -6
- code_puppy/agents/agent_qa_expert.py +98 -6
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_security_auditor.py +113 -3
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +106 -7
- code_puppy/agents/base_agent.py +802 -176
- code_puppy/agents/event_stream_handler.py +350 -0
- 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 +142 -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 +10 -5
- 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 +176 -738
- 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 +0 -3
- 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 +15 -26
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +2 -2
- 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 +16 -10
- code_puppy/command_line/mcp/start_all_command.py +18 -6
- code_puppy/command_line/mcp/start_command.py +47 -25
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +7 -1
- code_puppy/command_line/mcp/stop_command.py +8 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +75 -25
- 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 +463 -63
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +898 -112
- 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 +210 -148
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -698
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/blocking_startup.py +70 -43
- code_puppy/mcp_/captured_stdio_server.py +2 -2
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +65 -38
- code_puppy/mcp_/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/mcp_/server_registry_catalog.py +24 -5
- 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 +21 -5
- code_puppy/messaging/spinner/console_spinner.py +86 -51
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +634 -83
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +66 -68
- 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 +2 -2
- 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 +9 -12
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +41 -13
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +536 -52
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +19 -23
- code_puppy/tools/browser/browser_interactions.py +41 -48
- code_puppy/tools/browser/browser_locators.py +36 -38
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +16 -16
- code_puppy/tools/browser/browser_screenshot.py +79 -143
- code_puppy/tools/browser/browser_scripts.py +32 -42
- code_puppy/tools/browser/browser_workflows.py +44 -27
- 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 +930 -147
- code_puppy/tools/common.py +1113 -5
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +226 -154
- 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.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/messaging/spinner/textual_spinner.py +0 -106
- code_puppy/tools/browser/camoufox_manager.py +0 -216
- code_puppy/tools/browser/vqa_agent.py +0 -70
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -1105
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -551
- 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 -185
- 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 -17
- code_puppy/tui/screens/autosave_picker.py +0 -175
- 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 -306
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
- code_puppy-0.0.214.dist-info/RECORD +0 -131
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
"""Rich console renderer for structured messages.
|
|
2
|
+
|
|
3
|
+
This module implements the presentation layer for Code Puppy's messaging system.
|
|
4
|
+
It consumes structured messages from the MessageBus and renders them using Rich.
|
|
5
|
+
|
|
6
|
+
The renderer is responsible for ALL presentation decisions - the messages contain
|
|
7
|
+
only structured data with no formatting hints.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Dict, Optional, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.markup import escape as escape_rich_markup
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.rule import Rule
|
|
17
|
+
|
|
18
|
+
# Note: Syntax import removed - file content not displayed, only header
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from code_puppy.config import get_subagent_verbose
|
|
22
|
+
from code_puppy.tools.common import format_diff_with_colors
|
|
23
|
+
from code_puppy.tools.subagent_context import is_subagent
|
|
24
|
+
|
|
25
|
+
from .bus import MessageBus
|
|
26
|
+
from .commands import (
|
|
27
|
+
ConfirmationResponse,
|
|
28
|
+
SelectionResponse,
|
|
29
|
+
UserInputResponse,
|
|
30
|
+
)
|
|
31
|
+
from .messages import (
|
|
32
|
+
AgentReasoningMessage,
|
|
33
|
+
AgentResponseMessage,
|
|
34
|
+
AnyMessage,
|
|
35
|
+
ConfirmationRequest,
|
|
36
|
+
DiffMessage,
|
|
37
|
+
DividerMessage,
|
|
38
|
+
FileContentMessage,
|
|
39
|
+
FileListingMessage,
|
|
40
|
+
GrepResultMessage,
|
|
41
|
+
MessageLevel,
|
|
42
|
+
SelectionRequest,
|
|
43
|
+
ShellLineMessage,
|
|
44
|
+
ShellOutputMessage,
|
|
45
|
+
ShellStartMessage,
|
|
46
|
+
SpinnerControl,
|
|
47
|
+
StatusPanelMessage,
|
|
48
|
+
SubAgentInvocationMessage,
|
|
49
|
+
SubAgentResponseMessage,
|
|
50
|
+
TextMessage,
|
|
51
|
+
UserInputRequest,
|
|
52
|
+
VersionCheckMessage,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Note: Text and Tree were removed - no longer used in this implementation
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# Renderer Protocol
|
|
60
|
+
# =============================================================================
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class RendererProtocol(Protocol):
|
|
65
|
+
"""Protocol defining the interface for message renderers."""
|
|
66
|
+
|
|
67
|
+
async def render(self, message: AnyMessage) -> None:
|
|
68
|
+
"""Render a single message."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
async def start(self) -> None:
|
|
72
|
+
"""Start the renderer (begin consuming messages)."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
async def stop(self) -> None:
|
|
76
|
+
"""Stop the renderer."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# =============================================================================
|
|
81
|
+
# Default Styles
|
|
82
|
+
# =============================================================================
|
|
83
|
+
|
|
84
|
+
DEFAULT_STYLES: Dict[MessageLevel, str] = {
|
|
85
|
+
MessageLevel.ERROR: "bold red",
|
|
86
|
+
MessageLevel.WARNING: "yellow",
|
|
87
|
+
MessageLevel.SUCCESS: "green",
|
|
88
|
+
MessageLevel.INFO: "white",
|
|
89
|
+
MessageLevel.DEBUG: "dim",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
DIFF_STYLES = {
|
|
93
|
+
"add": "green",
|
|
94
|
+
"remove": "red",
|
|
95
|
+
"context": "dim",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# =============================================================================
|
|
100
|
+
# Rich Console Renderer
|
|
101
|
+
# =============================================================================
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RichConsoleRenderer:
|
|
105
|
+
"""Rich console implementation of the renderer protocol.
|
|
106
|
+
|
|
107
|
+
This renderer consumes messages from a MessageBus and renders them using Rich.
|
|
108
|
+
It uses a background thread for synchronous compatibility with the main loop.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
bus: MessageBus,
|
|
114
|
+
console: Optional[Console] = None,
|
|
115
|
+
styles: Optional[Dict[MessageLevel, str]] = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Initialize the renderer.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
bus: The MessageBus to consume messages from.
|
|
121
|
+
console: Rich Console instance (creates default if None).
|
|
122
|
+
styles: Custom style mappings (uses DEFAULT_STYLES if None).
|
|
123
|
+
"""
|
|
124
|
+
import threading
|
|
125
|
+
|
|
126
|
+
self._bus = bus
|
|
127
|
+
self._console = console or Console()
|
|
128
|
+
self._styles = styles or DEFAULT_STYLES.copy()
|
|
129
|
+
self._running = False
|
|
130
|
+
self._thread: Optional[threading.Thread] = None
|
|
131
|
+
self._spinners: Dict[str, object] = {} # spinner_id -> status context
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def console(self) -> Console:
|
|
135
|
+
"""Get the Rich console."""
|
|
136
|
+
return self._console
|
|
137
|
+
|
|
138
|
+
def _get_banner_color(self, banner_name: str) -> str:
|
|
139
|
+
"""Get the configured color for a banner.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
banner_name: The banner identifier (e.g., 'thinking', 'shell_command')
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Rich color name for the banner background
|
|
146
|
+
"""
|
|
147
|
+
from code_puppy.config import get_banner_color
|
|
148
|
+
|
|
149
|
+
return get_banner_color(banner_name)
|
|
150
|
+
|
|
151
|
+
def _format_banner(self, banner_name: str, text: str) -> str:
|
|
152
|
+
"""Format a banner with its configured color.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
banner_name: The banner identifier
|
|
156
|
+
text: The banner text
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Rich markup string for the banner
|
|
160
|
+
"""
|
|
161
|
+
color = self._get_banner_color(banner_name)
|
|
162
|
+
return f"[bold white on {color}] {text} [/bold white on {color}]"
|
|
163
|
+
|
|
164
|
+
def _should_suppress_subagent_output(self) -> bool:
|
|
165
|
+
"""Check if sub-agent output should be suppressed.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True if we're in a sub-agent context and verbose mode is disabled
|
|
169
|
+
"""
|
|
170
|
+
return is_subagent() and not get_subagent_verbose()
|
|
171
|
+
|
|
172
|
+
# =========================================================================
|
|
173
|
+
# Lifecycle (Synchronous - for compatibility with main.py)
|
|
174
|
+
# =========================================================================
|
|
175
|
+
|
|
176
|
+
def start(self) -> None:
|
|
177
|
+
"""Start the renderer in a background thread.
|
|
178
|
+
|
|
179
|
+
This is synchronous to match the old SynchronousInteractiveRenderer API.
|
|
180
|
+
"""
|
|
181
|
+
import threading
|
|
182
|
+
|
|
183
|
+
if self._running:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
self._running = True
|
|
187
|
+
self._bus.mark_renderer_active()
|
|
188
|
+
|
|
189
|
+
# Start background thread for message consumption
|
|
190
|
+
self._thread = threading.Thread(target=self._consume_loop_sync, daemon=True)
|
|
191
|
+
self._thread.start()
|
|
192
|
+
|
|
193
|
+
def stop(self) -> None:
|
|
194
|
+
"""Stop the renderer.
|
|
195
|
+
|
|
196
|
+
This is synchronous to match the old SynchronousInteractiveRenderer API.
|
|
197
|
+
"""
|
|
198
|
+
self._running = False
|
|
199
|
+
self._bus.mark_renderer_inactive()
|
|
200
|
+
|
|
201
|
+
if self._thread and self._thread.is_alive():
|
|
202
|
+
self._thread.join(timeout=1.0)
|
|
203
|
+
self._thread = None
|
|
204
|
+
|
|
205
|
+
def _consume_loop_sync(self) -> None:
|
|
206
|
+
"""Synchronous message consumption loop running in background thread."""
|
|
207
|
+
import time
|
|
208
|
+
|
|
209
|
+
# First, process any buffered messages
|
|
210
|
+
for msg in self._bus.get_buffered_messages():
|
|
211
|
+
self._render_sync(msg)
|
|
212
|
+
self._bus.clear_buffer()
|
|
213
|
+
|
|
214
|
+
# Then consume new messages
|
|
215
|
+
while self._running:
|
|
216
|
+
message = self._bus.get_message_nowait()
|
|
217
|
+
if message:
|
|
218
|
+
self._render_sync(message)
|
|
219
|
+
else:
|
|
220
|
+
time.sleep(0.01)
|
|
221
|
+
|
|
222
|
+
def _render_sync(self, message: AnyMessage) -> None:
|
|
223
|
+
"""Render a message synchronously with error handling."""
|
|
224
|
+
try:
|
|
225
|
+
self._do_render(message)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
# Don't let rendering errors crash the loop
|
|
228
|
+
# Escape the error message to prevent nested markup errors
|
|
229
|
+
safe_error = escape_rich_markup(str(e))
|
|
230
|
+
self._console.print(f"[dim red]Render error: {safe_error}[/dim red]")
|
|
231
|
+
|
|
232
|
+
# =========================================================================
|
|
233
|
+
# Async Lifecycle (for future async-first usage)
|
|
234
|
+
# =========================================================================
|
|
235
|
+
|
|
236
|
+
async def start_async(self) -> None:
|
|
237
|
+
"""Start the renderer asynchronously."""
|
|
238
|
+
if self._running:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
self._running = True
|
|
242
|
+
self._bus.mark_renderer_active()
|
|
243
|
+
|
|
244
|
+
# Process any buffered messages first
|
|
245
|
+
for msg in self._bus.get_buffered_messages():
|
|
246
|
+
self._render_sync(msg)
|
|
247
|
+
self._bus.clear_buffer()
|
|
248
|
+
|
|
249
|
+
async def stop_async(self) -> None:
|
|
250
|
+
"""Stop the renderer asynchronously."""
|
|
251
|
+
self._running = False
|
|
252
|
+
self._bus.mark_renderer_inactive()
|
|
253
|
+
|
|
254
|
+
# =========================================================================
|
|
255
|
+
# Main Dispatch
|
|
256
|
+
# =========================================================================
|
|
257
|
+
|
|
258
|
+
def _do_render(self, message: AnyMessage) -> None:
|
|
259
|
+
"""Synchronously render a message by dispatching to the appropriate handler.
|
|
260
|
+
|
|
261
|
+
Note: User input requests are skipped in sync mode as they require async.
|
|
262
|
+
"""
|
|
263
|
+
# Dispatch based on message type
|
|
264
|
+
if isinstance(message, TextMessage):
|
|
265
|
+
self._render_text(message)
|
|
266
|
+
elif isinstance(message, FileListingMessage):
|
|
267
|
+
self._render_file_listing(message)
|
|
268
|
+
elif isinstance(message, FileContentMessage):
|
|
269
|
+
self._render_file_content(message)
|
|
270
|
+
elif isinstance(message, GrepResultMessage):
|
|
271
|
+
self._render_grep_result(message)
|
|
272
|
+
elif isinstance(message, DiffMessage):
|
|
273
|
+
self._render_diff(message)
|
|
274
|
+
elif isinstance(message, ShellStartMessage):
|
|
275
|
+
self._render_shell_start(message)
|
|
276
|
+
elif isinstance(message, ShellLineMessage):
|
|
277
|
+
self._render_shell_line(message)
|
|
278
|
+
elif isinstance(message, ShellOutputMessage):
|
|
279
|
+
self._render_shell_output(message)
|
|
280
|
+
elif isinstance(message, AgentReasoningMessage):
|
|
281
|
+
self._render_agent_reasoning(message)
|
|
282
|
+
elif isinstance(message, AgentResponseMessage):
|
|
283
|
+
# Skip rendering - we now stream agent responses via event_stream_handler
|
|
284
|
+
pass
|
|
285
|
+
elif isinstance(message, SubAgentInvocationMessage):
|
|
286
|
+
self._render_subagent_invocation(message)
|
|
287
|
+
elif isinstance(message, SubAgentResponseMessage):
|
|
288
|
+
# Skip rendering - we now display sub-agent responses via display_non_streamed_result
|
|
289
|
+
pass
|
|
290
|
+
elif isinstance(message, UserInputRequest):
|
|
291
|
+
# Can't handle async user input in sync context - skip
|
|
292
|
+
self._console.print("[dim]User input requested (requires async)[/dim]")
|
|
293
|
+
elif isinstance(message, ConfirmationRequest):
|
|
294
|
+
# Can't handle async confirmation in sync context - skip
|
|
295
|
+
self._console.print("[dim]Confirmation requested (requires async)[/dim]")
|
|
296
|
+
elif isinstance(message, SelectionRequest):
|
|
297
|
+
# Can't handle async selection in sync context - skip
|
|
298
|
+
self._console.print("[dim]Selection requested (requires async)[/dim]")
|
|
299
|
+
elif isinstance(message, SpinnerControl):
|
|
300
|
+
self._render_spinner_control(message)
|
|
301
|
+
elif isinstance(message, DividerMessage):
|
|
302
|
+
self._render_divider(message)
|
|
303
|
+
elif isinstance(message, StatusPanelMessage):
|
|
304
|
+
self._render_status_panel(message)
|
|
305
|
+
elif isinstance(message, VersionCheckMessage):
|
|
306
|
+
self._render_version_check(message)
|
|
307
|
+
else:
|
|
308
|
+
# Unknown message type - render as debug
|
|
309
|
+
self._console.print(f"[dim]Unknown message: {type(message).__name__}[/dim]")
|
|
310
|
+
|
|
311
|
+
async def render(self, message: AnyMessage) -> None:
|
|
312
|
+
"""Render a message asynchronously (supports user input requests)."""
|
|
313
|
+
# Handle async-only message types
|
|
314
|
+
if isinstance(message, UserInputRequest):
|
|
315
|
+
await self._render_user_input_request(message)
|
|
316
|
+
elif isinstance(message, ConfirmationRequest):
|
|
317
|
+
await self._render_confirmation_request(message)
|
|
318
|
+
elif isinstance(message, SelectionRequest):
|
|
319
|
+
await self._render_selection_request(message)
|
|
320
|
+
else:
|
|
321
|
+
# Use sync render for everything else
|
|
322
|
+
self._do_render(message)
|
|
323
|
+
|
|
324
|
+
# =========================================================================
|
|
325
|
+
# Text Messages
|
|
326
|
+
# =========================================================================
|
|
327
|
+
|
|
328
|
+
def _render_text(self, msg: TextMessage) -> None:
|
|
329
|
+
"""Render a text message with appropriate styling.
|
|
330
|
+
|
|
331
|
+
Text is escaped to prevent Rich markup injection which could crash
|
|
332
|
+
the renderer if malformed tags are present in shell output or other
|
|
333
|
+
user-provided content.
|
|
334
|
+
"""
|
|
335
|
+
style = self._styles.get(msg.level, "white")
|
|
336
|
+
|
|
337
|
+
# Make version messages dim
|
|
338
|
+
if "Current version:" in msg.text or "Latest version:" in msg.text:
|
|
339
|
+
style = "dim"
|
|
340
|
+
|
|
341
|
+
prefix = self._get_level_prefix(msg.level)
|
|
342
|
+
# Escape Rich markup to prevent crashes from malformed tags
|
|
343
|
+
safe_text = escape_rich_markup(msg.text)
|
|
344
|
+
self._console.print(f"{prefix}{safe_text}", style=style)
|
|
345
|
+
|
|
346
|
+
def _get_level_prefix(self, level: MessageLevel) -> str:
|
|
347
|
+
"""Get a prefix icon for the message level."""
|
|
348
|
+
prefixes = {
|
|
349
|
+
MessageLevel.ERROR: "✗ ",
|
|
350
|
+
MessageLevel.WARNING: "⚠ ",
|
|
351
|
+
MessageLevel.SUCCESS: "✓ ",
|
|
352
|
+
MessageLevel.INFO: "ℹ ",
|
|
353
|
+
MessageLevel.DEBUG: "• ",
|
|
354
|
+
}
|
|
355
|
+
return prefixes.get(level, "")
|
|
356
|
+
|
|
357
|
+
# =========================================================================
|
|
358
|
+
# File Operations
|
|
359
|
+
# =========================================================================
|
|
360
|
+
|
|
361
|
+
def _render_file_listing(self, msg: FileListingMessage) -> None:
|
|
362
|
+
"""Render a compact directory listing with directory summaries.
|
|
363
|
+
|
|
364
|
+
Instead of listing every file, we group by directory and show:
|
|
365
|
+
- Directory name
|
|
366
|
+
- Number of files
|
|
367
|
+
- Total size
|
|
368
|
+
- Number of subdirectories
|
|
369
|
+
"""
|
|
370
|
+
# Skip for sub-agents unless verbose mode
|
|
371
|
+
if self._should_suppress_subagent_output():
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
import os
|
|
375
|
+
from collections import defaultdict
|
|
376
|
+
|
|
377
|
+
# Header on single line
|
|
378
|
+
rec_flag = f"(recursive={msg.recursive})"
|
|
379
|
+
banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
|
|
380
|
+
self._console.print(
|
|
381
|
+
f"\n{banner} "
|
|
382
|
+
f"📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Build a tree structure: {parent_path: {files: [], dirs: set(), size: int}}
|
|
386
|
+
# Each key is a directory path, value contains direct children stats
|
|
387
|
+
dir_stats: dict = defaultdict(
|
|
388
|
+
lambda: {"files": [], "subdirs": set(), "total_size": 0}
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Root directory is represented as ""
|
|
392
|
+
root_key = ""
|
|
393
|
+
|
|
394
|
+
for entry in msg.files:
|
|
395
|
+
path = entry.path
|
|
396
|
+
parent = os.path.dirname(path) if os.path.dirname(path) else root_key
|
|
397
|
+
|
|
398
|
+
if entry.type == "dir":
|
|
399
|
+
# Register this dir as a subdir of its parent
|
|
400
|
+
dir_stats[parent]["subdirs"].add(path)
|
|
401
|
+
# Ensure the dir itself exists in stats (even if empty)
|
|
402
|
+
_ = dir_stats[path]
|
|
403
|
+
else:
|
|
404
|
+
# It's a file - add to parent's stats
|
|
405
|
+
dir_stats[parent]["files"].append(entry)
|
|
406
|
+
dir_stats[parent]["total_size"] += entry.size
|
|
407
|
+
|
|
408
|
+
def render_dir_tree(dir_path: str, depth: int = 0) -> None:
|
|
409
|
+
"""Recursively render directory with compact summary."""
|
|
410
|
+
stats = dir_stats.get(
|
|
411
|
+
dir_path, {"files": [], "subdirs": set(), "total_size": 0}
|
|
412
|
+
)
|
|
413
|
+
files = stats["files"]
|
|
414
|
+
subdirs = sorted(stats["subdirs"])
|
|
415
|
+
|
|
416
|
+
# Calculate total size including subdirectories (recursive)
|
|
417
|
+
def get_recursive_size(d: str) -> int:
|
|
418
|
+
s = dir_stats.get(d, {"files": [], "subdirs": set(), "total_size": 0})
|
|
419
|
+
size = s["total_size"]
|
|
420
|
+
for sub in s["subdirs"]:
|
|
421
|
+
size += get_recursive_size(sub)
|
|
422
|
+
return size
|
|
423
|
+
|
|
424
|
+
def get_recursive_file_count(d: str) -> int:
|
|
425
|
+
s = dir_stats.get(d, {"files": [], "subdirs": set(), "total_size": 0})
|
|
426
|
+
count = len(s["files"])
|
|
427
|
+
for sub in s["subdirs"]:
|
|
428
|
+
count += get_recursive_file_count(sub)
|
|
429
|
+
return count
|
|
430
|
+
|
|
431
|
+
indent = " " * depth
|
|
432
|
+
|
|
433
|
+
# For root level, just show contents
|
|
434
|
+
if dir_path == root_key:
|
|
435
|
+
# Show files at root level (depth 0)
|
|
436
|
+
for f in sorted(files, key=lambda x: x.path):
|
|
437
|
+
icon = self._get_file_icon(f.path)
|
|
438
|
+
name = os.path.basename(f.path)
|
|
439
|
+
size_str = (
|
|
440
|
+
f" [dim]({self._format_size(f.size)})[/dim]"
|
|
441
|
+
if f.size > 0
|
|
442
|
+
else ""
|
|
443
|
+
)
|
|
444
|
+
self._console.print(
|
|
445
|
+
f"{indent}{icon} [green]{name}[/green]{size_str}"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Show subdirs at root level
|
|
449
|
+
for subdir in subdirs:
|
|
450
|
+
render_dir_tree(subdir, depth)
|
|
451
|
+
else:
|
|
452
|
+
# Show directory with summary
|
|
453
|
+
dir_name = os.path.basename(dir_path)
|
|
454
|
+
rec_size = get_recursive_size(dir_path)
|
|
455
|
+
rec_file_count = get_recursive_file_count(dir_path)
|
|
456
|
+
subdir_count = len(subdirs)
|
|
457
|
+
|
|
458
|
+
# Build summary parts
|
|
459
|
+
parts = []
|
|
460
|
+
if rec_file_count > 0:
|
|
461
|
+
parts.append(
|
|
462
|
+
f"{rec_file_count} file{'s' if rec_file_count != 1 else ''}"
|
|
463
|
+
)
|
|
464
|
+
if subdir_count > 0:
|
|
465
|
+
parts.append(
|
|
466
|
+
f"{subdir_count} subdir{'s' if subdir_count != 1 else ''}"
|
|
467
|
+
)
|
|
468
|
+
if rec_size > 0:
|
|
469
|
+
parts.append(self._format_size(rec_size))
|
|
470
|
+
|
|
471
|
+
summary = f" [dim]({', '.join(parts)})[/dim]" if parts else ""
|
|
472
|
+
self._console.print(
|
|
473
|
+
f"{indent}📁 [bold blue]{dir_name}/[/bold blue]{summary}"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Recursively show subdirectories
|
|
477
|
+
for subdir in subdirs:
|
|
478
|
+
render_dir_tree(subdir, depth + 1)
|
|
479
|
+
|
|
480
|
+
# Render the tree starting from root
|
|
481
|
+
render_dir_tree(root_key, 0)
|
|
482
|
+
|
|
483
|
+
# Summary
|
|
484
|
+
self._console.print("\n[bold cyan]Summary:[/bold cyan]")
|
|
485
|
+
self._console.print(
|
|
486
|
+
f"📁 [blue]{msg.dir_count} directories[/blue], "
|
|
487
|
+
f"📄 [green]{msg.file_count} files[/green] "
|
|
488
|
+
f"[dim]({self._format_size(msg.total_size)} total)[/dim]"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def _render_file_content(self, msg: FileContentMessage) -> None:
|
|
492
|
+
"""Render a file read - just show the header, not the content.
|
|
493
|
+
|
|
494
|
+
The file content is for the LLM only, not for display in the UI.
|
|
495
|
+
"""
|
|
496
|
+
# Skip for sub-agents unless verbose mode
|
|
497
|
+
if self._should_suppress_subagent_output():
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
# Build line info
|
|
501
|
+
line_info = ""
|
|
502
|
+
if msg.start_line is not None and msg.num_lines is not None:
|
|
503
|
+
end_line = msg.start_line + msg.num_lines - 1
|
|
504
|
+
line_info = f" [dim](lines {msg.start_line}-{end_line})[/dim]"
|
|
505
|
+
|
|
506
|
+
# Just print the header - content is for LLM only
|
|
507
|
+
banner = self._format_banner("read_file", "READ FILE")
|
|
508
|
+
self._console.print(
|
|
509
|
+
f"\n{banner} 📂 [bold cyan]{msg.path}[/bold cyan]{line_info}"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def _render_grep_result(self, msg: GrepResultMessage) -> None:
|
|
513
|
+
"""Render grep results grouped by file matching old format."""
|
|
514
|
+
# Skip for sub-agents unless verbose mode
|
|
515
|
+
if self._should_suppress_subagent_output():
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
import re
|
|
519
|
+
|
|
520
|
+
# Header
|
|
521
|
+
banner = self._format_banner("grep", "GREP")
|
|
522
|
+
self._console.print(
|
|
523
|
+
f"\n{banner} 📂 [dim]{msg.directory} for '{msg.search_term}'[/dim]"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if not msg.matches:
|
|
527
|
+
self._console.print(
|
|
528
|
+
f"[dim]No matches found for '{msg.search_term}' "
|
|
529
|
+
f"in {msg.directory}[/dim]"
|
|
530
|
+
)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
# Group by file
|
|
534
|
+
by_file: Dict[str, list] = {}
|
|
535
|
+
for match in msg.matches:
|
|
536
|
+
by_file.setdefault(match.file_path, []).append(match)
|
|
537
|
+
|
|
538
|
+
# Show verbose or concise based on message flag
|
|
539
|
+
if msg.verbose:
|
|
540
|
+
# Verbose mode: Show full output with line numbers and content
|
|
541
|
+
for file_path in sorted(by_file.keys()):
|
|
542
|
+
file_matches = by_file[file_path]
|
|
543
|
+
match_word = "match" if len(file_matches) == 1 else "matches"
|
|
544
|
+
self._console.print(
|
|
545
|
+
f"\n[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Show each match with line number and content
|
|
549
|
+
for match in file_matches:
|
|
550
|
+
line = match.line_content
|
|
551
|
+
# Extract the actual search term (not ripgrep flags)
|
|
552
|
+
search_term = msg.search_term.split()[-1]
|
|
553
|
+
if search_term.startswith("-"):
|
|
554
|
+
parts = msg.search_term.split()
|
|
555
|
+
search_term = parts[0] if parts else msg.search_term
|
|
556
|
+
|
|
557
|
+
# Case-insensitive highlighting
|
|
558
|
+
if search_term and not search_term.startswith("-"):
|
|
559
|
+
highlighted_line = re.sub(
|
|
560
|
+
f"({re.escape(search_term)})",
|
|
561
|
+
r"[bold yellow]\1[/bold yellow]",
|
|
562
|
+
line,
|
|
563
|
+
flags=re.IGNORECASE,
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
highlighted_line = line
|
|
567
|
+
|
|
568
|
+
ln = match.line_number
|
|
569
|
+
self._console.print(f" [dim]{ln:4d}[/dim] │ {highlighted_line}")
|
|
570
|
+
else:
|
|
571
|
+
# Concise mode (default): Show only file summaries
|
|
572
|
+
self._console.print("")
|
|
573
|
+
for file_path in sorted(by_file.keys()):
|
|
574
|
+
file_matches = by_file[file_path]
|
|
575
|
+
match_word = "match" if len(file_matches) == 1 else "matches"
|
|
576
|
+
self._console.print(
|
|
577
|
+
f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# Summary - subtle
|
|
581
|
+
match_word = "match" if msg.total_matches == 1 else "matches"
|
|
582
|
+
file_word = "file" if len(by_file) == 1 else "files"
|
|
583
|
+
num_files = len(by_file)
|
|
584
|
+
self._console.print(
|
|
585
|
+
f"[dim]Found {msg.total_matches} {match_word} "
|
|
586
|
+
f"across {num_files} {file_word}[/dim]"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# Trailing newline for spinner separation
|
|
590
|
+
self._console.print()
|
|
591
|
+
|
|
592
|
+
# =========================================================================
|
|
593
|
+
# Diff
|
|
594
|
+
# =========================================================================
|
|
595
|
+
|
|
596
|
+
def _render_diff(self, msg: DiffMessage) -> None:
|
|
597
|
+
"""Render a diff with beautiful syntax highlighting."""
|
|
598
|
+
# Skip for sub-agents unless verbose mode
|
|
599
|
+
if self._should_suppress_subagent_output():
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
# Operation-specific styling
|
|
603
|
+
op_icons = {"create": "✨", "modify": "✏️", "delete": "🗑️"}
|
|
604
|
+
op_colors = {"create": "green", "modify": "yellow", "delete": "red"}
|
|
605
|
+
icon = op_icons.get(msg.operation, "📄")
|
|
606
|
+
op_color = op_colors.get(msg.operation, "white")
|
|
607
|
+
|
|
608
|
+
# Header on single line
|
|
609
|
+
banner = self._format_banner("edit_file", "EDIT FILE")
|
|
610
|
+
self._console.print(
|
|
611
|
+
f"\n{banner} "
|
|
612
|
+
f"{icon} [{op_color}]{msg.operation.upper()}[/{op_color}] "
|
|
613
|
+
f"[bold cyan]{msg.path}[/bold cyan]"
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
if not msg.diff_lines:
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
# Reconstruct unified diff text from diff_lines for format_diff_with_colors
|
|
620
|
+
diff_text_lines = []
|
|
621
|
+
for line in msg.diff_lines:
|
|
622
|
+
if line.type == "add":
|
|
623
|
+
diff_text_lines.append(f"+{line.content}")
|
|
624
|
+
elif line.type == "remove":
|
|
625
|
+
diff_text_lines.append(f"-{line.content}")
|
|
626
|
+
else: # context
|
|
627
|
+
# Don't add space prefix to diff headers - they need to be preserved
|
|
628
|
+
# exactly for syntax highlighting to detect the file extension
|
|
629
|
+
if line.content.startswith(("---", "+++", "@@", "diff ", "index ")):
|
|
630
|
+
diff_text_lines.append(line.content)
|
|
631
|
+
else:
|
|
632
|
+
diff_text_lines.append(f" {line.content}")
|
|
633
|
+
|
|
634
|
+
diff_text = "\n".join(diff_text_lines)
|
|
635
|
+
|
|
636
|
+
# Use the beautiful syntax-highlighted diff formatter
|
|
637
|
+
formatted_diff = format_diff_with_colors(diff_text)
|
|
638
|
+
self._console.print(formatted_diff)
|
|
639
|
+
|
|
640
|
+
# =========================================================================
|
|
641
|
+
# Shell Output
|
|
642
|
+
# =========================================================================
|
|
643
|
+
|
|
644
|
+
def _render_shell_start(self, msg: ShellStartMessage) -> None:
|
|
645
|
+
"""Render shell command start notification."""
|
|
646
|
+
# Skip for sub-agents unless verbose mode
|
|
647
|
+
if self._should_suppress_subagent_output():
|
|
648
|
+
return
|
|
649
|
+
|
|
650
|
+
# Escape command to prevent Rich markup injection
|
|
651
|
+
safe_command = escape_rich_markup(msg.command)
|
|
652
|
+
# Header showing command is starting
|
|
653
|
+
banner = self._format_banner("shell_command", "SHELL COMMAND")
|
|
654
|
+
|
|
655
|
+
# Add background indicator if running in background mode
|
|
656
|
+
if msg.background:
|
|
657
|
+
self._console.print(
|
|
658
|
+
f"\n{banner} 🚀 [dim]$ {safe_command}[/dim] [bold magenta][BACKGROUND 🌙][/bold magenta]"
|
|
659
|
+
)
|
|
660
|
+
else:
|
|
661
|
+
self._console.print(f"\n{banner} 🚀 [dim]$ {safe_command}[/dim]")
|
|
662
|
+
|
|
663
|
+
# Show working directory if specified
|
|
664
|
+
if msg.cwd:
|
|
665
|
+
safe_cwd = escape_rich_markup(msg.cwd)
|
|
666
|
+
self._console.print(f"[dim]📂 Working directory: {safe_cwd}[/dim]")
|
|
667
|
+
|
|
668
|
+
# Show timeout or background status
|
|
669
|
+
if msg.background:
|
|
670
|
+
self._console.print("[dim]⏱ Runs detached (no timeout)[/dim]")
|
|
671
|
+
else:
|
|
672
|
+
self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
|
|
673
|
+
|
|
674
|
+
def _render_shell_line(self, msg: ShellLineMessage) -> None:
|
|
675
|
+
"""Render shell output line preserving ANSI codes."""
|
|
676
|
+
from rich.text import Text
|
|
677
|
+
|
|
678
|
+
# Use Text.from_ansi() to parse ANSI codes into Rich styling
|
|
679
|
+
# This preserves colors while still being safe
|
|
680
|
+
text = Text.from_ansi(msg.line)
|
|
681
|
+
|
|
682
|
+
# Make all shell output dim to reduce visual noise
|
|
683
|
+
self._console.print(text, style="dim")
|
|
684
|
+
|
|
685
|
+
def _render_shell_output(self, msg: ShellOutputMessage) -> None:
|
|
686
|
+
"""Render shell command output - just a trailing newline for spinner separation.
|
|
687
|
+
|
|
688
|
+
Shell command results are already returned to the LLM via tool responses,
|
|
689
|
+
so we don't need to clutter the UI with redundant output.
|
|
690
|
+
"""
|
|
691
|
+
# Just print trailing newline for spinner separation
|
|
692
|
+
self._console.print()
|
|
693
|
+
|
|
694
|
+
# =========================================================================
|
|
695
|
+
# Agent Messages
|
|
696
|
+
# =========================================================================
|
|
697
|
+
|
|
698
|
+
def _render_agent_reasoning(self, msg: AgentReasoningMessage) -> None:
|
|
699
|
+
"""Render agent reasoning matching old format."""
|
|
700
|
+
# Header matching old format
|
|
701
|
+
banner = self._format_banner("agent_reasoning", "AGENT REASONING")
|
|
702
|
+
self._console.print(f"\n{banner}")
|
|
703
|
+
|
|
704
|
+
# Current reasoning
|
|
705
|
+
self._console.print("[bold cyan]Current reasoning:[/bold cyan]")
|
|
706
|
+
# Render reasoning as markdown
|
|
707
|
+
md = Markdown(msg.reasoning)
|
|
708
|
+
self._console.print(md)
|
|
709
|
+
|
|
710
|
+
# Next steps (if any)
|
|
711
|
+
if msg.next_steps and msg.next_steps.strip():
|
|
712
|
+
self._console.print("\n[bold cyan]Planned next steps:[/bold cyan]")
|
|
713
|
+
md_steps = Markdown(msg.next_steps)
|
|
714
|
+
self._console.print(md_steps)
|
|
715
|
+
|
|
716
|
+
# Trailing newline for spinner separation
|
|
717
|
+
self._console.print()
|
|
718
|
+
|
|
719
|
+
def _render_agent_response(self, msg: AgentResponseMessage) -> None:
|
|
720
|
+
"""Render agent response with header and markdown formatting."""
|
|
721
|
+
# Header
|
|
722
|
+
banner = self._format_banner("agent_response", "AGENT RESPONSE")
|
|
723
|
+
self._console.print(f"\n{banner}\n")
|
|
724
|
+
|
|
725
|
+
# Content (markdown or plain)
|
|
726
|
+
if msg.is_markdown:
|
|
727
|
+
md = Markdown(msg.content)
|
|
728
|
+
self._console.print(md)
|
|
729
|
+
else:
|
|
730
|
+
self._console.print(msg.content)
|
|
731
|
+
|
|
732
|
+
def _render_subagent_invocation(self, msg: SubAgentInvocationMessage) -> None:
|
|
733
|
+
"""Render sub-agent invocation header with nice formatting."""
|
|
734
|
+
# Skip for sub-agents unless verbose mode (avoid nested invocation banners)
|
|
735
|
+
if self._should_suppress_subagent_output():
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
# Header with agent name and session
|
|
739
|
+
session_type = (
|
|
740
|
+
"New session"
|
|
741
|
+
if msg.is_new_session
|
|
742
|
+
else f"Continuing ({msg.message_count} messages)"
|
|
743
|
+
)
|
|
744
|
+
banner = self._format_banner("invoke_agent", "🤖 INVOKE AGENT")
|
|
745
|
+
self._console.print(
|
|
746
|
+
f"\n{banner} "
|
|
747
|
+
f"[bold cyan]{msg.agent_name}[/bold cyan] "
|
|
748
|
+
f"[dim]({session_type})[/dim]"
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Session ID
|
|
752
|
+
self._console.print(f"[dim]Session:[/dim] [bold]{msg.session_id}[/bold]")
|
|
753
|
+
|
|
754
|
+
# Prompt (truncated if too long, rendered as markdown)
|
|
755
|
+
prompt_display = (
|
|
756
|
+
msg.prompt[:200] + "..." if len(msg.prompt) > 200 else msg.prompt
|
|
757
|
+
)
|
|
758
|
+
self._console.print("[dim]Prompt:[/dim]")
|
|
759
|
+
md_prompt = Markdown(prompt_display)
|
|
760
|
+
self._console.print(md_prompt)
|
|
761
|
+
|
|
762
|
+
def _render_subagent_response(self, msg: SubAgentResponseMessage) -> None:
|
|
763
|
+
"""Render sub-agent response with markdown formatting."""
|
|
764
|
+
# Response header
|
|
765
|
+
banner = self._format_banner("subagent_response", "✓ AGENT RESPONSE")
|
|
766
|
+
self._console.print(f"\n{banner} [bold cyan]{msg.agent_name}[/bold cyan]")
|
|
767
|
+
|
|
768
|
+
# Render response as markdown
|
|
769
|
+
md = Markdown(msg.response)
|
|
770
|
+
self._console.print(md)
|
|
771
|
+
|
|
772
|
+
# Footer with session info
|
|
773
|
+
self._console.print(
|
|
774
|
+
f"\n[dim]Session [bold]{msg.session_id}[/bold] saved "
|
|
775
|
+
f"({msg.message_count} messages)[/dim]"
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# =========================================================================
|
|
779
|
+
# User Interaction
|
|
780
|
+
# =========================================================================
|
|
781
|
+
|
|
782
|
+
async def _render_user_input_request(self, msg: UserInputRequest) -> None:
|
|
783
|
+
"""Render input prompt and send response back to bus."""
|
|
784
|
+
prompt = msg.prompt_text
|
|
785
|
+
if msg.default_value:
|
|
786
|
+
prompt += f" [{msg.default_value}]"
|
|
787
|
+
prompt += ": "
|
|
788
|
+
|
|
789
|
+
# Get input (password hides input)
|
|
790
|
+
if msg.input_type == "password":
|
|
791
|
+
value = self._console.input(prompt, password=True)
|
|
792
|
+
else:
|
|
793
|
+
value = self._console.input(f"[cyan]{prompt}[/cyan]")
|
|
794
|
+
|
|
795
|
+
# Use default if empty
|
|
796
|
+
if not value and msg.default_value:
|
|
797
|
+
value = msg.default_value
|
|
798
|
+
|
|
799
|
+
# Send response back
|
|
800
|
+
response = UserInputResponse(prompt_id=msg.prompt_id, value=value)
|
|
801
|
+
self._bus.provide_response(response)
|
|
802
|
+
|
|
803
|
+
async def _render_confirmation_request(self, msg: ConfirmationRequest) -> None:
|
|
804
|
+
"""Render confirmation dialog and send response back."""
|
|
805
|
+
# Show title and description - escape to prevent markup injection
|
|
806
|
+
safe_title = escape_rich_markup(msg.title)
|
|
807
|
+
safe_description = escape_rich_markup(msg.description)
|
|
808
|
+
self._console.print(f"\n[bold yellow]{safe_title}[/bold yellow]")
|
|
809
|
+
self._console.print(safe_description)
|
|
810
|
+
|
|
811
|
+
# Show options
|
|
812
|
+
options_str = "/".join(msg.options)
|
|
813
|
+
prompt = f"[{options_str}]"
|
|
814
|
+
|
|
815
|
+
while True:
|
|
816
|
+
choice = self._console.input(f"[cyan]{prompt}[/cyan] ").strip().lower()
|
|
817
|
+
|
|
818
|
+
# Check for match
|
|
819
|
+
for i, opt in enumerate(msg.options):
|
|
820
|
+
if choice == opt.lower() or choice == opt[0].lower():
|
|
821
|
+
confirmed = i == 0 # First option is "confirm"
|
|
822
|
+
|
|
823
|
+
# Get feedback if allowed
|
|
824
|
+
feedback = None
|
|
825
|
+
if msg.allow_feedback:
|
|
826
|
+
feedback = self._console.input(
|
|
827
|
+
"[dim]Feedback (optional): [/dim]"
|
|
828
|
+
)
|
|
829
|
+
feedback = feedback if feedback else None
|
|
830
|
+
|
|
831
|
+
response = ConfirmationResponse(
|
|
832
|
+
prompt_id=msg.prompt_id,
|
|
833
|
+
confirmed=confirmed,
|
|
834
|
+
feedback=feedback,
|
|
835
|
+
)
|
|
836
|
+
self._bus.provide_response(response)
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
self._console.print(f"[red]Please enter one of: {options_str}[/red]")
|
|
840
|
+
|
|
841
|
+
async def _render_selection_request(self, msg: SelectionRequest) -> None:
|
|
842
|
+
"""Render selection menu and send response back."""
|
|
843
|
+
safe_prompt = escape_rich_markup(msg.prompt_text)
|
|
844
|
+
self._console.print(f"\n[bold]{safe_prompt}[/bold]")
|
|
845
|
+
|
|
846
|
+
# Show numbered options - escape to prevent markup injection
|
|
847
|
+
for i, opt in enumerate(msg.options):
|
|
848
|
+
safe_opt = escape_rich_markup(opt)
|
|
849
|
+
self._console.print(f" [cyan]{i + 1}[/cyan]. {safe_opt}")
|
|
850
|
+
|
|
851
|
+
if msg.allow_cancel:
|
|
852
|
+
self._console.print(" [dim]0. Cancel[/dim]")
|
|
853
|
+
|
|
854
|
+
while True:
|
|
855
|
+
choice = self._console.input("[cyan]Enter number: [/cyan]").strip()
|
|
856
|
+
|
|
857
|
+
try:
|
|
858
|
+
idx = int(choice)
|
|
859
|
+
if msg.allow_cancel and idx == 0:
|
|
860
|
+
response = SelectionResponse(
|
|
861
|
+
prompt_id=msg.prompt_id,
|
|
862
|
+
selected_index=-1,
|
|
863
|
+
selected_value="",
|
|
864
|
+
)
|
|
865
|
+
self._bus.provide_response(response)
|
|
866
|
+
return
|
|
867
|
+
|
|
868
|
+
if 1 <= idx <= len(msg.options):
|
|
869
|
+
response = SelectionResponse(
|
|
870
|
+
prompt_id=msg.prompt_id,
|
|
871
|
+
selected_index=idx - 1,
|
|
872
|
+
selected_value=msg.options[idx - 1],
|
|
873
|
+
)
|
|
874
|
+
self._bus.provide_response(response)
|
|
875
|
+
return
|
|
876
|
+
except ValueError:
|
|
877
|
+
pass
|
|
878
|
+
|
|
879
|
+
self._console.print(f"[red]Please enter 1-{len(msg.options)}[/red]")
|
|
880
|
+
|
|
881
|
+
# =========================================================================
|
|
882
|
+
# Control Messages
|
|
883
|
+
# =========================================================================
|
|
884
|
+
|
|
885
|
+
def _render_spinner_control(self, msg: SpinnerControl) -> None:
|
|
886
|
+
"""Handle spinner control messages."""
|
|
887
|
+
# Note: Rich's spinner/status is typically used as a context manager.
|
|
888
|
+
# For full spinner support, we'd need a more complex implementation.
|
|
889
|
+
# For now, we just print the status text.
|
|
890
|
+
if msg.action == "start" and msg.text:
|
|
891
|
+
self._console.print(f"[dim]⠋ {msg.text}[/dim]")
|
|
892
|
+
elif msg.action == "update" and msg.text:
|
|
893
|
+
self._console.print(f"[dim]⠋ {msg.text}[/dim]")
|
|
894
|
+
elif msg.action == "stop":
|
|
895
|
+
pass # Spinner stopped
|
|
896
|
+
|
|
897
|
+
def _render_divider(self, msg: DividerMessage) -> None:
|
|
898
|
+
"""Render a horizontal divider."""
|
|
899
|
+
chars = {"light": "─", "heavy": "━", "double": "═"}
|
|
900
|
+
char = chars.get(msg.style, "─")
|
|
901
|
+
rule = Rule(style="dim", characters=char)
|
|
902
|
+
self._console.print(rule)
|
|
903
|
+
|
|
904
|
+
# =========================================================================
|
|
905
|
+
# Status Messages
|
|
906
|
+
# =========================================================================
|
|
907
|
+
|
|
908
|
+
def _render_status_panel(self, msg: StatusPanelMessage) -> None:
|
|
909
|
+
"""Render a status panel with key-value fields."""
|
|
910
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
911
|
+
table.add_column("Key", style="bold cyan")
|
|
912
|
+
table.add_column("Value")
|
|
913
|
+
|
|
914
|
+
for key, value in msg.fields.items():
|
|
915
|
+
table.add_row(key, value)
|
|
916
|
+
|
|
917
|
+
panel = Panel(table, title=f"[bold]{msg.title}[/bold]", border_style="blue")
|
|
918
|
+
self._console.print(panel)
|
|
919
|
+
|
|
920
|
+
def _render_version_check(self, msg: VersionCheckMessage) -> None:
|
|
921
|
+
"""Render version check information."""
|
|
922
|
+
if msg.update_available:
|
|
923
|
+
cur = msg.current_version
|
|
924
|
+
latest = msg.latest_version
|
|
925
|
+
self._console.print(f"[dim]⬆ Update available: {cur} → {latest}[/dim]")
|
|
926
|
+
else:
|
|
927
|
+
self._console.print(
|
|
928
|
+
f"[dim]✓ You're on the latest version ({msg.current_version})[/dim]"
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
# =========================================================================
|
|
932
|
+
# Helpers
|
|
933
|
+
# =========================================================================
|
|
934
|
+
|
|
935
|
+
def _format_size(self, size_bytes: int) -> str:
|
|
936
|
+
"""Format byte size to human readable matching old format."""
|
|
937
|
+
if size_bytes < 1024:
|
|
938
|
+
return f"{size_bytes} B"
|
|
939
|
+
elif size_bytes < 1024 * 1024:
|
|
940
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
941
|
+
elif size_bytes < 1024 * 1024 * 1024:
|
|
942
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
943
|
+
else:
|
|
944
|
+
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
|
945
|
+
|
|
946
|
+
def _get_file_icon(self, file_path: str) -> str:
|
|
947
|
+
"""Get an emoji icon for a file based on its extension."""
|
|
948
|
+
import os
|
|
949
|
+
|
|
950
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
951
|
+
icons = {
|
|
952
|
+
# Python
|
|
953
|
+
".py": "🐍",
|
|
954
|
+
".pyw": "🐍",
|
|
955
|
+
# JavaScript/TypeScript
|
|
956
|
+
".js": "📜",
|
|
957
|
+
".jsx": "📜",
|
|
958
|
+
".ts": "📜",
|
|
959
|
+
".tsx": "📜",
|
|
960
|
+
# Web
|
|
961
|
+
".html": "🌐",
|
|
962
|
+
".htm": "🌐",
|
|
963
|
+
".xml": "🌐",
|
|
964
|
+
".css": "🎨",
|
|
965
|
+
".scss": "🎨",
|
|
966
|
+
".sass": "🎨",
|
|
967
|
+
# Documentation
|
|
968
|
+
".md": "📝",
|
|
969
|
+
".markdown": "📝",
|
|
970
|
+
".rst": "📝",
|
|
971
|
+
".txt": "📝",
|
|
972
|
+
# Config
|
|
973
|
+
".json": "⚙️",
|
|
974
|
+
".yaml": "⚙️",
|
|
975
|
+
".yml": "⚙️",
|
|
976
|
+
".toml": "⚙️",
|
|
977
|
+
".ini": "⚙️",
|
|
978
|
+
# Images
|
|
979
|
+
".jpg": "🖼️",
|
|
980
|
+
".jpeg": "🖼️",
|
|
981
|
+
".png": "🖼️",
|
|
982
|
+
".gif": "🖼️",
|
|
983
|
+
".svg": "🖼️",
|
|
984
|
+
".webp": "🖼️",
|
|
985
|
+
# Audio
|
|
986
|
+
".mp3": "🎵",
|
|
987
|
+
".wav": "🎵",
|
|
988
|
+
".ogg": "🎵",
|
|
989
|
+
".flac": "🎵",
|
|
990
|
+
# Video
|
|
991
|
+
".mp4": "🎬",
|
|
992
|
+
".avi": "🎬",
|
|
993
|
+
".mov": "🎬",
|
|
994
|
+
".webm": "🎬",
|
|
995
|
+
# Documents
|
|
996
|
+
".pdf": "📄",
|
|
997
|
+
".doc": "📄",
|
|
998
|
+
".docx": "📄",
|
|
999
|
+
".xls": "📄",
|
|
1000
|
+
".xlsx": "📄",
|
|
1001
|
+
".ppt": "📄",
|
|
1002
|
+
".pptx": "📄",
|
|
1003
|
+
# Archives
|
|
1004
|
+
".zip": "📦",
|
|
1005
|
+
".tar": "📦",
|
|
1006
|
+
".gz": "📦",
|
|
1007
|
+
".rar": "📦",
|
|
1008
|
+
".7z": "📦",
|
|
1009
|
+
# Executables
|
|
1010
|
+
".exe": "⚡",
|
|
1011
|
+
".dll": "⚡",
|
|
1012
|
+
".so": "⚡",
|
|
1013
|
+
".dylib": "⚡",
|
|
1014
|
+
}
|
|
1015
|
+
return icons.get(ext, "📄")
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
# =============================================================================
|
|
1019
|
+
# Export all public symbols
|
|
1020
|
+
# =============================================================================
|
|
1021
|
+
|
|
1022
|
+
__all__ = [
|
|
1023
|
+
"RendererProtocol",
|
|
1024
|
+
"RichConsoleRenderer",
|
|
1025
|
+
"DEFAULT_STYLES",
|
|
1026
|
+
"DIFF_STYLES",
|
|
1027
|
+
]
|