Coverage for src/infra/io/log_output/console.py: 24%

1"""Console logging helpers for mala.

2

3 Claude Code style colored logging with agent identification support.

4"""

5

6 import json

7 from datetime import datetime

8 from typing import Any

9

10

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

12 _verbose_enabled: bool = False

13

14

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

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

17 global _verbose_enabled

18 _verbose_enabled = enabled

19

20

21 def is_verbose_enabled() -> bool:

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

23 return _verbose_enabled

24

25

26 def 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

39 class 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

58 AGENT_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

68 EDIT_TOOLS = frozenset(

69 {

70 "Edit",

71 "Write",

72 "NotebookEdit",

73 }

74)

75

76# Tools that show file path in quiet mode

77 FILE_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)

88 CODE_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

103 def 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

114 def 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

157 def 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

181 def _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

272 def 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

328 def _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

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

398 KNOWN_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

419 class 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)