Coverage for src / infra / io / log_output / console.py: 24%
147 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
1"""Console logging helpers for mala.
3Claude Code style colored logging with agent identification support.
4"""
6import json
7from datetime import datetime
8from typing import Any
11# Global verbose setting (can be modified at runtime)
12_verbose_enabled: bool = False
15def set_verbose(enabled: bool) -> None:
16 """Enable or disable verbose output globally."""
17 global _verbose_enabled
18 _verbose_enabled = enabled
21def is_verbose_enabled() -> bool:
22 """Check if verbose output is currently enabled."""
23 return _verbose_enabled
26def truncate_text(text: str, max_length: int) -> str:
27 """Truncate text to max_length, adding ellipsis if truncated.
29 Respects global verbose setting. If verbose is enabled,
30 returns the original text unchanged.
31 """
32 if _verbose_enabled:
33 return text
34 if len(text) > max_length:
35 return text[:max_length] + "..."
36 return text
39class Colors:
40 """ANSI color codes for terminal output (bright variants)."""
42 RESET = "\033[0m"
43 BOLD = "\033[1m"
44 # Bright colors for better terminal visibility
45 GREEN = "\033[92m"
46 YELLOW = "\033[93m"
47 BLUE = "\033[94m"
48 MAGENTA = "\033[95m"
49 CYAN = "\033[96m"
50 RED = "\033[91m"
51 GRAY = "\033[37;1m" # Bold light gray - brighter for dark terminal visibility
52 WHITE = "\033[97m"
53 # Subdued style for secondary info (brighter than ANSI 90 for visibility)
54 MUTED = "\033[37m" # Standard gray - visible but subdued on dark terminals
57# Agent color palette for distinguishing concurrent agents
58AGENT_COLORS = [
59 "\033[96m", # Bright Cyan
60 "\033[93m", # Bright Yellow
61 "\033[95m", # Bright Magenta
62 "\033[92m", # Bright Green
63 "\033[94m", # Bright Blue
64 "\033[97m", # Bright White
65]
67# Tools that have code fields which should be pretty-printed
68EDIT_TOOLS = frozenset(
69 {
70 "Edit",
71 "Write",
72 "NotebookEdit",
73 }
74)
76# Tools that show file path in quiet mode
77FILE_TOOLS = frozenset(
78 {
79 "Edit",
80 "Write",
81 "NotebookEdit",
82 "Read",
83 "Glob",
84 }
85)
87# Fields in edit tools that contain code (should be pretty-printed with newlines)
88CODE_FIELDS = frozenset(
89 {
90 "code_edit",
91 "content",
92 "new_source",
93 "old_string",
94 "new_string",
95 }
96)
98# Maps agent/issue IDs to their assigned colors
99_agent_color_map: dict[str, str] = {}
100_agent_color_index = 0
103def get_agent_color(agent_id: str) -> str:
104 """Get a consistent color for an agent based on its ID."""
105 global _agent_color_index
106 if agent_id not in _agent_color_map:
107 _agent_color_map[agent_id] = AGENT_COLORS[
108 _agent_color_index % len(AGENT_COLORS)
109 ]
110 _agent_color_index += 1
111 return _agent_color_map[agent_id]
114def log(
115 icon: str,
116 message: str,
117 color: str = Colors.RESET,
118 dim: bool = False,
119 agent_id: str | None = None,
120 issue_id: str | None = None,
121) -> None:
122 """Claude Code style logging with optional agent color coding.
124 Note: The dim parameter is accepted for API compatibility but no longer
125 applies the DIM ANSI attribute, as it reduces visibility on dark terminals.
126 Callers should use Colors.GRAY or Colors.MUTED directly for subdued output.
128 Args:
129 icon: Icon to display (e.g. "→", "◦").
130 message: Message to log.
131 color: Color for the message.
132 dim: Kept for API compatibility (no longer applies DIM).
133 agent_id: Optional agent ID for color mapping.
134 issue_id: Optional issue ID for display. When provided, shows [issue_id]
135 instead of [agent_id], but still uses agent_id for color mapping.
136 """
137 # Use caller's color directly - dim parameter kept for API compatibility
138 # but no longer reduces visibility (improves dark terminal readability)
139 timestamp = datetime.now().strftime("%H:%M:%S")
141 # Build prefix: prefer issue_id for display, agent_id for color
142 if issue_id:
143 # Use issue_id as display, agent_id for color mapping
144 agent_color = get_agent_color(agent_id) if agent_id else Colors.CYAN
145 prefix = f"{agent_color}[{issue_id}]{Colors.RESET} "
146 elif agent_id:
147 agent_color = get_agent_color(agent_id)
148 prefix = f"{agent_color}[{agent_id}]{Colors.RESET} "
149 else:
150 prefix = ""
152 print(
153 f"{Colors.GRAY}{timestamp}{Colors.RESET} {prefix}{color}{icon} {message}{Colors.RESET}"
154 )
157def log_verbose(
158 icon: str,
159 message: str,
160 color: str = Colors.MUTED,
161 agent_id: str | None = None,
162 issue_id: str | None = None,
163) -> None:
164 """Log a message only when verbose mode is enabled.
166 Use this for detailed state transitions and debug info that users
167 only need when troubleshooting with the -v flag.
169 Args:
170 icon: Icon to display (e.g. "→", "◦").
171 message: Message to log.
172 color: Color for the message (defaults to MUTED).
173 agent_id: Optional agent ID for color coding.
174 issue_id: Optional issue ID for display. When provided, shows [issue_id]
175 instead of [agent_id], but still uses agent_id for color mapping.
176 """
177 if _verbose_enabled:
178 log(icon, message, color, agent_id=agent_id, issue_id=issue_id)
181def _format_arguments(
182 arguments: dict[str, Any] | None,
183 verbose: bool,
184 tool_name: str = "",
185 key_color: str = Colors.CYAN,
186) -> str:
187 """Format tool arguments for display.
189 Args:
190 arguments: Tool arguments as a dictionary.
191 verbose: Whether to show full output (vs abbreviated).
192 tool_name: Name of the tool (used to detect edit tools for code formatting).
193 key_color: Color to use for argument keys (defaults to CYAN).
195 Returns:
196 Formatted string representation of arguments as key: value lines.
197 """
198 if not arguments:
199 return ""
201 is_edit_tool = tool_name in EDIT_TOOLS
202 lines = []
204 for key, value in arguments.items():
205 is_code_field = key in CODE_FIELDS
207 if isinstance(value, str):
208 if is_code_field and is_edit_tool:
209 # Code field: show with actual newlines
210 if verbose:
211 # Full code display with distinct coloring
212 code_lines = value.split("\n")
213 lines.append(f"{key_color}{key}:{Colors.RESET}")
214 for code_line in code_lines:
215 lines.append(f" {Colors.MUTED}{code_line}{Colors.RESET}")
216 else:
217 # Truncated code preview
218 preview = value[:60].replace("\n", "↵")
219 if len(value) > 60:
220 preview += "..."
221 lines.append(
222 f"{key_color}{key}:{Colors.RESET} {Colors.MUTED}{preview}{Colors.RESET}"
223 )
224 else:
225 # Regular string field
226 if verbose or len(value) <= 80:
227 lines.append(
228 f"{key_color}{key}:{Colors.RESET} {Colors.WHITE}{value}{Colors.RESET}"
229 )
230 else:
231 truncated = value[:80] + "..."
232 lines.append(
233 f"{key_color}{key}:{Colors.RESET} {Colors.WHITE}{truncated}{Colors.RESET}"
234 )
235 elif isinstance(value, bool):
236 lines.append(
237 f"{key_color}{key}:{Colors.RESET} {Colors.WHITE}{str(value).lower()}{Colors.RESET}"
238 )
239 elif isinstance(value, (int, float)):
240 lines.append(
241 f"{key_color}{key}:{Colors.RESET} {Colors.WHITE}{value}{Colors.RESET}"
242 )
243 elif isinstance(value, dict):
244 if verbose:
245 formatted = json.dumps(value, indent=2, ensure_ascii=False)
246 lines.append(f"{key_color}{key}:{Colors.RESET}")
247 for dict_line in formatted.split("\n"):
248 lines.append(f" {Colors.MUTED}{dict_line}{Colors.RESET}")
249 else:
250 lines.append(
251 f"{key_color}{key}:{Colors.RESET} {Colors.MUTED}{{...}}{Colors.RESET}"
252 )
253 elif isinstance(value, list):
254 if verbose:
255 formatted = json.dumps(value, indent=2, ensure_ascii=False)
256 lines.append(f"{key_color}{key}:{Colors.RESET}")
257 for list_line in formatted.split("\n"):
258 lines.append(f" {Colors.MUTED}{list_line}{Colors.RESET}")
259 else:
260 lines.append(
261 f"{key_color}{key}:{Colors.RESET} {Colors.MUTED}[...]{Colors.RESET}"
262 )
263 else:
264 # Fallback for other types
265 lines.append(
266 f"{key_color}{key}:{Colors.RESET} {Colors.WHITE}{value!r}{Colors.RESET}"
267 )
269 return "\n ".join(lines)
272def log_tool(
273 tool_name: str,
274 description: str = "",
275 agent_id: str | None = None,
276 arguments: dict[str, Any] | None = None,
277) -> None:
278 """Log tool usage in Claude Code style.
280 Args:
281 tool_name: Name of the tool being called.
282 description: Brief description of the tool action.
283 agent_id: Optional agent ID for color coding.
284 arguments: Optional tool arguments to display.
286 In quiet mode (non-verbose), shows single line per tool call:
287 - File tools (Read/Edit/Write/etc): show file_path or path
288 - Bash: show description field
289 - Other tools: show truncated args dict
290 """
291 icon = "\u2699"
292 verbose = is_verbose_enabled()
294 if agent_id:
295 agent_color = get_agent_color(agent_id)
296 prefix = f"{agent_color}[{agent_id}]{Colors.RESET} "
297 else:
298 agent_color = Colors.CYAN
299 prefix = ""
301 if not verbose:
302 # Quiet mode: single line output
303 summary = _get_quiet_summary(tool_name, description, arguments)
304 if summary:
305 print(
306 f" {prefix}{Colors.CYAN}{icon} {tool_name}{Colors.RESET} "
307 f"{Colors.MUTED}{summary}{Colors.RESET}"
308 )
309 else:
310 print(f" {prefix}{Colors.CYAN}{icon} {tool_name}{Colors.RESET}")
311 return
313 # Verbose mode: full output with arguments
314 desc_text = truncate_text(description, 50) if description else ""
315 desc = f" {Colors.MUTED}{desc_text}{Colors.RESET}" if desc_text else ""
317 # Format arguments if provided
318 args_output = ""
319 if arguments:
320 formatted_args = _format_arguments(arguments, verbose, tool_name, agent_color)
321 if formatted_args:
322 # Multi-line key:value format (no "args:" prefix)
323 args_output = f"\n {formatted_args}"
325 print(f" {prefix}{Colors.CYAN}{icon} {tool_name}{Colors.RESET}{desc}{args_output}")
328def _get_quiet_summary(
329 tool_name: str, description: str, arguments: dict[str, Any] | None
330) -> str:
331 """Get single-line summary for quiet mode output.
333 Args:
334 tool_name: Name of the tool being called.
335 description: Brief description of the tool action.
336 arguments: Tool arguments.
338 Returns:
339 Single-line summary string.
340 """
341 # File tools: show file path
342 if tool_name in FILE_TOOLS and arguments:
343 path = (
344 arguments.get("file_path")
345 or arguments.get("path")
346 or arguments.get("pattern")
347 )
348 if path:
349 return str(path)
351 # Bash: show description field
352 if tool_name == "Bash":
353 if description:
354 return description
355 if arguments and arguments.get("description"):
356 return str(arguments["description"])
358 # Other tools: truncated args dict
359 if arguments:
360 keys = list(arguments.keys())[:3]
361 if keys:
362 preview = ", ".join(f"{k}=..." for k in keys)
363 if len(arguments) > 3:
364 preview += f", +{len(arguments) - 3} more"
365 return f"{{{preview}}}"
367 return ""
370def log_agent_text(text: str, agent_id: str) -> None:
371 """Log agent text output with consistent formatting.
373 Uses 2-space indent like other log functions for consistency.
374 Applies truncation if enabled.
375 """
376 truncated = truncate_text(text, 100)
377 agent_color = get_agent_color(agent_id)
378 print(
379 f" {agent_color}[{agent_id}]{Colors.RESET} {Colors.MUTED}{truncated}{Colors.RESET}"
380 )
383# Color name to ANSI code mapping
384_COLOR_MAP: dict[str, str] = {
385 "cyan": Colors.CYAN,
386 "green": Colors.GREEN,
387 "red": Colors.RED,
388 "yellow": Colors.YELLOW,
389 "blue": Colors.BLUE,
390 "magenta": Colors.MAGENTA,
391 "gray": Colors.GRAY,
392 "white": Colors.WHITE,
393}
396# Known icon characters that can prefix messages
397# Only these characters will be parsed as icon prefixes in LoggerPort.log()
398KNOWN_ICONS: frozenset[str] = frozenset(
399 {
400 "▸", # Running/in-progress
401 "→", # Default/transition
402 "◦", # Verbose/secondary
403 "●", # Primary marker
404 "○", # Empty/skipped marker
405 "✓", # Success
406 "✗", # Failure
407 "⚠", # Warning
408 "!", # Warning (alternate)
409 "⚙", # Tool/processing
410 "◐", # Config/settings
411 "◌", # Ready/waiting
412 "▶", # Agent started
413 "🧹", # Cleanup
414 "🔍", # Verification
415 }
416)
419class ConsoleLoggerAdapter:
420 """Adapter that implements LoggerPort for console output.
422 Maps color names to ANSI codes and delegates to the log() function.
423 """
425 def log(
426 self,
427 message: str,
428 *,
429 level: str = "info",
430 color: str | None = None,
431 ) -> None:
432 """Log a message to the console.
434 Args:
435 message: The message to log.
436 level: Log level (unused, kept for interface compatibility).
437 color: Optional color name (e.g., "cyan", "green", "red").
438 """
439 # Map color name to ANSI code, default to reset if unknown
440 ansi_color = _COLOR_MAP.get(color, Colors.RESET) if color else Colors.RESET
441 # Extract icon prefix if present and is a known icon
442 # (e.g., "▸ Running..." -> icon="▸", rest="Running...")
443 # Only split on known icons to avoid misparsing messages like "A thing happened"
444 if (
445 message
446 and len(message) >= 2
447 and message[1] == " "
448 and message[0] in KNOWN_ICONS
449 ):
450 icon = message[0]
451 rest = message[2:]
452 else:
453 icon = "→"
454 rest = message
455 log(icon, rest, color=ansi_color)