code-puppy 0.0.302__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 (65) hide show
  1. code_puppy/agents/base_agent.py +373 -46
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +795 -0
  4. code_puppy/command_line/add_model_menu.py +8 -1
  5. code_puppy/command_line/autosave_menu.py +266 -35
  6. code_puppy/command_line/colors_menu.py +515 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +59 -10
  9. code_puppy/command_line/core_commands.py +19 -7
  10. code_puppy/command_line/mcp/edit_command.py +3 -1
  11. code_puppy/command_line/mcp/handler.py +7 -2
  12. code_puppy/command_line/mcp/install_command.py +8 -3
  13. code_puppy/command_line/mcp/logs_command.py +173 -64
  14. code_puppy/command_line/mcp/restart_command.py +7 -2
  15. code_puppy/command_line/mcp/search_command.py +10 -4
  16. code_puppy/command_line/mcp/start_all_command.py +16 -6
  17. code_puppy/command_line/mcp/start_command.py +3 -1
  18. code_puppy/command_line/mcp/status_command.py +2 -1
  19. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  20. code_puppy/command_line/mcp/stop_command.py +3 -1
  21. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  22. code_puppy/command_line/model_settings_menu.py +53 -7
  23. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  24. code_puppy/command_line/session_commands.py +11 -4
  25. code_puppy/config.py +103 -15
  26. code_puppy/keymap.py +8 -2
  27. code_puppy/main.py +5 -828
  28. code_puppy/mcp_/__init__.py +17 -0
  29. code_puppy/mcp_/blocking_startup.py +61 -32
  30. code_puppy/mcp_/config_wizard.py +5 -1
  31. code_puppy/mcp_/managed_server.py +23 -3
  32. code_puppy/mcp_/manager.py +65 -0
  33. code_puppy/mcp_/mcp_logs.py +224 -0
  34. code_puppy/messaging/__init__.py +20 -4
  35. code_puppy/messaging/bus.py +64 -0
  36. code_puppy/messaging/markdown_patches.py +57 -0
  37. code_puppy/messaging/messages.py +16 -0
  38. code_puppy/messaging/renderers.py +21 -9
  39. code_puppy/messaging/rich_renderer.py +113 -67
  40. code_puppy/messaging/spinner/console_spinner.py +34 -0
  41. code_puppy/model_factory.py +185 -30
  42. code_puppy/model_utils.py +57 -48
  43. code_puppy/models.json +19 -5
  44. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  45. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  46. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  47. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  48. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  49. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  50. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  51. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  52. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  53. code_puppy/prompts/codex_system_prompt.md +310 -0
  54. code_puppy/pydantic_patches.py +131 -0
  55. code_puppy/terminal_utils.py +126 -0
  56. code_puppy/tools/agent_tools.py +34 -9
  57. code_puppy/tools/command_runner.py +361 -32
  58. code_puppy/tools/file_operations.py +33 -45
  59. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  60. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
  61. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
  62. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  63. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  64. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  65. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -86,6 +86,9 @@ class MessageBus:
86
86
  # Request/Response correlation: prompt_id → Future (for async usage)
87
87
  self._pending_requests: Dict[str, asyncio.Future[Any]] = {}
88
88
 
89
+ # Session context for multi-agent tracking
90
+ self._current_session_id: Optional[str] = None
91
+
89
92
  # =========================================================================
90
93
  # Outgoing Messages (Agent → UI)
91
94
  # =========================================================================
@@ -95,11 +98,16 @@ class MessageBus:
95
98
 
96
99
  Thread-safe. Can be called from sync or async context.
97
100
  If no renderer is active, messages are buffered for later.
101
+ Auto-tags message with current session_id if not already set.
98
102
 
99
103
  Args:
100
104
  message: The message to emit.
101
105
  """
106
+ # Auto-tag message with current session if not already set
102
107
  with self._lock:
108
+ if message.session_id is None and self._current_session_id is not None:
109
+ message.session_id = self._current_session_id
110
+
103
111
  if not self._has_active_renderer:
104
112
  self._startup_buffer.append(message)
105
113
  return
@@ -151,6 +159,43 @@ class MessageBus:
151
159
  """Emit a DEBUG level text message."""
152
160
  self.emit_text(MessageLevel.DEBUG, text)
153
161
 
162
+ def emit_shell_line(self, line: str, stream: str = "stdout") -> None:
163
+ """Emit a shell output line with ANSI preservation.
164
+
165
+ Args:
166
+ line: The output line (may contain ANSI codes).
167
+ stream: Which stream this came from ("stdout" or "stderr").
168
+ """
169
+ from .messages import ShellLineMessage
170
+
171
+ message = ShellLineMessage(line=line, stream=stream) # type: ignore[arg-type]
172
+ self.emit(message)
173
+
174
+ # =========================================================================
175
+ # Session Context (Multi-Agent Tracking)
176
+ # =========================================================================
177
+
178
+ def set_session_context(self, session_id: Optional[str]) -> None:
179
+ """Set the current session context for auto-tagging messages.
180
+
181
+ When set, all messages emitted via emit() will be automatically tagged
182
+ with this session_id unless they already have one set.
183
+
184
+ Args:
185
+ session_id: The session ID to tag messages with, or None to clear.
186
+ """
187
+ with self._lock:
188
+ self._current_session_id = session_id
189
+
190
+ def get_session_context(self) -> Optional[str]:
191
+ """Get the current session context.
192
+
193
+ Returns:
194
+ The current session_id, or None if not set.
195
+ """
196
+ with self._lock:
197
+ return self._current_session_id
198
+
154
199
  # =========================================================================
155
200
  # User Input Requests (Agent waits for UI response)
156
201
  # =========================================================================
@@ -526,6 +571,21 @@ def emit_debug(text: str) -> None:
526
571
  get_message_bus().emit_debug(text)
527
572
 
528
573
 
574
+ def emit_shell_line(line: str, stream: str = "stdout") -> None:
575
+ """Emit a shell output line with ANSI preservation."""
576
+ get_message_bus().emit_shell_line(line, stream)
577
+
578
+
579
+ def set_session_context(session_id: Optional[str]) -> None:
580
+ """Set the session context on the global bus."""
581
+ get_message_bus().set_session_context(session_id)
582
+
583
+
584
+ def get_session_context() -> Optional[str]:
585
+ """Get the session context from the global bus."""
586
+ return get_message_bus().get_session_context()
587
+
588
+
529
589
  # =============================================================================
530
590
  # Export all public symbols
531
591
  # =============================================================================
@@ -543,4 +603,8 @@ __all__ = [
543
603
  "emit_error",
544
604
  "emit_success",
545
605
  "emit_debug",
606
+ "emit_shell_line",
607
+ # Session context
608
+ "set_session_context",
609
+ "get_session_context",
546
610
  ]
@@ -0,0 +1,57 @@
1
+ """Patches for Rich's Markdown rendering.
2
+
3
+ This module provides customizations to Rich's default Markdown rendering,
4
+ particularly for header justification which is hardcoded to center in Rich.
5
+ """
6
+
7
+ from rich import box
8
+ from rich.markdown import Heading, Markdown
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+
13
+ class LeftJustifiedHeading(Heading):
14
+ """A heading that left-justifies text instead of centering.
15
+
16
+ Rich's default Heading class hardcodes `text.justify = 'center'`,
17
+ which can look odd in a CLI context. This subclass overrides that
18
+ to use left justification instead.
19
+ """
20
+
21
+ def __rich_console__(self, console, options):
22
+ """Render the heading with left justification."""
23
+ text = self.text
24
+ text.justify = "left" # Override Rich's default 'center'
25
+
26
+ if self.tag == "h1":
27
+ # Draw a border around h1s (same as Rich default)
28
+ yield Panel(
29
+ text,
30
+ box=box.HEAVY,
31
+ style="markdown.h1.border",
32
+ )
33
+ else:
34
+ # Styled text for h2 and beyond (same as Rich default)
35
+ if self.tag == "h2":
36
+ yield Text("")
37
+ yield text
38
+
39
+
40
+ _patched = False
41
+
42
+
43
+ def patch_markdown_headings():
44
+ """Patch Rich's Markdown to use left-justified headings.
45
+
46
+ This function is idempotent - calling it multiple times has no effect
47
+ after the first call.
48
+ """
49
+ global _patched
50
+ if _patched:
51
+ return
52
+
53
+ Markdown.elements["heading_open"] = LeftJustifiedHeading
54
+ _patched = True
55
+
56
+
57
+ __all__ = ["patch_markdown_headings", "LeftJustifiedHeading"]
@@ -56,6 +56,10 @@ class BaseMessage(BaseModel):
56
56
  category: MessageCategory = Field(
57
57
  description="Category for routing and rendering decisions"
58
58
  )
59
+ session_id: Optional[str] = Field(
60
+ default=None,
61
+ description="Session ID of the agent that emitted this message (for multi-agent tracking)",
62
+ )
59
63
 
60
64
  model_config = {"frozen": False, "extra": "forbid"}
61
65
 
@@ -207,6 +211,16 @@ class ShellStartMessage(BaseMessage):
207
211
  timeout: int = Field(default=60, description="Timeout in seconds")
208
212
 
209
213
 
214
+ class ShellLineMessage(BaseMessage):
215
+ """A single line of shell command output with ANSI preservation."""
216
+
217
+ category: MessageCategory = MessageCategory.TOOL_OUTPUT
218
+ line: str = Field(description="The output line (may contain ANSI codes)")
219
+ stream: Literal["stdout", "stderr"] = Field(
220
+ default="stdout", description="Which output stream this line came from"
221
+ )
222
+
223
+
210
224
  class ShellOutputMessage(BaseMessage):
211
225
  """Output from a shell command execution with stdout, stderr, and timing."""
212
226
 
@@ -394,6 +408,7 @@ AnyMessage = Union[
394
408
  GrepResultMessage,
395
409
  DiffMessage,
396
410
  ShellStartMessage,
411
+ ShellLineMessage,
397
412
  ShellOutputMessage,
398
413
  AgentReasoningMessage,
399
414
  AgentResponseMessage,
@@ -433,6 +448,7 @@ __all__ = [
433
448
  "DiffMessage",
434
449
  # Shell
435
450
  "ShellStartMessage",
451
+ "ShellLineMessage",
436
452
  "ShellOutputMessage",
437
453
  # Agent
438
454
  "AgentReasoningMessage",
@@ -12,6 +12,7 @@ from typing import Optional
12
12
 
13
13
  from rich.console import Console
14
14
  from rich.markdown import Markdown
15
+ from rich.markup import escape as escape_rich_markup
15
16
 
16
17
  from .message_queue import MessageQueue, MessageType, UIMessage
17
18
 
@@ -126,11 +127,15 @@ class InteractiveRenderer(MessageRenderer):
126
127
  self.console.print(markdown)
127
128
  except Exception:
128
129
  # Fallback to plain text if markdown parsing fails
129
- self.console.print(message.content)
130
+ safe_content = escape_rich_markup(message.content)
131
+ self.console.print(safe_content)
130
132
  elif style:
131
- self.console.print(message.content, style=style)
133
+ # Escape Rich markup to prevent crashes from malformed tags
134
+ safe_content = escape_rich_markup(message.content)
135
+ self.console.print(safe_content, style=style)
132
136
  else:
133
- self.console.print(message.content)
137
+ safe_content = escape_rich_markup(message.content)
138
+ self.console.print(safe_content)
134
139
  else:
135
140
  # For complex Rich objects (Tables, Markdown, Text, etc.)
136
141
  self.console.print(message.content)
@@ -145,7 +150,8 @@ class InteractiveRenderer(MessageRenderer):
145
150
  # This renderer is not currently used in practice, but if it were:
146
151
  # We would need async input handling here
147
152
  # For now, just render as a system message
148
- self.console.print(f"[bold cyan]INPUT REQUESTED:[/bold cyan] {message.content}")
153
+ safe_content = escape_rich_markup(str(message.content))
154
+ self.console.print(f"[bold cyan]INPUT REQUESTED:[/bold cyan] {safe_content}")
149
155
  if hasattr(self.console.file, "flush"):
150
156
  self.console.file.flush()
151
157
 
@@ -253,11 +259,16 @@ class SynchronousInteractiveRenderer:
253
259
  self.console.print(markdown)
254
260
  except Exception:
255
261
  # Fallback to plain text if markdown parsing fails
256
- self.console.print(message.content)
262
+ safe_content = escape_rich_markup(message.content)
263
+ self.console.print(safe_content)
257
264
  elif style:
258
- self.console.print(message.content, style=style)
265
+ # Escape Rich markup to prevent crashes from malformed tags
266
+ # in shell output or other user-provided content
267
+ safe_content = escape_rich_markup(message.content)
268
+ self.console.print(safe_content, style=style)
259
269
  else:
260
- self.console.print(message.content)
270
+ safe_content = escape_rich_markup(message.content)
271
+ self.console.print(safe_content)
261
272
  else:
262
273
  # For complex Rich objects (Tables, Markdown, Text, etc.)
263
274
  self.console.print(message.content)
@@ -276,8 +287,9 @@ class SynchronousInteractiveRenderer:
276
287
  )
277
288
  return
278
289
 
279
- # Display the prompt
280
- self.console.print(f"[bold cyan]{message.content}[/bold cyan]")
290
+ # Display the prompt - escape to prevent markup injection
291
+ safe_content = escape_rich_markup(str(message.content))
292
+ self.console.print(f"[bold cyan]{safe_content}[/bold cyan]")
281
293
  if hasattr(self.console.file, "flush"):
282
294
  self.console.file.flush()
283
295
 
@@ -11,6 +11,7 @@ from typing import Dict, Optional, Protocol, runtime_checkable
11
11
 
12
12
  from rich.console import Console
13
13
  from rich.markdown import Markdown
14
+ from rich.markup import escape as escape_rich_markup
14
15
  from rich.panel import Panel
15
16
  from rich.rule import Rule
16
17
 
@@ -37,6 +38,7 @@ from .messages import (
37
38
  GrepResultMessage,
38
39
  MessageLevel,
39
40
  SelectionRequest,
41
+ ShellLineMessage,
40
42
  ShellOutputMessage,
41
43
  ShellStartMessage,
42
44
  SpinnerControl,
@@ -131,6 +133,32 @@ class RichConsoleRenderer:
131
133
  """Get the Rich console."""
132
134
  return self._console
133
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
+
134
162
  # =========================================================================
135
163
  # Lifecycle (Synchronous - for compatibility with main.py)
136
164
  # =========================================================================
@@ -187,7 +215,9 @@ class RichConsoleRenderer:
187
215
  self._do_render(message)
188
216
  except Exception as e:
189
217
  # Don't let rendering errors crash the loop
190
- self._console.print(f"[dim red]Render error: {e}[/dim red]")
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]")
191
221
 
192
222
  # =========================================================================
193
223
  # Async Lifecycle (for future async-first usage)
@@ -233,12 +263,15 @@ class RichConsoleRenderer:
233
263
  self._render_diff(message)
234
264
  elif isinstance(message, ShellStartMessage):
235
265
  self._render_shell_start(message)
266
+ elif isinstance(message, ShellLineMessage):
267
+ self._render_shell_line(message)
236
268
  elif isinstance(message, ShellOutputMessage):
237
269
  self._render_shell_output(message)
238
270
  elif isinstance(message, AgentReasoningMessage):
239
271
  self._render_agent_reasoning(message)
240
272
  elif isinstance(message, AgentResponseMessage):
241
- self._render_agent_response(message)
273
+ # Skip rendering - we now stream agent responses via event_stream_handler
274
+ pass
242
275
  elif isinstance(message, SubAgentInvocationMessage):
243
276
  self._render_subagent_invocation(message)
244
277
  elif isinstance(message, SubAgentResponseMessage):
@@ -282,7 +315,12 @@ class RichConsoleRenderer:
282
315
  # =========================================================================
283
316
 
284
317
  def _render_text(self, msg: TextMessage) -> None:
285
- """Render a text message with appropriate styling."""
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
+ """
286
324
  style = self._styles.get(msg.level, "white")
287
325
 
288
326
  # Make version messages dim
@@ -290,7 +328,9 @@ class RichConsoleRenderer:
290
328
  style = "dim"
291
329
 
292
330
  prefix = self._get_level_prefix(msg.level)
293
- self._console.print(f"{prefix}{msg.text}", style=style)
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)
294
334
 
295
335
  def _get_level_prefix(self, level: MessageLevel) -> str:
296
336
  """Get a prefix icon for the message level."""
@@ -311,8 +351,9 @@ class RichConsoleRenderer:
311
351
  """Render a directory listing matching the old Rich-formatted output."""
312
352
  # Header on single line
313
353
  rec_flag = f"(recursive={msg.recursive})"
354
+ banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
314
355
  self._console.print(
315
- f"\n[bold white on blue] DIRECTORY LISTING [/bold white on blue] "
356
+ f"\n{banner} "
316
357
  f"📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
317
358
  )
318
359
 
@@ -362,26 +403,25 @@ class RichConsoleRenderer:
362
403
  line_info = f" [dim](lines {msg.start_line}-{end_line})[/dim]"
363
404
 
364
405
  # Just print the header - content is for LLM only
406
+ banner = self._format_banner("read_file", "READ FILE")
365
407
  self._console.print(
366
- f"\n[bold white on blue] READ FILE [/bold white on blue] "
367
- f"📂 [bold cyan]{msg.path}[/bold cyan]{line_info}"
408
+ f"\n{banner} 📂 [bold cyan]{msg.path}[/bold cyan]{line_info}"
368
409
  )
369
410
 
370
411
  def _render_grep_result(self, msg: GrepResultMessage) -> None:
371
412
  """Render grep results grouped by file matching old format."""
372
413
  import re
373
414
 
374
- # Header matching old format
415
+ # Header
416
+ banner = self._format_banner("grep", "GREP")
375
417
  self._console.print(
376
- f"\n[bold white on blue] GREP [/bold white on blue] "
377
- f"📂 [bold cyan]{msg.directory}[/bold cyan] "
378
- f"[dim]for '{msg.search_term}'[/dim]"
418
+ f"\n{banner} 📂 [dim]{msg.directory} for '{msg.search_term}'[/dim]"
379
419
  )
380
420
 
381
421
  if not msg.matches:
382
422
  self._console.print(
383
- f"[yellow]No matches found for '{msg.search_term}' "
384
- f"in {msg.directory}[/yellow]"
423
+ f"[dim]No matches found for '{msg.search_term}' "
424
+ f"in {msg.directory}[/dim]"
385
425
  )
386
426
  return
387
427
 
@@ -397,8 +437,7 @@ class RichConsoleRenderer:
397
437
  file_matches = by_file[file_path]
398
438
  match_word = "match" if len(file_matches) == 1 else "matches"
399
439
  self._console.print(
400
- f"\n[bold white]📄 {file_path}[/bold white] "
401
- f"[dim]({len(file_matches)} {match_word})[/dim]"
440
+ f"\n[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
402
441
  )
403
442
 
404
443
  # Show each match with line number and content
@@ -414,7 +453,7 @@ class RichConsoleRenderer:
414
453
  if search_term and not search_term.startswith("-"):
415
454
  highlighted_line = re.sub(
416
455
  f"({re.escape(search_term)})",
417
- r"[bold yellow on black]\1[/bold yellow on black]",
456
+ r"[bold yellow]\1[/bold yellow]",
418
457
  line,
419
458
  flags=re.IGNORECASE,
420
459
  )
@@ -422,9 +461,7 @@ class RichConsoleRenderer:
422
461
  highlighted_line = line
423
462
 
424
463
  ln = match.line_number
425
- self._console.print(
426
- f" [bold cyan]{ln:4d}[/bold cyan] │ {highlighted_line}"
427
- )
464
+ self._console.print(f" [dim]{ln:4d}[/dim] │ {highlighted_line}")
428
465
  else:
429
466
  # Concise mode (default): Show only file summaries
430
467
  self._console.print("")
@@ -435,15 +472,18 @@ class RichConsoleRenderer:
435
472
  f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
436
473
  )
437
474
 
438
- # Summary
475
+ # Summary - subtle
439
476
  match_word = "match" if msg.total_matches == 1 else "matches"
440
477
  file_word = "file" if len(by_file) == 1 else "files"
441
478
  num_files = len(by_file)
442
479
  self._console.print(
443
- f"[green]Found [bold]{msg.total_matches}[/bold] {match_word} "
444
- f"across [bold]{num_files}[/bold] {file_word}[/green]"
480
+ f"[dim]Found {msg.total_matches} {match_word} "
481
+ f"across {num_files} {file_word}[/dim]"
445
482
  )
446
483
 
484
+ # Trailing newline for spinner separation
485
+ self._console.print()
486
+
447
487
  # =========================================================================
448
488
  # Diff
449
489
  # =========================================================================
@@ -457,8 +497,9 @@ class RichConsoleRenderer:
457
497
  op_color = op_colors.get(msg.operation, "white")
458
498
 
459
499
  # Header on single line
500
+ banner = self._format_banner("edit_file", "EDIT FILE")
460
501
  self._console.print(
461
- f"\n[bold white on blue] EDIT FILE [/bold white on blue] "
502
+ f"\n{banner} "
462
503
  f"{icon} [{op_color}]{msg.operation.upper()}[/{op_color}] "
463
504
  f"[bold cyan]{msg.path}[/bold cyan]"
464
505
  )
@@ -474,7 +515,12 @@ class RichConsoleRenderer:
474
515
  elif line.type == "remove":
475
516
  diff_text_lines.append(f"-{line.content}")
476
517
  else: # context
477
- diff_text_lines.append(f" {line.content}")
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}")
478
524
 
479
525
  diff_text = "\n".join(diff_text_lines)
480
526
 
@@ -488,43 +534,39 @@ class RichConsoleRenderer:
488
534
 
489
535
  def _render_shell_start(self, msg: ShellStartMessage) -> None:
490
536
  """Render shell command start notification."""
537
+ # Escape command to prevent Rich markup injection
538
+ safe_command = escape_rich_markup(msg.command)
491
539
  # Header showing command is starting
492
- self._console.print(
493
- f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] "
494
- f"🚀 [bold green]$ {msg.command}[/bold green]"
495
- )
540
+ banner = self._format_banner("shell_command", "SHELL COMMAND")
541
+ self._console.print(f"\n{banner} 🚀 [dim]$ {safe_command}[/dim]")
496
542
 
497
543
  # Show working directory if specified
498
544
  if msg.cwd:
499
- self._console.print(f"[dim]📂 Working directory: {msg.cwd}[/dim]")
545
+ safe_cwd = escape_rich_markup(msg.cwd)
546
+ self._console.print(f"[dim]📂 Working directory: {safe_cwd}[/dim]")
500
547
 
501
548
  # Show timeout
502
549
  self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
503
550
 
504
- def _render_shell_output(self, msg: ShellOutputMessage) -> None:
505
- """Render shell command output matching old format."""
506
- # Header matching old format
507
- self._console.print(
508
- f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] "
509
- f"📂 [bold green]$ {msg.command}[/bold green]"
510
- )
551
+ def _render_shell_line(self, msg: ShellLineMessage) -> None:
552
+ """Render shell output line preserving ANSI codes."""
553
+ from rich.text import Text
511
554
 
512
- # stdout
513
- if msg.stdout:
514
- self._console.print(msg.stdout)
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)
515
558
 
516
- # stderr (if any)
517
- if msg.stderr:
518
- self._console.print(f"[red]{msg.stderr}[/red]")
559
+ # Make all shell output dim to reduce visual noise
560
+ self._console.print(text, style="dim")
519
561
 
520
- # Footer with timing and exit code
521
- if msg.exit_code == 0:
522
- self._console.print(f"[dim]Completed in {msg.duration_seconds:.2f}s[/dim]")
523
- else:
524
- dur = msg.duration_seconds
525
- self._console.print(
526
- f"[red]Exit code: {msg.exit_code}[/red] [dim]({dur:.2f}s)[/dim]"
527
- )
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()
528
570
 
529
571
  # =========================================================================
530
572
  # Agent Messages
@@ -533,9 +575,8 @@ class RichConsoleRenderer:
533
575
  def _render_agent_reasoning(self, msg: AgentReasoningMessage) -> None:
534
576
  """Render agent reasoning matching old format."""
535
577
  # Header matching old format
536
- self._console.print(
537
- "\n[bold white on purple] AGENT REASONING [/bold white on purple]"
538
- )
578
+ banner = self._format_banner("agent_reasoning", "AGENT REASONING")
579
+ self._console.print(f"\n{banner}")
539
580
 
540
581
  # Current reasoning
541
582
  self._console.print("[bold cyan]Current reasoning:[/bold cyan]")
@@ -549,12 +590,14 @@ class RichConsoleRenderer:
549
590
  md_steps = Markdown(msg.next_steps)
550
591
  self._console.print(md_steps)
551
592
 
593
+ # Trailing newline for spinner separation
594
+ self._console.print()
595
+
552
596
  def _render_agent_response(self, msg: AgentResponseMessage) -> None:
553
597
  """Render agent response with header and markdown formatting."""
554
598
  # Header
555
- self._console.print(
556
- "\n[bold white on purple] AGENT RESPONSE [/bold white on purple]\n"
557
- )
599
+ banner = self._format_banner("agent_response", "AGENT RESPONSE")
600
+ self._console.print(f"\n{banner}\n")
558
601
 
559
602
  # Content (markdown or plain)
560
603
  if msg.is_markdown:
@@ -571,8 +614,9 @@ class RichConsoleRenderer:
571
614
  if msg.is_new_session
572
615
  else f"Continuing ({msg.message_count} messages)"
573
616
  )
617
+ banner = self._format_banner("invoke_agent", "🤖 INVOKE AGENT")
574
618
  self._console.print(
575
- f"\n[bold white on purple] 🤖 INVOKE AGENT [/bold white on purple] "
619
+ f"\n{banner} "
576
620
  f"[bold cyan]{msg.agent_name}[/bold cyan] "
577
621
  f"[dim]({session_type})[/dim]"
578
622
  )
@@ -591,10 +635,8 @@ class RichConsoleRenderer:
591
635
  def _render_subagent_response(self, msg: SubAgentResponseMessage) -> None:
592
636
  """Render sub-agent response with markdown formatting."""
593
637
  # Response header
594
- self._console.print(
595
- f"\n[bold white on green] ✓ AGENT RESPONSE [/bold white on green] "
596
- f"[bold cyan]{msg.agent_name}[/bold cyan]"
597
- )
638
+ banner = self._format_banner("subagent_response", "✓ AGENT RESPONSE")
639
+ self._console.print(f"\n{banner} [bold cyan]{msg.agent_name}[/bold cyan]")
598
640
 
599
641
  # Render response as markdown
600
642
  md = Markdown(msg.response)
@@ -633,9 +675,11 @@ class RichConsoleRenderer:
633
675
 
634
676
  async def _render_confirmation_request(self, msg: ConfirmationRequest) -> None:
635
677
  """Render confirmation dialog and send response back."""
636
- # Show title and description
637
- self._console.print(f"\n[bold yellow]{msg.title}[/bold yellow]")
638
- self._console.print(msg.description)
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)
639
683
 
640
684
  # Show options
641
685
  options_str = "/".join(msg.options)
@@ -669,11 +713,13 @@ class RichConsoleRenderer:
669
713
 
670
714
  async def _render_selection_request(self, msg: SelectionRequest) -> None:
671
715
  """Render selection menu and send response back."""
672
- self._console.print(f"\n[bold]{msg.prompt_text}[/bold]")
716
+ safe_prompt = escape_rich_markup(msg.prompt_text)
717
+ self._console.print(f"\n[bold]{safe_prompt}[/bold]")
673
718
 
674
- # Show numbered options
719
+ # Show numbered options - escape to prevent markup injection
675
720
  for i, opt in enumerate(msg.options):
676
- self._console.print(f" [cyan]{i + 1}[/cyan]. {opt}")
721
+ safe_opt = escape_rich_markup(opt)
722
+ self._console.print(f" [cyan]{i + 1}[/cyan]. {safe_opt}")
677
723
 
678
724
  if msg.allow_cancel:
679
725
  self._console.print(" [dim]0. Cancel[/dim]")