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

1"""Console logging helpers for mala. 

2 

3Claude Code style colored logging with agent identification support. 

4""" 

5 

6import json 

7from datetime import datetime 

8from typing import Any 

9 

10 

11# Global verbose setting (can be modified at runtime) 

12_verbose_enabled: bool = False 

13 

14 

15def set_verbose(enabled: bool) -> None: 

16 """Enable or disable verbose output globally.""" 

17 global _verbose_enabled 

18 _verbose_enabled = enabled 

19 

20 

21def is_verbose_enabled() -> bool: 

22 """Check if verbose output is currently enabled.""" 

23 return _verbose_enabled 

24 

25 

26def truncate_text(text: str, max_length: int) -> str: 

27 """Truncate text to max_length, adding ellipsis if truncated. 

28 

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 

37 

38 

39class Colors: 

40 """ANSI color codes for terminal output (bright variants).""" 

41 

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 

55 

56 

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] 

66 

67# Tools that have code fields which should be pretty-printed 

68EDIT_TOOLS = frozenset( 

69 { 

70 "Edit", 

71 "Write", 

72 "NotebookEdit", 

73 } 

74) 

75 

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) 

86 

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) 

97 

98# Maps agent/issue IDs to their assigned colors 

99_agent_color_map: dict[str, str] = {} 

100_agent_color_index = 0 

101 

102 

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] 

112 

113 

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. 

123 

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. 

127 

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") 

140 

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 = "" 

151 

152 print( 

153 f"{Colors.GRAY}{timestamp}{Colors.RESET} {prefix}{color}{icon} {message}{Colors.RESET}" 

154 ) 

155 

156 

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. 

165 

166 Use this for detailed state transitions and debug info that users 

167 only need when troubleshooting with the -v flag. 

168 

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) 

179 

180 

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. 

188 

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). 

194 

195 Returns: 

196 Formatted string representation of arguments as key: value lines. 

197 """ 

198 if not arguments: 

199 return "" 

200 

201 is_edit_tool = tool_name in EDIT_TOOLS 

202 lines = [] 

203 

204 for key, value in arguments.items(): 

205 is_code_field = key in CODE_FIELDS 

206 

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 ) 

268 

269 return "\n ".join(lines) 

270 

271 

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. 

279 

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. 

285 

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() 

293 

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 = "" 

300 

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 

312 

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 "" 

316 

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}" 

324 

325 print(f" {prefix}{Colors.CYAN}{icon} {tool_name}{Colors.RESET}{desc}{args_output}") 

326 

327 

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. 

332 

333 Args: 

334 tool_name: Name of the tool being called. 

335 description: Brief description of the tool action. 

336 arguments: Tool arguments. 

337 

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) 

350 

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"]) 

357 

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}}}" 

366 

367 return "" 

368 

369 

370def log_agent_text(text: str, agent_id: str) -> None: 

371 """Log agent text output with consistent formatting. 

372 

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 ) 

381 

382 

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} 

394 

395 

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) 

417 

418 

419class ConsoleLoggerAdapter: 

420 """Adapter that implements LoggerPort for console output. 

421 

422 Maps color names to ANSI codes and delegates to the log() function. 

423 """ 

424 

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. 

433 

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)