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.
- checksums.yaml +7 -0
- data/exe/swarm +6 -0
- data/lib/swarm_cli/cli.rb +149 -0
- data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
- data/lib/swarm_cli/commands/mcp_tools.rb +146 -0
- data/lib/swarm_cli/commands/run.rb +116 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +912 -0
- data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
- data/lib/swarm_cli/mcp_serve_options.rb +44 -0
- data/lib/swarm_cli/mcp_tools_options.rb +59 -0
- data/lib/swarm_cli/options.rb +115 -0
- data/lib/swarm_cli/version.rb +5 -0
- data/lib/swarm_cli.rb +36 -0
- metadata +283 -0
@@ -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
|