codepp 0.0.437__py3-none-any.whl

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