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