swarm_cli 2.0.0.pre.1 → 2.0.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 +4 -4
- data/lib/swarm_cli/cli.rb +21 -1
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +81 -24
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +404 -629
- data/lib/swarm_cli/interactive_repl.rb +640 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +53 -17
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_cli.rb +3 -1
- metadata +23 -17
@@ -2,79 +2,69 @@
|
|
2
2
|
|
3
3
|
module SwarmCLI
|
4
4
|
module Formatters
|
5
|
-
# HumanFormatter creates beautiful, detailed real-time output using
|
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
|
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
|
-
|
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
|
-
|
35
|
-
|
18
|
+
@mode = mode
|
19
|
+
|
20
|
+
# Initialize Pastel with TTY detection
|
36
21
|
@pastel = Pastel.new(enabled: output.tty?)
|
37
|
-
|
38
|
-
|
39
|
-
@
|
40
|
-
@
|
41
|
-
@
|
42
|
-
@
|
43
|
-
|
44
|
-
|
45
|
-
@
|
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
|
-
#
|
62
|
-
|
63
|
-
|
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
|
-
|
108
|
-
|
109
|
-
|
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
|
-
|
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
|
-
|
117
|
+
@output.puts @divider.full
|
119
118
|
print_error(error)
|
120
|
-
|
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
|
-
@
|
181
|
-
@
|
126
|
+
@usage_tracker.track_agent(agent)
|
127
|
+
@usage_tracker.track_llm_request(entry[:usage])
|
182
128
|
|
183
|
-
|
184
|
-
|
185
|
-
|
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
|
-
|
188
|
-
@output.puts
|
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
|
142
|
+
# Show tools available
|
191
143
|
if entry[:tools]&.any?
|
192
|
-
|
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
|
147
|
+
# Show delegation options
|
197
148
|
if entry[:delegates_to]&.any?
|
198
|
-
|
199
|
-
|
200
|
-
@
|
201
|
-
|
202
|
-
|
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
|
-
|
206
|
-
|
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
|
-
|
213
|
-
|
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
|
175
|
+
# Track usage
|
216
176
|
if entry[:usage]
|
217
|
-
|
218
|
-
|
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
|
-
|
244
|
-
@output.puts
|
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
|
191
|
+
# Display thinking text (if present)
|
248
192
|
if entry[:content] && !entry[:content].empty?
|
249
|
-
|
250
|
-
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
307
|
-
|
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
|
-
#
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
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
|
321
|
-
|
256
|
+
@output.puts @event_renderer.agent_completed(agent: agent)
|
322
257
|
@output.puts
|
323
|
-
|
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
|
-
|
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
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
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
|
-
|
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 &&=
|
275
|
+
show_args &&= entry[:tool] != "TodoWrite" || @verbose
|
346
276
|
|
347
277
|
if show_args
|
348
|
-
@output.puts
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
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
|
-
#
|
358
|
-
|
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
|
362
|
-
@tool_call_count += 1
|
363
|
-
# Display delegation to another agent
|
294
|
+
def handle_tool_result(entry)
|
364
295
|
agent = entry[:agent]
|
365
|
-
|
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
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
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
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
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
|
-
#
|
387
|
-
|
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
|
391
|
-
|
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
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
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
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
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
|
-
#
|
414
|
-
|
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
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
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
|
-
|
385
|
+
@output.puts @divider.event(indent: @depth_tracker.get(entry[:agent]))
|
440
386
|
|
441
|
-
#
|
442
|
-
|
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
|
-
#
|
456
|
-
|
457
|
-
icon = threshold == "90%" ? "⚠️" : "⚡"
|
401
|
+
# Determine warning severity
|
402
|
+
type = threshold == "90%" ? :error : :warning
|
458
403
|
|
459
|
-
@output.puts
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
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
|
-
|
477
|
-
|
478
|
-
|
421
|
+
lines = [
|
422
|
+
@pastel.yellow("Model '#{model}' not found in registry"),
|
423
|
+
]
|
479
424
|
|
480
425
|
if suggestions.any?
|
481
|
-
|
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 ? " (#{
|
486
|
-
|
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
|
-
|
434
|
+
lines << @pastel.dim("Error: #{error_message}")
|
490
435
|
end
|
491
436
|
|
492
|
-
|
493
|
-
|
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
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
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
|
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
|
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
|
-
|
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
|
581
|
-
|
582
|
-
|
530
|
+
def handle_breakpoint_enter(entry)
|
531
|
+
agent = entry[:agent]
|
532
|
+
event = entry[:event]
|
583
533
|
|
584
|
-
|
534
|
+
# Pause all spinners to allow clean interactive debugging
|
535
|
+
@spinner_manager.pause_all
|
585
536
|
|
586
|
-
#
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
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
|
-
|
596
|
-
|
597
|
-
|
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,
|
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(
|
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(
|
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
|
-
|
618
|
-
case status
|
572
|
+
line = case status
|
619
573
|
when "completed"
|
620
|
-
#
|
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
|
-
#
|
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
|
-
#
|
627
|
-
@output.puts "#{indent} #{@pastel.white("#{num}.")} #{content}"
|
578
|
+
"#{indent} #{@pastel.white("#{num}.")} #{content}"
|
628
579
|
else
|
629
|
-
#
|
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
|
-
|
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("#{
|
714
|
-
|
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("#{
|
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
|
-
|
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("#{
|
648
|
+
@output.puts @pastel.bold("#{SwarmCLI::UI::Icons::INFO} Execution Summary:")
|
759
649
|
@output.puts
|
760
650
|
|
761
|
-
#
|
762
|
-
agents_display = @
|
763
|
-
|
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
|
-
|
768
|
-
@output.puts " #{
|
769
|
-
@output.puts " #{
|
770
|
-
@output.puts " #{
|
771
|
-
@output.puts " #{
|
772
|
-
@output.puts " #{
|
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("#{
|
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
|
-
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
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
|