swarm_cli 2.0.0.pre.1

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.
@@ -0,0 +1,912 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module Formatters
5
+ # HumanFormatter creates beautiful, detailed real-time output using TTY toolkit.
6
+ # Shows everything: agent thinking, tool calls with arguments, results, responses.
7
+ # Uses simple dividers for clean, readable output without box alignment issues.
8
+ class HumanFormatter
9
+ ICONS = {
10
+ success: "โœ“",
11
+ error: "โœ—",
12
+ info: "โ„น",
13
+ agent: "๐Ÿค–",
14
+ tool: "๐Ÿ”ง",
15
+ llm: "๐Ÿง ",
16
+ thinking: "๐Ÿ’ญ",
17
+ response: "๐Ÿ’ฌ",
18
+ cost: "๐Ÿ’ฐ",
19
+ time: "โฑ",
20
+ tokens: "๐Ÿ“Š",
21
+ arrow_right: "โ†’",
22
+ delegate: "๐Ÿ“จ",
23
+ result: "๐Ÿ“ฅ",
24
+ bullet: "โ€ข",
25
+ sparkles: "โœจ",
26
+ hook: "๐Ÿช",
27
+ }.freeze
28
+
29
+ def initialize(output: $stdout, quiet: false, truncate: false, verbose: false)
30
+ @output = output
31
+ @quiet = quiet
32
+ @truncate = truncate
33
+ @verbose = verbose
34
+ # Enable colors when output is a TTY (terminal), disable for non-TTY (pipes, files)
35
+ # This ensures colors work in terminals but don't pollute piped/redirected output
36
+ @pastel = Pastel.new(enabled: output.tty?)
37
+ @llm_request_count = 0
38
+ @tool_call_count = 0
39
+ @total_cost = 0.0
40
+ @total_tokens = 0
41
+ @agents_seen = Set.new
42
+ @agent_depth = {}
43
+ @agent_colors = {}
44
+ @color_palette = [:cyan, :magenta, :yellow, :blue, :green, :bright_cyan, :bright_magenta]
45
+ @next_color_index = 0
46
+ @start_time = nil
47
+ @terminal_width = TTY::Screen.width
48
+ @active_spinner = nil
49
+ @recent_tool_calls = {} # tool_call_id => tool_name
50
+ end
51
+
52
+ # Called when swarm execution starts
53
+ def on_start(config_path:, swarm_name:, lead_agent:, prompt:)
54
+ @start_time = Time.now
55
+ print_header(swarm_name, lead_agent)
56
+ print_prompt(prompt)
57
+ print_divider
58
+ @output.puts @pastel.bold("#{ICONS[:info]} Execution Log:")
59
+ @output.puts
60
+
61
+ # Start spinner while waiting for first event
62
+ lead_color = get_agent_color(lead_agent.to_sym)
63
+ start_spinner(" ", "#{@pastel.public_send(lead_color, lead_agent)} is processing the request...")
64
+ end
65
+
66
+ # Called for each log entry from SwarmSDK
67
+ def on_log(entry)
68
+ return if @quiet
69
+
70
+ # Clear any active spinner before processing new event
71
+ clear_spinner
72
+
73
+ case entry[:type]
74
+ when "swarm_start"
75
+ handle_swarm_start(entry)
76
+ when "swarm_stop"
77
+ handle_swarm_stop(entry)
78
+ when "user_prompt"
79
+ handle_user_request(entry)
80
+ when "agent_step"
81
+ handle_agent_step(entry)
82
+ when "agent_stop"
83
+ handle_agent_stop(entry)
84
+ when "tool_call"
85
+ handle_tool_call(entry)
86
+ when "tool_result"
87
+ handle_tool_result(entry)
88
+ when "agent_delegation"
89
+ handle_agent_delegation(entry)
90
+ when "delegation_result"
91
+ handle_delegation_result(entry)
92
+ when "context_limit_warning"
93
+ handle_context_warning(entry)
94
+ when "model_lookup_warning"
95
+ handle_model_lookup_warning(entry)
96
+ when "compression_started"
97
+ handle_compression_started(entry)
98
+ when "compression_completed"
99
+ handle_compression_completed(entry)
100
+ when "hook_executed"
101
+ handle_hook_executed(entry)
102
+ end
103
+ end
104
+
105
+ # Called when swarm execution completes successfully
106
+ def on_success(result:)
107
+ clear_spinner
108
+ @output.puts
109
+ print_divider
110
+ print_result(result)
111
+ print_summary(result)
112
+ end
113
+
114
+ # Called when swarm execution fails
115
+ def on_error(error:, duration: nil)
116
+ clear_spinner
117
+ @output.puts
118
+ print_divider
119
+ print_error(error)
120
+ print_divider
121
+ end
122
+
123
+ private
124
+
125
+ def handle_swarm_start(entry)
126
+ # SwarmSDK now emits this automatically
127
+ # The CLI's on_start method already displayed the header, so we skip it here
128
+ # to avoid duplicate output
129
+ end
130
+
131
+ def handle_swarm_stop(entry)
132
+ # SwarmSDK now emits this automatically
133
+ # The CLI's on_success/on_error methods will display the summary,
134
+ # so we just silently track it here
135
+ end
136
+
137
+ def handle_compression_started(entry)
138
+ agent = entry[:agent]
139
+ indent = " " * (@agent_depth[agent] || 0)
140
+ agent_color = get_agent_color(agent)
141
+ timestamp = format_timestamp(entry[:timestamp])
142
+
143
+ message_count = entry[:message_count]
144
+ estimated_tokens = entry[:estimated_tokens]
145
+
146
+ @output.puts
147
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.yellow("๐Ÿ—œ๏ธ CONTEXT COMPRESSION")} #{@pastel.public_send(agent_color, agent)}"
148
+ @output.puts "#{indent} #{@pastel.dim("Compressing #{message_count} messages (~#{format_number(estimated_tokens)} tokens)...")}"
149
+ @output.puts
150
+
151
+ # Show spinner while compressing
152
+ start_spinner("#{indent} ", "Compacting context...")
153
+ end
154
+
155
+ def handle_compression_completed(entry)
156
+ agent = entry[:agent]
157
+ indent = " " * (@agent_depth[agent] || 0)
158
+ agent_color = get_agent_color(agent)
159
+ timestamp = format_timestamp(entry[:timestamp])
160
+
161
+ original_messages = entry[:original_message_count]
162
+ compressed_messages = entry[:compressed_message_count]
163
+ messages_removed = entry[:messages_removed]
164
+
165
+ original_tokens = entry[:original_tokens]
166
+ compressed_tokens = entry[:compressed_tokens]
167
+ compression_ratio = entry[:compression_ratio]
168
+
169
+ time_taken = entry[:time_taken]
170
+
171
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.green("โœ“ COMPRESSION COMPLETE")} #{@pastel.public_send(agent_color, agent)}"
172
+ @output.puts "#{indent} #{@pastel.dim("Messages:")} #{original_messages} โ†’ #{compressed_messages} #{@pastel.green("(-#{messages_removed})")}"
173
+ @output.puts "#{indent} #{@pastel.dim("Tokens:")} #{format_number(original_tokens)} โ†’ #{format_number(compressed_tokens)} #{@pastel.green("(#{(compression_ratio * 100).round(1)}%)")}"
174
+ @output.puts "#{indent} #{@pastel.dim("Time taken:")} #{format_duration(time_taken)}"
175
+ @output.puts
176
+ end
177
+
178
+ def handle_user_request(entry)
179
+ agent = entry[:agent]
180
+ @agents_seen.add(agent)
181
+ @llm_request_count += 1
182
+
183
+ @agent_depth[agent] ||= calculate_depth(agent)
184
+ indent = " " * @agent_depth[agent]
185
+ agent_color = get_agent_color(agent)
186
+
187
+ timestamp = format_timestamp(entry[:timestamp])
188
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.public_send(agent_color, "#{ICONS[:thinking]} #{agent}")} #{@pastel.dim("(#{entry[:model]})")}"
189
+
190
+ # Show actual tools (non-delegation)
191
+ if entry[:tools]&.any?
192
+ tools_list = entry[:tools].join(", ")
193
+ @output.puts "#{indent} #{@pastel.dim("Tools available: #{tools_list}")}"
194
+ end
195
+
196
+ # Show agents this agent can delegate to
197
+ if entry[:delegates_to]&.any?
198
+ delegates_list = entry[:delegates_to].map do |delegate_name|
199
+ delegate_color = get_agent_color(delegate_name.to_sym)
200
+ @pastel.public_send(delegate_color, delegate_name)
201
+ end.join(", ")
202
+ @output.puts "#{indent} #{@pastel.dim("Can delegate to:")} #{delegates_list}"
203
+ end
204
+
205
+ # Show spinner while waiting for response
206
+ start_spinner("#{indent} ", "#{@pastel.public_send(agent_color, agent)} is thinking...")
207
+ end
208
+
209
+ def handle_agent_step(entry)
210
+ # Agent made an intermediate response with tool calls
211
+ agent = entry[:agent]
212
+ indent = " " * (@agent_depth[agent] || 0)
213
+ get_agent_color(agent)
214
+
215
+ # Track usage for this LLM API call
216
+ if entry[:usage]
217
+ cost = entry[:usage][:total_cost] || 0.0
218
+ tokens = entry[:usage][:total_tokens] || 0
219
+ @total_cost += cost
220
+ @total_tokens += tokens
221
+
222
+ # Build usage text with per-turn tokens and cost
223
+ usage_parts = ["#{format_number(tokens)} tokens", format_cost(cost)]
224
+
225
+ # Add context usage if available (cumulative tracking)
226
+ if entry[:usage][:tokens_used_percentage]
227
+ usage_percentage = entry[:usage][:tokens_used_percentage]
228
+ tokens_remaining = entry[:usage][:tokens_remaining]
229
+
230
+ # Color code the percentage based on usage
231
+ percentage_display = format_context_percentage(usage_percentage)
232
+
233
+ if tokens_remaining
234
+ remaining_display = format_number(tokens_remaining)
235
+ usage_parts << "#{percentage_display} used, #{remaining_display} remaining"
236
+ else
237
+ # Context limit not available for this model
238
+ cumulative = entry[:usage][:cumulative_total_tokens]
239
+ usage_parts << "#{format_number(cumulative)} cumulative" if cumulative
240
+ end
241
+ end
242
+
243
+ usage_text = usage_parts.join(" #{@pastel.dim("โ”‚")} ")
244
+ @output.puts "#{indent} #{@pastel.dim(usage_text)}"
245
+ end
246
+
247
+ # Display the agent's thinking/explanation text if present
248
+ if entry[:content] && !entry[:content].empty?
249
+ # Filter out system reminders for cleaner display
250
+ thinking_text = strip_system_reminders(entry[:content])
251
+
252
+ unless thinking_text.empty?
253
+ @output.puts
254
+ # Display thinking text in italic for visual distinction
255
+ thinking_text.split("\n").each do |line|
256
+ @output.puts "#{indent} #{@pastel.italic(line)}"
257
+ end
258
+ end
259
+ end
260
+
261
+ # Show what the agent is doing (requesting tools)
262
+ tool_count = entry[:tool_calls]&.size || 0
263
+ if tool_count > 0
264
+ @output.puts "#{indent} #{@pastel.dim("โ†’ Requesting #{tool_count} tool#{"s" if tool_count > 1}...")}"
265
+ end
266
+
267
+ @output.puts
268
+ print_event_divider
269
+ end
270
+
271
+ def handle_agent_stop(entry)
272
+ # Agent completed with final response (no more tool calls)
273
+ agent = entry[:agent]
274
+ indent = " " * (@agent_depth[agent] || 0)
275
+ agent_color = get_agent_color(agent)
276
+ depth = @agent_depth[agent] || 0
277
+
278
+ # Track usage for this final LLM API call
279
+ if entry[:usage]
280
+ cost = entry[:usage][:total_cost] || 0.0
281
+ tokens = entry[:usage][:total_tokens] || 0
282
+ @total_cost += cost
283
+ @total_tokens += tokens
284
+
285
+ # Build usage text with per-turn tokens and cost
286
+ usage_parts = ["#{format_number(tokens)} tokens", format_cost(cost)]
287
+
288
+ # Add context usage if available (cumulative tracking)
289
+ if entry[:usage][:tokens_used_percentage]
290
+ usage_percentage = entry[:usage][:tokens_used_percentage]
291
+ tokens_remaining = entry[:usage][:tokens_remaining]
292
+
293
+ # Color code the percentage based on usage
294
+ percentage_display = format_context_percentage(usage_percentage)
295
+
296
+ if tokens_remaining
297
+ remaining_display = format_number(tokens_remaining)
298
+ usage_parts << "#{percentage_display} used, #{remaining_display} remaining"
299
+ else
300
+ # Context limit not available for this model
301
+ cumulative = entry[:usage][:cumulative_total_tokens]
302
+ usage_parts << "#{format_number(cumulative)} cumulative" if cumulative
303
+ end
304
+ end
305
+
306
+ usage_text = usage_parts.join(" #{@pastel.dim("โ”‚")} ")
307
+ @output.puts "#{indent} #{@pastel.dim(usage_text)}"
308
+ end
309
+
310
+ # Display final response
311
+ # Skip displaying content if this is a delegated agent (depth > 0)
312
+ # The content will be shown as a delegation_result to avoid duplication
313
+ if entry[:content] && !entry[:content].empty? && depth == 0
314
+ timestamp = format_timestamp(entry[:timestamp])
315
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.public_send(agent_color, "#{ICONS[:response]} #{agent}")} responded:"
316
+ display_response(entry[:content], indent)
317
+ end
318
+
319
+ @output.puts
320
+ @output.puts "#{indent}#{@pastel.green("#{ICONS[:success]} #{agent} completed")}"
321
+
322
+ @output.puts
323
+ print_event_divider
324
+ end
325
+
326
+ def handle_tool_call(entry)
327
+ @tool_call_count += 1
328
+ # Display tool call (non-delegation)
329
+ agent = entry[:agent]
330
+ indent = " " * (@agent_depth[agent] || 0)
331
+ agent_color = get_agent_color(agent)
332
+
333
+ timestamp = format_timestamp(entry[:timestamp])
334
+ tool_name = entry[:tool]
335
+ tool_call_id = entry[:tool_call_id]
336
+ args = entry[:arguments]
337
+
338
+ # Track this tool call for later matching with results
339
+ @recent_tool_calls[tool_call_id] = tool_name if tool_call_id
340
+
341
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.public_send(agent_color, agent)} #{@pastel.blue("#{ICONS[:tool]} uses tool")} #{@pastel.bold.blue(tool_name)}"
342
+
343
+ # Show arguments (skip for TodoWrite unless verbose mode)
344
+ show_args = args && !args.empty?
345
+ show_args &&= tool_name != "TodoWrite" || @verbose
346
+
347
+ if show_args
348
+ @output.puts "#{indent} #{@pastel.dim("Arguments:")}"
349
+ args.each do |key, value|
350
+ formatted_value = format_argument_value(value)
351
+ @output.puts "#{indent} #{@pastel.cyan("#{key}:")} #{formatted_value}"
352
+ end
353
+ end
354
+
355
+ @output.puts
356
+
357
+ # Show spinner while waiting for tool to execute
358
+ start_spinner("#{indent} ", "Executing #{@pastel.bold.blue(tool_name)}...")
359
+ end
360
+
361
+ def handle_agent_delegation(entry)
362
+ @tool_call_count += 1
363
+ # Display delegation to another agent
364
+ agent = entry[:agent]
365
+ delegate_to = entry[:delegate_to]
366
+ indent = " " * (@agent_depth[agent] || 0)
367
+ agent_color = get_agent_color(agent)
368
+ delegate_color = get_agent_color(delegate_to.to_sym)
369
+
370
+ timestamp = format_timestamp(entry[:timestamp])
371
+ args = entry[:arguments]
372
+
373
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.public_send(agent_color, agent)} #{@pastel.yellow("#{ICONS[:delegate]} delegates to")} #{@pastel.public_send(delegate_color, delegate_to)}"
374
+ @output.puts
375
+
376
+ if args && !args.empty?
377
+ @output.puts "#{indent} #{@pastel.dim("Arguments:")}"
378
+ args.each do |key, value|
379
+ formatted_value = format_argument_value(value)
380
+ @output.puts "#{indent} #{@pastel.cyan("#{key}:")} #{formatted_value}"
381
+ end
382
+ end
383
+
384
+ @output.puts
385
+
386
+ # Show spinner while waiting for delegated agent to respond
387
+ start_spinner(" #{indent} ", "Waiting for #{@pastel.public_send(delegate_color, delegate_to)}...")
388
+ end
389
+
390
+ def handle_tool_result(entry)
391
+ agent = entry[:agent]
392
+ indent = " " * (@agent_depth[agent] || 0)
393
+ agent_color = get_agent_color(agent)
394
+ tool_name = entry[:tool] || extract_tool_from_entry(entry)
395
+
396
+ timestamp = format_timestamp(entry[:timestamp])
397
+
398
+ # Check if this is a TodoWrite tool result - if so, show formatted todo list
399
+ if tool_name == "TodoWrite"
400
+ display_todo_list(agent, indent, timestamp)
401
+ else
402
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.green("#{ICONS[:result]} Tool result")} received by #{agent}"
403
+
404
+ result_text = entry[:result]
405
+ if result_text.is_a?(String) && !result_text.empty?
406
+ display_result(result_text, indent)
407
+ end
408
+ end
409
+
410
+ @output.puts
411
+ print_event_divider
412
+
413
+ # Show spinner while agent processes the result
414
+ start_spinner("#{indent} ", "#{@pastel.public_send(agent_color, agent)} processing tool result...")
415
+ end
416
+
417
+ def handle_delegation_result(entry)
418
+ agent = entry[:agent]
419
+ delegate_from = entry[:delegate_from]
420
+ indent = " " * (@agent_depth[agent] || 0)
421
+ agent_color = get_agent_color(agent)
422
+
423
+ timestamp = format_timestamp(entry[:timestamp])
424
+
425
+ # Show who sent the result to whom
426
+ if delegate_from
427
+ delegate_color = get_agent_color(delegate_from.to_sym)
428
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.green("#{ICONS[:result]} Delegation result")} from #{@pastel.public_send(delegate_color, delegate_from)} #{@pastel.dim("โ†’")} #{@pastel.public_send(agent_color, agent)}"
429
+ else
430
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.green("#{ICONS[:result]} Delegation result")} received by #{@pastel.public_send(agent_color, agent)}"
431
+ end
432
+
433
+ result_text = entry[:result]
434
+ if result_text.is_a?(String) && !result_text.empty?
435
+ display_result(result_text, indent)
436
+ end
437
+
438
+ @output.puts
439
+ print_event_divider
440
+
441
+ # Show spinner while agent processes the delegation result
442
+ start_spinner("#{indent} ", "#{@pastel.public_send(agent_color, agent)} processing result from #{@pastel.public_send(delegate_color, delegate_from)}...")
443
+ end
444
+
445
+ def handle_context_warning(entry)
446
+ agent = entry[:agent]
447
+ indent = " " * (@agent_depth[agent] || 0)
448
+ agent_color = get_agent_color(agent)
449
+ timestamp = format_timestamp(entry[:timestamp])
450
+
451
+ threshold = entry[:threshold]
452
+ current_usage = entry[:current_usage]
453
+ tokens_remaining = entry[:tokens_remaining]
454
+
455
+ # Color code based on threshold
456
+ warning_color = threshold == "90%" ? :red : :yellow
457
+ icon = threshold == "90%" ? "โš ๏ธ" : "โšก"
458
+
459
+ @output.puts
460
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.public_send(warning_color, "#{icon} CONTEXT WARNING")} #{@pastel.public_send(agent_color, agent)}"
461
+ @output.puts "#{indent} #{@pastel.public_send(warning_color, "Context usage: #{current_usage} (threshold: #{threshold})")}"
462
+ @output.puts "#{indent} #{@pastel.dim("Tokens remaining: #{format_number(tokens_remaining)}")}"
463
+ @output.puts
464
+ end
465
+
466
+ def handle_model_lookup_warning(entry)
467
+ agent = entry[:agent]
468
+ indent = " " * (@agent_depth[agent] || 0)
469
+ agent_color = get_agent_color(agent)
470
+ timestamp = format_timestamp(entry[:timestamp])
471
+
472
+ model = entry[:model]
473
+ error_message = entry[:error_message]
474
+ suggestions = entry[:suggestions] || []
475
+
476
+ @output.puts
477
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.yellow("โš ๏ธ MODEL WARNING")} #{@pastel.public_send(agent_color, agent)}"
478
+ @output.puts "#{indent} #{@pastel.yellow("Model '#{model}' not found in registry")}"
479
+
480
+ if suggestions.any?
481
+ @output.puts "#{indent} #{@pastel.dim("Did you mean one of these?")}"
482
+ suggestions.each do |suggestion|
483
+ model_id = suggestion[:id] || suggestion["id"]
484
+ context = suggestion[:context_window] || suggestion["context_window"]
485
+ context_display = context ? " (#{format_number(context)} tokens)" : ""
486
+ @output.puts "#{indent} #{@pastel.cyan("โ€ข")} #{@pastel.white(model_id)}#{@pastel.dim(context_display)}"
487
+ end
488
+ else
489
+ @output.puts "#{indent} #{@pastel.dim("Error: #{error_message}")}"
490
+ end
491
+
492
+ @output.puts "#{indent} #{@pastel.dim("Context tracking unavailable for this model.")}"
493
+ @output.puts
494
+ end
495
+
496
+ def handle_hook_executed(entry)
497
+ hook_event = entry[:hook_event]
498
+ agent = entry[:agent]
499
+ command = entry[:command]
500
+ exit_code = entry[:exit_code]
501
+ success = entry[:success]
502
+ stderr = entry[:stderr]
503
+ blocked = entry[:blocked]
504
+
505
+ indent = " " * (@agent_depth[agent] || 0)
506
+ agent_color = agent ? get_agent_color(agent.to_sym) : :white
507
+ timestamp = format_timestamp(entry[:timestamp])
508
+
509
+ # Determine hook display color and status
510
+ if blocked
511
+ # Exit code 2 - blocked execution
512
+ status_color = :red
513
+ ICONS[:error]
514
+ status_text = "BLOCKED"
515
+ elsif success
516
+ # Exit code 0 - success
517
+ status_color = :green
518
+ ICONS[:success]
519
+ status_text = "executed"
520
+ else
521
+ # Other exit codes - non-blocking error
522
+ status_color = :yellow
523
+ status_text = "warning"
524
+ end
525
+
526
+ # Format hook event name
527
+ hook_display = @pastel.cyan(hook_event || "unknown")
528
+
529
+ # Show hook execution
530
+ agent_display = agent ? @pastel.public_send(agent_color, agent) : ""
531
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.public_send(status_color, "#{ICONS[:hook]} Hook #{status_text}")} #{hook_display} #{agent_display}"
532
+
533
+ # Show command if verbose mode
534
+ if @verbose
535
+ @output.puts "#{indent} #{@pastel.dim("Command:")} #{@pastel.dim(command)}"
536
+ end
537
+
538
+ # Show stderr if present (errors or messages)
539
+ # For user_prompt blocks, show prominently since prompt was erased
540
+ if stderr && !stderr.empty?
541
+ if blocked && hook_event == "user_prompt"
542
+ # User prompt was blocked - show prominently to user
543
+ @output.puts
544
+ @output.puts "#{indent} #{@pastel.bold.red("โ›” Prompt Blocked by Hook:")}"
545
+ stderr.lines.each do |line|
546
+ @output.puts "#{indent} #{@pastel.red(line.chomp)}"
547
+ end
548
+ @output.puts "#{indent} #{@pastel.dim("(Prompt was not sent to the agent)")}"
549
+ elsif blocked
550
+ @output.puts "#{indent} #{@pastel.red("Blocked:")} #{@pastel.red(stderr)}"
551
+ else
552
+ @output.puts "#{indent} #{@pastel.yellow("Message:")} #{@pastel.dim(stderr)}"
553
+ end
554
+ end
555
+
556
+ # Show exit code in verbose mode
557
+ if @verbose && exit_code
558
+ code_color = if exit_code == 0
559
+ :green
560
+ else
561
+ (exit_code == 2 ? :red : :yellow)
562
+ end
563
+ @output.puts "#{indent} #{@pastel.dim("Exit code:")} #{@pastel.public_send(code_color, exit_code)}"
564
+ end
565
+
566
+ @output.puts
567
+
568
+ # Show spinner after hook execution so user knows agent is processing
569
+ # Skip spinner for user_prompt blocks (prompt was erased, execution stops)
570
+ if agent && !(blocked && hook_event == "user_prompt")
571
+ agent_display = @pastel.public_send(agent_color, agent)
572
+ if blocked
573
+ start_spinner("#{indent} ", "#{agent_display} adapting to blocked operation...")
574
+ else
575
+ start_spinner("#{indent} ", "#{agent_display} processing hook result...")
576
+ end
577
+ end
578
+ end
579
+
580
+ def display_result(content, indent)
581
+ # Filter out system reminders unless verbose mode is enabled
582
+ filtered_content = @verbose ? content : strip_system_reminders(content)
583
+
584
+ lines = filtered_content.split("\n")
585
+
586
+ # Truncate based on verbose flag
587
+ display_lines = if @verbose
588
+ # Verbose mode: show everything
589
+ lines
590
+ else
591
+ # Non-verbose mode: truncate to first 2 lines and 300 chars
592
+ truncate_tool_result(lines, filtered_content)
593
+ end
594
+
595
+ display_lines.each do |line|
596
+ @output.puts "#{indent} #{@pastel.bright_green(line)}"
597
+ end
598
+ end
599
+
600
+ def display_todo_list(agent, indent, timestamp)
601
+ # Get the current todos from TodoManager
602
+ todos = SwarmSDK::Tools::Stores::TodoManager.get_todos(agent.to_sym)
603
+
604
+ if todos.empty?
605
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.cyan("#{ICONS[:bullet]} Todo list")} updated (empty)"
606
+ return
607
+ end
608
+
609
+ @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.cyan("#{ICONS[:bullet]} Todo list")} updated:"
610
+ @output.puts
611
+
612
+ todos.each_with_index do |todo, index|
613
+ status = todo[:status] || todo["status"]
614
+ content = todo[:content] || todo["content"]
615
+ num = index + 1
616
+
617
+ # Format based on status
618
+ case status
619
+ when "completed"
620
+ # Strikethrough for completed - use dim and strikethrough
621
+ @output.puts "#{indent} #{@pastel.dim("#{num}.")} #{@pastel.dim.strikethrough(content)}"
622
+ when "in_progress"
623
+ # Bold for in progress items
624
+ @output.puts "#{indent} #{@pastel.bold.yellow("#{num}.")} #{@pastel.bold(content)}"
625
+ when "pending"
626
+ # Regular for pending
627
+ @output.puts "#{indent} #{@pastel.white("#{num}.")} #{content}"
628
+ else
629
+ # Fallback
630
+ @output.puts "#{indent} #{num}. #{content}"
631
+ end
632
+ end
633
+ end
634
+
635
+ def extract_tool_from_entry(entry)
636
+ # Try to extract tool name from the entry
637
+ # First check if it has :tool directly
638
+ return entry[:tool] if entry[:tool]
639
+
640
+ # Otherwise look up from tool_call_id
641
+ tool_call_id = entry[:tool_call_id]
642
+ return unless tool_call_id
643
+
644
+ @recent_tool_calls[tool_call_id]
645
+ end
646
+
647
+ def display_response(content, indent)
648
+ lines = content.split("\n")
649
+
650
+ # Only truncate if flag is enabled
651
+ display_lines = if @truncate
652
+ if lines.length > 12
653
+ lines.first(12) + ["", @pastel.dim("... (#{lines.length - 12} more lines)")]
654
+ elsif content.length > 500
655
+ [content[0..500], "", @pastel.dim("... (#{content.length} total chars)")]
656
+ else
657
+ lines
658
+ end
659
+ else
660
+ lines
661
+ end
662
+
663
+ display_lines.each do |line|
664
+ @output.puts "#{indent} #{line}"
665
+ end
666
+ end
667
+
668
+ def format_argument_value(value)
669
+ case value
670
+ when String
671
+ if @truncate && value.length > 300
672
+ lines = value.split("\n")
673
+ if lines.length > 3
674
+ preview = lines.first(3).join("\n")
675
+ "#{@pastel.white(preview)}\n#{@pastel.dim(" ... (#{lines.length} lines, #{value.length} chars)")}"
676
+ else
677
+ preview = value[0..300]
678
+ "#{@pastel.white(preview)}\n#{@pastel.dim(" ... (#{value.length} chars)")}"
679
+ end
680
+ else
681
+ @pastel.white(value)
682
+ end
683
+ when Array
684
+ @pastel.dim("[#{value.join(", ")}]")
685
+ when Hash
686
+ formatted = value.map { |k, v| "#{k}: #{v}" }.join(", ")
687
+ @pastel.dim("{#{formatted}}")
688
+ when Numeric
689
+ @pastel.white(value.to_s)
690
+ when TrueClass, FalseClass
691
+ @pastel.white(value.to_s)
692
+ when NilClass
693
+ @pastel.dim("nil")
694
+ else
695
+ @pastel.white(value.to_s)
696
+ end
697
+ end
698
+
699
+ def calculate_depth(agent)
700
+ @agents_seen.size == 1 ? 0 : 1
701
+ end
702
+
703
+ def get_agent_color(agent)
704
+ @agent_colors[agent] ||= begin
705
+ color = @color_palette[@next_color_index % @color_palette.length]
706
+ @next_color_index += 1
707
+ color
708
+ end
709
+ end
710
+
711
+ def print_header(swarm_name, lead_agent)
712
+ @output.puts
713
+ @output.puts @pastel.bold.bright_cyan("#{ICONS[:sparkles]} SwarmSDK - AI Agent Orchestration #{ICONS[:sparkles]}")
714
+ print_divider
715
+ @output.puts "#{@pastel.bold("Swarm:")} #{@pastel.cyan(swarm_name)}"
716
+ @output.puts "#{@pastel.bold("Lead Agent:")} #{@pastel.cyan(lead_agent)}"
717
+ @output.puts
718
+ end
719
+
720
+ def print_prompt(prompt)
721
+ @output.puts @pastel.bold("#{ICONS[:thinking]} Task Prompt:")
722
+ @output.puts @pastel.bright_white(prompt)
723
+ @output.puts
724
+ end
725
+
726
+ def print_divider
727
+ @output.puts @pastel.dim("โ”€" * @terminal_width)
728
+ end
729
+
730
+ def print_result(result)
731
+ @output.puts
732
+ @output.puts @pastel.bold.green("#{ICONS[:success]} Execution Complete")
733
+ @output.puts
734
+
735
+ if result.content && !result.content.empty?
736
+ @output.puts @pastel.bold("#{ICONS[:response]} Final Response from #{@pastel.public_send(get_agent_color(result.agent.to_sym), result.agent)}:")
737
+ @output.puts
738
+ print_divider
739
+
740
+ # Render markdown if content looks like markdown
741
+ content_to_display = if looks_like_markdown?(result.content)
742
+ begin
743
+ TTY::Markdown.parse(result.content)
744
+ rescue StandardError
745
+ result.content
746
+ end
747
+ else
748
+ result.content
749
+ end
750
+
751
+ @output.puts content_to_display
752
+ print_divider
753
+ @output.puts
754
+ end
755
+ end
756
+
757
+ def print_summary(result)
758
+ @output.puts @pastel.bold("#{ICONS[:info]} Execution Summary:")
759
+ @output.puts
760
+
761
+ # Agent involvement with colors
762
+ agents_display = @agents_seen.map do |agent|
763
+ color = get_agent_color(agent)
764
+ @pastel.public_send(color, agent)
765
+ end.join(", ")
766
+
767
+ @output.puts " #{ICONS[:agent]} #{@pastel.bold("Agents used:")} #{agents_display}"
768
+ @output.puts " #{ICONS[:llm]} #{@pastel.bold("LLM Requests:")} #{result.llm_requests}"
769
+ @output.puts " #{ICONS[:tool]} #{@pastel.bold("Tool Calls:")} #{result.tool_calls_count}"
770
+ @output.puts " #{ICONS[:tokens]} #{@pastel.bold("Total Tokens:")} #{format_number(result.total_tokens)}"
771
+ @output.puts " #{ICONS[:cost]} #{@pastel.bold("Total Cost:")} #{format_cost(result.total_cost)}"
772
+ @output.puts " #{ICONS[:time]} #{@pastel.bold("Duration:")} #{format_duration(result.duration)}"
773
+
774
+ @output.puts
775
+ end
776
+
777
+ def print_error(error)
778
+ @output.puts
779
+ @output.puts @pastel.bold.red("#{ICONS[:error]} Execution Failed")
780
+ @output.puts
781
+ @output.puts @pastel.red("Error: #{error.class.name}")
782
+ @output.puts @pastel.red(error.message)
783
+ @output.puts
784
+
785
+ if error.backtrace
786
+ @output.puts @pastel.dim("Backtrace:")
787
+ error.backtrace.first(5).each do |line|
788
+ @output.puts @pastel.dim(" #{line}")
789
+ end
790
+ @output.puts
791
+ end
792
+ end
793
+
794
+ def looks_like_markdown?(text)
795
+ text.match?(/^#+\s|^\*\s|^-\s|^\d+\.\s|```|\[.+\]\(.+\)/)
796
+ end
797
+
798
+ def format_cost(cost)
799
+ if cost < 0.01
800
+ @pastel.green("$#{format("%.6f", cost)}")
801
+ elsif cost < 1.0
802
+ @pastel.yellow("$#{format("%.4f", cost)}")
803
+ else
804
+ @pastel.red("$#{format("%.2f", cost)}")
805
+ end
806
+ end
807
+
808
+ def format_context_percentage(percentage_string)
809
+ # Extract numeric value from percentage string (e.g., "12.5%" -> 12.5)
810
+ percentage = percentage_string.to_s.gsub("%", "").to_f
811
+
812
+ if percentage < 50
813
+ @pastel.green(percentage_string)
814
+ elsif percentage < 80
815
+ @pastel.yellow(percentage_string)
816
+ else
817
+ @pastel.red(percentage_string)
818
+ end
819
+ end
820
+
821
+ def format_duration(seconds)
822
+ if seconds < 1
823
+ "#{(seconds * 1000).round}ms"
824
+ elsif seconds < 60
825
+ "#{seconds.round(2)}s"
826
+ else
827
+ minutes = (seconds / 60).floor
828
+ secs = (seconds % 60).round
829
+ "#{minutes}m #{secs}s"
830
+ end
831
+ end
832
+
833
+ def format_number(num)
834
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
835
+ end
836
+
837
+ def format_timestamp(timestamp)
838
+ return "" unless timestamp
839
+
840
+ time = Time.parse(timestamp)
841
+ time.strftime("[%H:%M:%S]")
842
+ rescue StandardError
843
+ ""
844
+ end
845
+
846
+ def print_event_divider
847
+ @output.puts @pastel.dim(" " + "ยท" * 60)
848
+ end
849
+
850
+ def start_spinner(indent, message)
851
+ return if @quiet
852
+
853
+ @active_spinner = TTY::Spinner.new("#{indent}:spinner #{message}", format: :dots, output: @output)
854
+ @active_spinner.auto_spin
855
+ end
856
+
857
+ def clear_spinner
858
+ return unless @active_spinner
859
+
860
+ @active_spinner.stop
861
+ @active_spinner = nil
862
+ end
863
+
864
+ def strip_system_reminders(content)
865
+ # Remove <system-reminder>...</system-reminder> blocks
866
+ content.gsub(%r{<system-reminder>.*?</system-reminder>}m, "").strip
867
+ end
868
+
869
+ def truncate_tool_result(lines, full_content)
870
+ # Truncate to first 2 lines and 300 characters
871
+ total_lines = lines.length
872
+ total_chars = full_content.length
873
+
874
+ # Take first 2 lines
875
+ preview_lines = lines.first(2)
876
+ preview_text = preview_lines.join("\n")
877
+
878
+ # Track what we're actually showing
879
+ shown_lines = preview_lines.length
880
+ shown_chars = preview_text.length
881
+
882
+ # If preview already exceeds 300 chars, truncate it
883
+ if preview_text.length > 300
884
+ preview_text = preview_text[0..299]
885
+ # Convert back to array with single truncated element
886
+ preview_lines = [preview_text]
887
+ shown_lines = 1 # We only show partial content now
888
+ shown_chars = 300
889
+ end
890
+
891
+ # Calculate what's hidden
892
+ if total_lines <= 2 && total_chars <= 300
893
+ # Nothing hidden, show as-is
894
+ preview_lines
895
+ else
896
+ # Build truncation message
897
+ hidden_parts = []
898
+
899
+ hidden_lines = total_lines - shown_lines
900
+ hidden_chars = total_chars - shown_chars
901
+
902
+ hidden_parts << "#{hidden_lines} more lines" if hidden_lines > 0
903
+ hidden_parts << "#{hidden_chars} more characters" if hidden_chars > 0
904
+
905
+ truncation_msg = @pastel.dim("... (#{hidden_parts.join(", ")})")
906
+
907
+ preview_lines + ["", truncation_msg]
908
+ end
909
+ end
910
+ end
911
+ end
912
+ end