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.
Files changed (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.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
+ ]