swarm_cli 2.0.0.pre.1 → 2.0.0

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.
@@ -2,79 +2,69 @@
2
2
 
3
3
  module SwarmCLI
4
4
  module Formatters
5
- # HumanFormatter creates beautiful, detailed real-time output using TTY toolkit.
5
+ # HumanFormatter creates beautiful, detailed real-time output using reusable UI components.
6
6
  # Shows everything: agent thinking, tool calls with arguments, results, responses.
7
- # Uses simple dividers for clean, readable output without box alignment issues.
7
+ # Uses clean component architecture for maintainability and testability.
8
+ #
9
+ # Modes:
10
+ # - :non_interactive - Full headers, task prompt, complete summary (for single execution)
11
+ # - :interactive - Minimal output for REPL (headers shown in welcome screen)
8
12
  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)
13
+ def initialize(output: $stdout, quiet: false, truncate: false, verbose: false, mode: :non_interactive)
30
14
  @output = output
31
15
  @quiet = quiet
32
16
  @truncate = truncate
33
17
  @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
18
+ @mode = mode
19
+
20
+ # Initialize Pastel with TTY detection
36
21
  @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
22
+
23
+ # Initialize state managers
24
+ @color_cache = SwarmCLI::UI::State::AgentColorCache.new
25
+ @depth_tracker = SwarmCLI::UI::State::DepthTracker.new
26
+ @usage_tracker = SwarmCLI::UI::State::UsageTracker.new
27
+ @spinner_manager = SwarmCLI::UI::State::SpinnerManager.new
28
+
29
+ # Initialize components
30
+ @divider = SwarmCLI::UI::Components::Divider.new(pastel: @pastel, terminal_width: TTY::Screen.width)
31
+ @agent_badge = SwarmCLI::UI::Components::AgentBadge.new(pastel: @pastel, color_cache: @color_cache)
32
+ @content_block = SwarmCLI::UI::Components::ContentBlock.new(pastel: @pastel)
33
+ @panel = SwarmCLI::UI::Components::Panel.new(pastel: @pastel)
34
+
35
+ # Initialize event renderer
36
+ @event_renderer = SwarmCLI::UI::Renderers::EventRenderer.new(
37
+ pastel: @pastel,
38
+ agent_badge: @agent_badge,
39
+ depth_tracker: @depth_tracker,
40
+ )
41
+
42
+ # Track last context percentage for warnings
43
+ @last_context_percentage = {}
44
+
45
+ # Start time tracking
46
46
  @start_time = nil
47
- @terminal_width = TTY::Screen.width
48
- @active_spinner = nil
49
- @recent_tool_calls = {} # tool_call_id => tool_name
50
47
  end
51
48
 
52
49
  # Called when swarm execution starts
53
50
  def on_start(config_path:, swarm_name:, lead_agent:, prompt:)
54
51
  @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
52
 
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...")
53
+ # Only show headers in non-interactive mode
54
+ if @mode == :non_interactive
55
+ print_header(swarm_name, lead_agent)
56
+ print_prompt(prompt)
57
+ @output.puts @divider.full
58
+ @output.puts @pastel.bold("#{SwarmCLI::UI::Icons::INFO} Execution Log:")
59
+ @output.puts
60
+ end
64
61
  end
65
62
 
66
63
  # Called for each log entry from SwarmSDK
67
64
  def on_log(entry)
68
65
  return if @quiet
69
66
 
70
- # Clear any active spinner before processing new event
71
- clear_spinner
72
-
73
67
  case entry[:type]
74
- when "swarm_start"
75
- handle_swarm_start(entry)
76
- when "swarm_stop"
77
- handle_swarm_stop(entry)
78
68
  when "user_prompt"
79
69
  handle_user_request(entry)
80
70
  when "agent_step"
@@ -99,452 +89,422 @@ module SwarmCLI
99
89
  handle_compression_completed(entry)
100
90
  when "hook_executed"
101
91
  handle_hook_executed(entry)
92
+ when "breakpoint_enter"
93
+ handle_breakpoint_enter(entry)
94
+ when "breakpoint_exit"
95
+ handle_breakpoint_exit(entry)
102
96
  end
103
97
  end
104
98
 
105
99
  # Called when swarm execution completes successfully
106
100
  def on_success(result:)
107
- clear_spinner
108
- @output.puts
109
- print_divider
101
+ if @mode == :non_interactive
102
+ # Full result display with summary
103
+ @output.puts
104
+ @output.puts @divider.full
105
+ end
106
+
107
+ # Print result (handles mode internally)
110
108
  print_result(result)
111
- print_summary(result)
109
+
110
+ # Only print summary in non-interactive mode
111
+ print_summary(result) if @mode == :non_interactive
112
112
  end
113
113
 
114
114
  # Called when swarm execution fails
115
115
  def on_error(error:, duration: nil)
116
- clear_spinner
117
116
  @output.puts
118
- print_divider
117
+ @output.puts @divider.full
119
118
  print_error(error)
120
- print_divider
119
+ @output.puts @divider.full
121
120
  end
122
121
 
123
122
  private
124
123
 
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
124
  def handle_user_request(entry)
179
125
  agent = entry[:agent]
180
- @agents_seen.add(agent)
181
- @llm_request_count += 1
126
+ @usage_tracker.track_agent(agent)
127
+ @usage_tracker.track_llm_request(entry[:usage])
182
128
 
183
- @agent_depth[agent] ||= calculate_depth(agent)
184
- indent = " " * @agent_depth[agent]
185
- agent_color = get_agent_color(agent)
129
+ # Stop any delegation waiting spinner (in case this agent was delegated to)
130
+ unless @quiet
131
+ delegation_spinner = "delegation_#{agent}".to_sym
132
+ @spinner_manager.stop(delegation_spinner) if @spinner_manager.active?(delegation_spinner)
133
+ end
186
134
 
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]})")}"
135
+ # Render agent thinking line
136
+ @output.puts @event_renderer.agent_thinking(
137
+ agent: agent,
138
+ model: entry[:model],
139
+ timestamp: entry[:timestamp],
140
+ )
189
141
 
190
- # Show actual tools (non-delegation)
142
+ # Show tools available
191
143
  if entry[:tools]&.any?
192
- tools_list = entry[:tools].join(", ")
193
- @output.puts "#{indent} #{@pastel.dim("Tools available: #{tools_list}")}"
144
+ @output.puts @event_renderer.tools_available(entry[:tools], indent: @depth_tracker.get(agent))
194
145
  end
195
146
 
196
- # Show agents this agent can delegate to
147
+ # Show delegation options
197
148
  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}"
149
+ @output.puts @event_renderer.delegates_to(
150
+ entry[:delegates_to],
151
+ indent: @depth_tracker.get(agent),
152
+ color_cache: @color_cache,
153
+ )
203
154
  end
204
155
 
205
- # Show spinner while waiting for response
206
- start_spinner("#{indent} ", "#{@pastel.public_send(agent_color, agent)} is thinking...")
156
+ @output.puts
157
+
158
+ # Start spinner for agent thinking
159
+ unless @quiet
160
+ spinner_key = "agent_#{agent}".to_sym
161
+ @spinner_manager.start(spinner_key, "#{agent} is thinking...")
162
+ end
207
163
  end
208
164
 
209
165
  def handle_agent_step(entry)
210
- # Agent made an intermediate response with tool calls
211
166
  agent = entry[:agent]
212
- indent = " " * (@agent_depth[agent] || 0)
213
- get_agent_color(agent)
167
+ indent_level = @depth_tracker.get(agent)
168
+
169
+ # Stop agent thinking spinner
170
+ unless @quiet
171
+ spinner_key = "agent_#{agent}".to_sym
172
+ @spinner_manager.stop(spinner_key)
173
+ end
214
174
 
215
- # Track usage for this LLM API call
175
+ # Track usage
216
176
  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
177
+ @usage_tracker.track_llm_request(entry[:usage])
178
+ @last_context_percentage[agent] = entry[:usage][:tokens_used_percentage]
242
179
 
243
- usage_text = usage_parts.join(" #{@pastel.dim("│")} ")
244
- @output.puts "#{indent} #{@pastel.dim(usage_text)}"
180
+ # Render usage stats
181
+ @output.puts @event_renderer.usage_stats(
182
+ tokens: entry[:usage][:total_tokens] || 0,
183
+ cost: entry[:usage][:total_cost] || 0.0,
184
+ context_pct: entry[:usage][:tokens_used_percentage],
185
+ remaining: entry[:usage][:tokens_remaining],
186
+ cumulative: entry[:usage][:cumulative_total_tokens],
187
+ indent: indent_level,
188
+ )
245
189
  end
246
190
 
247
- # Display the agent's thinking/explanation text if present
191
+ # Display thinking text (if present)
248
192
  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
193
+ thinking = @event_renderer.thinking_text(entry[:content], indent: indent_level)
194
+ @output.puts thinking unless thinking.empty?
195
+ @output.puts if thinking && !thinking.empty?
259
196
  end
260
197
 
261
- # Show what the agent is doing (requesting tools)
198
+ # Show tool request summary
262
199
  tool_count = entry[:tool_calls]&.size || 0
263
200
  if tool_count > 0
201
+ indent = @depth_tracker.indent(agent)
264
202
  @output.puts "#{indent} #{@pastel.dim("→ Requesting #{tool_count} tool#{"s" if tool_count > 1}...")}"
265
203
  end
266
204
 
267
205
  @output.puts
268
- print_event_divider
206
+ @output.puts @divider.event(indent: indent_level)
269
207
  end
270
208
 
271
209
  def handle_agent_stop(entry)
272
- # Agent completed with final response (no more tool calls)
273
210
  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
211
+ indent_level = @depth_tracker.get(agent)
305
212
 
306
- usage_text = usage_parts.join(" #{@pastel.dim("│")} ")
307
- @output.puts "#{indent} #{@pastel.dim(usage_text)}"
213
+ # Stop agent thinking spinner with success
214
+ unless @quiet
215
+ spinner_key = "agent_#{agent}".to_sym
216
+ @spinner_manager.success(spinner_key, "completed")
308
217
  end
309
218
 
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)
219
+ # Track usage
220
+ if entry[:usage]
221
+ @usage_tracker.track_llm_request(entry[:usage])
222
+ @last_context_percentage[agent] = entry[:usage][:tokens_used_percentage]
223
+
224
+ # Render usage stats
225
+ @output.puts @event_renderer.usage_stats(
226
+ tokens: entry[:usage][:total_tokens] || 0,
227
+ cost: entry[:usage][:total_cost] || 0.0,
228
+ context_pct: entry[:usage][:tokens_used_percentage],
229
+ remaining: entry[:usage][:tokens_remaining],
230
+ cumulative: entry[:usage][:cumulative_total_tokens],
231
+ indent: indent_level,
232
+ )
233
+ end
234
+
235
+ # Display final response (only for top-level agent in non-interactive mode)
236
+ # In interactive mode, response is shown by print_result to avoid duplication
237
+ if entry[:content] && !entry[:content].empty? && indent_level.zero? && @mode == :non_interactive
238
+ @output.puts @event_renderer.agent_response(
239
+ agent: agent,
240
+ timestamp: entry[:timestamp],
241
+ )
242
+
243
+ # Render response content
244
+ indent = @depth_tracker.indent(agent)
245
+ response_lines = entry[:content].split("\n")
246
+
247
+ if @truncate && response_lines.length > 12
248
+ response_lines.first(12).each { |line| @output.puts "#{indent} #{line}" }
249
+ @output.puts "#{indent} #{@pastel.dim("... (#{response_lines.length - 12} more lines)")}"
250
+ else
251
+ response_lines.each { |line| @output.puts "#{indent} #{line}" }
252
+ end
317
253
  end
318
254
 
319
255
  @output.puts
320
- @output.puts "#{indent}#{@pastel.green("#{ICONS[:success]} #{agent} completed")}"
321
-
256
+ @output.puts @event_renderer.agent_completed(agent: agent)
322
257
  @output.puts
323
- print_event_divider
258
+ @output.puts @divider.event(indent: indent_level)
324
259
  end
325
260
 
326
261
  def handle_tool_call(entry)
327
- @tool_call_count += 1
328
- # Display tool call (non-delegation)
329
262
  agent = entry[:agent]
330
- indent = " " * (@agent_depth[agent] || 0)
331
- agent_color = get_agent_color(agent)
263
+ @usage_tracker.track_tool_call(tool_call_id: entry[:tool_call_id], tool_name: entry[:tool])
332
264
 
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
265
+ # Render tool call event
266
+ @output.puts @event_renderer.tool_call(
267
+ agent: agent,
268
+ tool: entry[:tool],
269
+ timestamp: entry[:timestamp],
270
+ )
340
271
 
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)
272
+ # Show arguments (skip TodoWrite unless verbose)
273
+ args = entry[:arguments]
344
274
  show_args = args && !args.empty?
345
- show_args &&= tool_name != "TodoWrite" || @verbose
275
+ show_args &&= entry[:tool] != "TodoWrite" || @verbose
346
276
 
347
277
  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
278
+ @output.puts @event_renderer.tool_arguments(
279
+ args,
280
+ indent: @depth_tracker.get(agent),
281
+ truncate: @truncate,
282
+ )
353
283
  end
354
284
 
355
285
  @output.puts
356
286
 
357
- # Show spinner while waiting for tool to execute
358
- start_spinner("#{indent} ", "Executing #{@pastel.bold.blue(tool_name)}...")
287
+ # Start spinner for tool execution
288
+ unless @quiet || entry[:tool] == "TodoWrite"
289
+ spinner_key = "tool_#{entry[:tool_call_id]}".to_sym
290
+ @spinner_manager.start(spinner_key, "Executing #{entry[:tool]}...")
291
+ end
359
292
  end
360
293
 
361
- def handle_agent_delegation(entry)
362
- @tool_call_count += 1
363
- # Display delegation to another agent
294
+ def handle_tool_result(entry)
364
295
  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)
296
+ tool_name = entry[:tool] || @usage_tracker.tool_name_for(entry[:tool_call_id])
369
297
 
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
298
+ # Stop tool spinner with success
299
+ unless @quiet || tool_name == "TodoWrite"
300
+ spinner_key = "tool_#{entry[:tool_call_id]}".to_sym
301
+ @spinner_manager.success(spinner_key, "completed")
302
+ end
375
303
 
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}"
304
+ # Special handling for TodoWrite
305
+ if tool_name == "TodoWrite"
306
+ display_todo_list(agent, entry[:timestamp])
307
+ else
308
+ @output.puts @event_renderer.tool_result(
309
+ agent: agent,
310
+ timestamp: entry[:timestamp],
311
+ tool: tool_name,
312
+ )
313
+
314
+ # Render result content
315
+ if entry[:result].is_a?(String) && !entry[:result].empty?
316
+ result_text = @event_renderer.tool_result_content(
317
+ entry[:result],
318
+ indent: @depth_tracker.get(agent),
319
+ truncate: !@verbose,
320
+ )
321
+ @output.puts result_text unless result_text.empty?
381
322
  end
382
323
  end
383
324
 
384
325
  @output.puts
326
+ @output.puts @divider.event(indent: @depth_tracker.get(agent))
385
327
 
386
- # Show spinner while waiting for delegated agent to respond
387
- start_spinner(" #{indent} ", "Waiting for #{@pastel.public_send(delegate_color, delegate_to)}...")
328
+ # Start spinner for agent processing tool result
329
+ # The agent will determine what to do next (more tools or finish)
330
+ # This spinner will be stopped by the next agent_step or agent_stop event
331
+ unless @quiet
332
+ spinner_key = "agent_#{agent}".to_sym
333
+ indent = @depth_tracker.indent(agent)
334
+ @spinner_manager.start(spinner_key, "#{indent}#{agent} is processing...")
335
+ end
388
336
  end
389
337
 
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])
338
+ def handle_agent_delegation(entry)
339
+ @usage_tracker.track_tool_call
397
340
 
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}"
341
+ @output.puts @event_renderer.delegation(
342
+ from: entry[:agent],
343
+ to: entry[:delegate_to],
344
+ timestamp: entry[:timestamp],
345
+ )
346
+ @output.puts
403
347
 
404
- result_text = entry[:result]
405
- if result_text.is_a?(String) && !result_text.empty?
406
- display_result(result_text, indent)
407
- end
348
+ # Show arguments if present
349
+ if entry[:arguments] && !entry[:arguments].empty?
350
+ @output.puts @event_renderer.tool_arguments(
351
+ entry[:arguments],
352
+ indent: @depth_tracker.get(entry[:agent]),
353
+ truncate: @truncate,
354
+ )
408
355
  end
409
356
 
410
357
  @output.puts
411
- print_event_divider
412
358
 
413
- # Show spinner while agent processes the result
414
- start_spinner("#{indent} ", "#{@pastel.public_send(agent_color, agent)} processing tool result...")
359
+ # Start spinner waiting for delegated agent
360
+ unless @quiet
361
+ spinner_key = "delegation_#{entry[:delegate_to]}".to_sym
362
+ indent = @depth_tracker.indent(entry[:agent])
363
+ @spinner_manager.start(spinner_key, "#{indent}Waiting for #{entry[:delegate_to]}...")
364
+ end
415
365
  end
416
366
 
417
367
  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)
368
+ @output.puts @event_renderer.delegation_result(
369
+ from: entry[:delegate_from],
370
+ to: entry[:agent],
371
+ timestamp: entry[:timestamp],
372
+ )
373
+
374
+ # Render result content
375
+ if entry[:result].is_a?(String) && !entry[:result].empty?
376
+ result_text = @event_renderer.tool_result_content(
377
+ entry[:result],
378
+ indent: @depth_tracker.get(entry[:agent]),
379
+ truncate: !@verbose,
380
+ )
381
+ @output.puts result_text unless result_text.empty?
436
382
  end
437
383
 
438
384
  @output.puts
439
- print_event_divider
385
+ @output.puts @divider.event(indent: @depth_tracker.get(entry[:agent]))
440
386
 
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)}...")
387
+ # Start spinner for agent processing delegation result
388
+ unless @quiet
389
+ spinner_key = "agent_#{entry[:agent]}".to_sym
390
+ indent = @depth_tracker.indent(entry[:agent])
391
+ @spinner_manager.start(spinner_key, "#{indent}#{entry[:agent]} is processing...")
392
+ end
443
393
  end
444
394
 
445
395
  def handle_context_warning(entry)
446
396
  agent = entry[:agent]
447
- indent = " " * (@agent_depth[agent] || 0)
448
- agent_color = get_agent_color(agent)
449
- timestamp = format_timestamp(entry[:timestamp])
450
-
451
397
  threshold = entry[:threshold]
452
398
  current_usage = entry[:current_usage]
453
399
  tokens_remaining = entry[:tokens_remaining]
454
400
 
455
- # Color code based on threshold
456
- warning_color = threshold == "90%" ? :red : :yellow
457
- icon = threshold == "90%" ? "⚠️" : "⚡"
401
+ # Determine warning severity
402
+ type = threshold == "90%" ? :error : :warning
458
403
 
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
404
+ @output.puts @panel.render(
405
+ type: type,
406
+ title: "CONTEXT WARNING #{@agent_badge.render(agent)}",
407
+ lines: [
408
+ @pastel.public_send((type == :error ? :red : :yellow), "Context usage: #{current_usage} (threshold: #{threshold})"),
409
+ @pastel.dim("Tokens remaining: #{SwarmCLI::UI::Formatters::Number.format(tokens_remaining)}"),
410
+ ],
411
+ indent: @depth_tracker.get(agent),
412
+ )
464
413
  end
465
414
 
466
415
  def handle_model_lookup_warning(entry)
467
416
  agent = entry[:agent]
468
- indent = " " * (@agent_depth[agent] || 0)
469
- agent_color = get_agent_color(agent)
470
- timestamp = format_timestamp(entry[:timestamp])
471
-
472
417
  model = entry[:model]
473
418
  error_message = entry[:error_message]
474
419
  suggestions = entry[:suggestions] || []
475
420
 
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")}"
421
+ lines = [
422
+ @pastel.yellow("Model '#{model}' not found in registry"),
423
+ ]
479
424
 
480
425
  if suggestions.any?
481
- @output.puts "#{indent} #{@pastel.dim("Did you mean one of these?")}"
426
+ lines << @pastel.dim("Did you mean one of these?")
482
427
  suggestions.each do |suggestion|
483
428
  model_id = suggestion[:id] || suggestion["id"]
484
429
  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)}"
430
+ context_display = context ? " (#{SwarmCLI::UI::Formatters::Number.format(context)} tokens)" : ""
431
+ lines << " #{@pastel.cyan("•")} #{@pastel.white(model_id)}#{@pastel.dim(context_display)}"
487
432
  end
488
433
  else
489
- @output.puts "#{indent} #{@pastel.dim("Error: #{error_message}")}"
434
+ lines << @pastel.dim("Error: #{error_message}")
490
435
  end
491
436
 
492
- @output.puts "#{indent} #{@pastel.dim("Context tracking unavailable for this model.")}"
493
- @output.puts
437
+ lines << @pastel.dim("Context tracking unavailable for this model.")
438
+
439
+ @output.puts @panel.render(
440
+ type: :warning,
441
+ title: "MODEL WARNING #{@agent_badge.render(agent)}",
442
+ lines: lines,
443
+ indent: 0, # Always at root level (warnings shown at boot, not during execution)
444
+ )
445
+ end
446
+
447
+ def handle_compression_started(entry)
448
+ agent = entry[:agent]
449
+ message_count = entry[:message_count]
450
+ estimated_tokens = entry[:estimated_tokens]
451
+
452
+ @output.puts @panel.render(
453
+ type: :info,
454
+ title: "CONTEXT COMPRESSION #{@agent_badge.render(agent)}",
455
+ lines: [
456
+ @pastel.dim("Compressing #{message_count} messages (~#{SwarmCLI::UI::Formatters::Number.format(estimated_tokens)} tokens)..."),
457
+ ],
458
+ indent: @depth_tracker.get(agent),
459
+ )
460
+ end
461
+
462
+ def handle_compression_completed(entry)
463
+ agent = entry[:agent]
464
+ original_messages = entry[:original_message_count]
465
+ compressed_messages = entry[:compressed_message_count]
466
+ messages_removed = entry[:messages_removed]
467
+ original_tokens = entry[:original_tokens]
468
+ compressed_tokens = entry[:compressed_tokens]
469
+ compression_ratio = entry[:compression_ratio]
470
+ time_taken = entry[:time_taken]
471
+
472
+ @output.puts @panel.render(
473
+ type: :success,
474
+ title: "COMPRESSION COMPLETE #{@agent_badge.render(agent)}",
475
+ lines: [
476
+ "#{@pastel.dim("Messages:")} #{original_messages} → #{compressed_messages} #{@pastel.green("(-#{messages_removed})")}",
477
+ "#{@pastel.dim("Tokens:")} #{SwarmCLI::UI::Formatters::Number.format(original_tokens)} → #{SwarmCLI::UI::Formatters::Number.format(compressed_tokens)} #{@pastel.green("(#{(compression_ratio * 100).round(1)}%)")}",
478
+ "#{@pastel.dim("Time taken:")} #{SwarmCLI::UI::Formatters::Time.duration(time_taken)}",
479
+ ],
480
+ indent: @depth_tracker.get(agent),
481
+ )
494
482
  end
495
483
 
496
484
  def handle_hook_executed(entry)
497
485
  hook_event = entry[:hook_event]
498
486
  agent = entry[:agent]
499
- command = entry[:command]
500
- exit_code = entry[:exit_code]
501
487
  success = entry[:success]
502
- stderr = entry[:stderr]
503
488
  blocked = entry[:blocked]
489
+ stderr = entry[:stderr]
490
+ exit_code = entry[:exit_code]
504
491
 
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
492
+ @output.puts @event_renderer.hook_executed(
493
+ hook_event: hook_event,
494
+ agent: agent,
495
+ timestamp: entry[:timestamp],
496
+ success: success,
497
+ blocked: blocked,
498
+ )
537
499
 
538
- # Show stderr if present (errors or messages)
539
- # For user_prompt blocks, show prominently since prompt was erased
500
+ # Show stderr if present
540
501
  if stderr && !stderr.empty?
502
+ indent = @depth_tracker.indent(agent)
503
+
541
504
  if blocked && hook_event == "user_prompt"
542
- # User prompt was blocked - show prominently to user
543
505
  @output.puts
544
506
  @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
507
+ stderr.lines.each { |line| @output.puts "#{indent} #{@pastel.red(line.chomp)}" }
548
508
  @output.puts "#{indent} #{@pastel.dim("(Prompt was not sent to the agent)")}"
549
509
  elsif blocked
550
510
  @output.puts "#{indent} #{@pastel.red("Blocked:")} #{@pastel.red(stderr)}"
@@ -555,7 +515,8 @@ module SwarmCLI
555
515
 
556
516
  # Show exit code in verbose mode
557
517
  if @verbose && exit_code
558
- code_color = if exit_code == 0
518
+ indent = @depth_tracker.indent(agent)
519
+ code_color = if exit_code.zero?
559
520
  :green
560
521
  else
561
522
  (exit_code == 2 ? :red : :yellow)
@@ -564,49 +525,43 @@ module SwarmCLI
564
525
  end
565
526
 
566
527
  @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
528
  end
579
529
 
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)
530
+ def handle_breakpoint_enter(entry)
531
+ agent = entry[:agent]
532
+ event = entry[:event]
583
533
 
584
- lines = filtered_content.split("\n")
534
+ # Pause all spinners to allow clean interactive debugging
535
+ @spinner_manager.pause_all
585
536
 
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
537
+ # Show debugging notice
538
+ @output.puts
539
+ @output.puts @pastel.yellow("#{SwarmCLI::UI::Icons::THINKING} Breakpoint: Entering interactive debugging (#{event} hook)")
540
+ @output.puts @pastel.dim(" Agent: #{agent}")
541
+ @output.puts @pastel.dim(" Type 'exit' to continue execution")
542
+ @output.puts
543
+ end
594
544
 
595
- display_lines.each do |line|
596
- @output.puts "#{indent} #{@pastel.bright_green(line)}"
597
- end
545
+ def handle_breakpoint_exit(entry)
546
+ # Resume all spinners after debugging
547
+ @spinner_manager.resume_all
548
+
549
+ @output.puts
550
+ @output.puts @pastel.green("#{SwarmCLI::UI::Icons::SUCCESS} Breakpoint: Resuming execution")
551
+ @output.puts
598
552
  end
599
553
 
600
- def display_todo_list(agent, indent, timestamp)
601
- # Get the current todos from TodoManager
554
+ def display_todo_list(agent, timestamp)
602
555
  todos = SwarmSDK::Tools::Stores::TodoManager.get_todos(agent.to_sym)
556
+ indent = @depth_tracker.indent(agent)
557
+ time = SwarmCLI::UI::Formatters::Time.timestamp(timestamp)
603
558
 
604
559
  if todos.empty?
605
- @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.cyan("#{ICONS[:bullet]} Todo list")} updated (empty)"
560
+ @output.puts "#{indent}#{@pastel.dim(time)} #{@pastel.cyan("#{SwarmCLI::UI::Icons::BULLET} Todo list")} updated (empty)"
606
561
  return
607
562
  end
608
563
 
609
- @output.puts "#{indent}#{@pastel.dim(timestamp)} #{@pastel.cyan("#{ICONS[:bullet]} Todo list")} updated:"
564
+ @output.puts "#{indent}#{@pastel.dim(time)} #{@pastel.cyan("#{SwarmCLI::UI::Icons::BULLET} Todo list")} updated:"
610
565
  @output.puts
611
566
 
612
567
  todos.each_with_index do |todo, index|
@@ -614,129 +569,41 @@ module SwarmCLI
614
569
  content = todo[:content] || todo["content"]
615
570
  num = index + 1
616
571
 
617
- # Format based on status
618
- case status
572
+ line = case status
619
573
  when "completed"
620
- # Strikethrough for completed - use dim and strikethrough
621
- @output.puts "#{indent} #{@pastel.dim("#{num}.")} #{@pastel.dim.strikethrough(content)}"
574
+ "#{indent} #{@pastel.dim("#{num}.")} #{@pastel.dim.strikethrough(content)}"
622
575
  when "in_progress"
623
- # Bold for in progress items
624
- @output.puts "#{indent} #{@pastel.bold.yellow("#{num}.")} #{@pastel.bold(content)}"
576
+ "#{indent} #{@pastel.bold.yellow("#{num}.")} #{@pastel.bold(content)}"
625
577
  when "pending"
626
- # Regular for pending
627
- @output.puts "#{indent} #{@pastel.white("#{num}.")} #{content}"
578
+ "#{indent} #{@pastel.white("#{num}.")} #{content}"
628
579
  else
629
- # Fallback
630
- @output.puts "#{indent} #{num}. #{content}"
580
+ "#{indent} #{num}. #{content}"
631
581
  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
582
 
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
583
+ @output.puts line
708
584
  end
709
585
  end
710
586
 
711
587
  def print_header(swarm_name, lead_agent)
712
588
  @output.puts
713
- @output.puts @pastel.bold.bright_cyan("#{ICONS[:sparkles]} SwarmSDK - AI Agent Orchestration #{ICONS[:sparkles]}")
714
- print_divider
589
+ @output.puts @pastel.bold.bright_cyan("#{SwarmCLI::UI::Icons::SPARKLES} SwarmSDK - AI Agent Orchestration #{SwarmCLI::UI::Icons::SPARKLES}")
590
+ @output.puts @divider.full
715
591
  @output.puts "#{@pastel.bold("Swarm:")} #{@pastel.cyan(swarm_name)}"
716
592
  @output.puts "#{@pastel.bold("Lead Agent:")} #{@pastel.cyan(lead_agent)}"
717
593
  @output.puts
718
594
  end
719
595
 
720
596
  def print_prompt(prompt)
721
- @output.puts @pastel.bold("#{ICONS[:thinking]} Task Prompt:")
597
+ @output.puts @pastel.bold("#{SwarmCLI::UI::Icons::THINKING} Task Prompt:")
722
598
  @output.puts @pastel.bright_white(prompt)
723
599
  @output.puts
724
600
  end
725
601
 
726
- def print_divider
727
- @output.puts @pastel.dim("─" * @terminal_width)
728
- end
729
-
730
602
  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
603
+ return unless result.content && !result.content.empty?
739
604
 
605
+ # Interactive mode: Just show the response content directly
606
+ if @mode == :interactive
740
607
  # Render markdown if content looks like markdown
741
608
  content_to_display = if looks_like_markdown?(result.content)
742
609
  begin
@@ -749,164 +616,72 @@ module SwarmCLI
749
616
  end
750
617
 
751
618
  @output.puts content_to_display
752
- print_divider
753
619
  @output.puts
620
+ return
754
621
  end
622
+
623
+ # Non-interactive mode: Full result display with header and dividers
624
+ @output.puts
625
+ @output.puts @pastel.bold.green("#{SwarmCLI::UI::Icons::SUCCESS} Execution Complete")
626
+ @output.puts
627
+ @output.puts @pastel.bold("#{SwarmCLI::UI::Icons::RESPONSE} Final Response from #{@agent_badge.render(result.agent)}:")
628
+ @output.puts
629
+ @output.puts @divider.full
630
+
631
+ # Render markdown if content looks like markdown
632
+ content_to_display = if looks_like_markdown?(result.content)
633
+ begin
634
+ TTY::Markdown.parse(result.content)
635
+ rescue StandardError
636
+ result.content
637
+ end
638
+ else
639
+ result.content
640
+ end
641
+
642
+ @output.puts content_to_display
643
+ @output.puts @divider.full
644
+ @output.puts
755
645
  end
756
646
 
757
647
  def print_summary(result)
758
- @output.puts @pastel.bold("#{ICONS[:info]} Execution Summary:")
648
+ @output.puts @pastel.bold("#{SwarmCLI::UI::Icons::INFO} Execution Summary:")
759
649
  @output.puts
760
650
 
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(", ")
651
+ # Agents used (colored list)
652
+ agents_display = @agent_badge.render_list(@usage_tracker.agents)
653
+ @output.puts " #{SwarmCLI::UI::Icons::AGENT} #{@pastel.bold("Agents used:")} #{agents_display}"
766
654
 
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)}"
655
+ # Metrics
656
+ @output.puts " #{SwarmCLI::UI::Icons::LLM} #{@pastel.bold("LLM Requests:")} #{result.llm_requests}"
657
+ @output.puts " #{SwarmCLI::UI::Icons::TOOL} #{@pastel.bold("Tool Calls:")} #{result.tool_calls_count}"
658
+ @output.puts " #{SwarmCLI::UI::Icons::TOKENS} #{@pastel.bold("Total Tokens:")} #{SwarmCLI::UI::Formatters::Number.format(result.total_tokens)}"
659
+ @output.puts " #{SwarmCLI::UI::Icons::COST} #{@pastel.bold("Total Cost:")} #{SwarmCLI::UI::Formatters::Cost.format(result.total_cost, pastel: @pastel)}"
660
+ @output.puts " #{SwarmCLI::UI::Icons::TIME} #{@pastel.bold("Duration:")} #{SwarmCLI::UI::Formatters::Time.duration(result.duration)}"
773
661
 
774
662
  @output.puts
775
663
  end
776
664
 
777
665
  def print_error(error)
778
666
  @output.puts
779
- @output.puts @pastel.bold.red("#{ICONS[:error]} Execution Failed")
667
+ @output.puts @pastel.bold.red("#{SwarmCLI::UI::Icons::ERROR} Execution Failed")
780
668
  @output.puts
781
669
  @output.puts @pastel.red("Error: #{error.class.name}")
782
670
  @output.puts @pastel.red(error.message)
783
671
  @output.puts
784
672
 
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
673
+ return unless error.backtrace
674
+
675
+ @output.puts @pastel.dim("Backtrace:")
676
+ error.backtrace.first(5).each do |line|
677
+ @output.puts @pastel.dim(" #{line}")
791
678
  end
679
+ @output.puts
792
680
  end
793
681
 
794
682
  def looks_like_markdown?(text)
795
683
  text.match?(/^#+\s|^\*\s|^-\s|^\d+\.\s|```|\[.+\]\(.+\)/)
796
684
  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
685
  end
911
686
  end
912
687
  end