swarm_cli 2.1.13 → 3.0.0.alpha2

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/exe/swarm3 +11 -0
  4. data/lib/swarm_cli/v3/activity_indicator.rb +168 -0
  5. data/lib/swarm_cli/v3/ansi_colors.rb +70 -0
  6. data/lib/swarm_cli/v3/cli.rb +721 -0
  7. data/lib/swarm_cli/v3/command_completer.rb +112 -0
  8. data/lib/swarm_cli/v3/display.rb +607 -0
  9. data/lib/swarm_cli/v3/dropdown.rb +130 -0
  10. data/lib/swarm_cli/v3/event_renderer.rb +161 -0
  11. data/lib/swarm_cli/v3/file_completer.rb +143 -0
  12. data/lib/swarm_cli/v3/raw_input_reader.rb +304 -0
  13. data/lib/swarm_cli/v3/reboot_tool.rb +123 -0
  14. data/lib/swarm_cli/v3/text_input.rb +235 -0
  15. data/lib/swarm_cli/v3.rb +52 -0
  16. metadata +30 -245
  17. data/exe/swarm +0 -6
  18. data/lib/swarm_cli/cli.rb +0 -201
  19. data/lib/swarm_cli/command_registry.rb +0 -61
  20. data/lib/swarm_cli/commands/mcp_serve.rb +0 -130
  21. data/lib/swarm_cli/commands/mcp_tools.rb +0 -148
  22. data/lib/swarm_cli/commands/migrate.rb +0 -55
  23. data/lib/swarm_cli/commands/run.rb +0 -173
  24. data/lib/swarm_cli/config_loader.rb +0 -98
  25. data/lib/swarm_cli/formatters/human_formatter.rb +0 -811
  26. data/lib/swarm_cli/formatters/json_formatter.rb +0 -62
  27. data/lib/swarm_cli/interactive_repl.rb +0 -895
  28. data/lib/swarm_cli/mcp_serve_options.rb +0 -44
  29. data/lib/swarm_cli/mcp_tools_options.rb +0 -59
  30. data/lib/swarm_cli/migrate_options.rb +0 -54
  31. data/lib/swarm_cli/migrator.rb +0 -132
  32. data/lib/swarm_cli/options.rb +0 -151
  33. data/lib/swarm_cli/ui/components/agent_badge.rb +0 -33
  34. data/lib/swarm_cli/ui/components/content_block.rb +0 -120
  35. data/lib/swarm_cli/ui/components/divider.rb +0 -57
  36. data/lib/swarm_cli/ui/components/panel.rb +0 -62
  37. data/lib/swarm_cli/ui/components/usage_stats.rb +0 -70
  38. data/lib/swarm_cli/ui/formatters/cost.rb +0 -49
  39. data/lib/swarm_cli/ui/formatters/number.rb +0 -58
  40. data/lib/swarm_cli/ui/formatters/text.rb +0 -77
  41. data/lib/swarm_cli/ui/formatters/time.rb +0 -73
  42. data/lib/swarm_cli/ui/icons.rb +0 -36
  43. data/lib/swarm_cli/ui/renderers/event_renderer.rb +0 -188
  44. data/lib/swarm_cli/ui/state/agent_color_cache.rb +0 -45
  45. data/lib/swarm_cli/ui/state/depth_tracker.rb +0 -40
  46. data/lib/swarm_cli/ui/state/spinner_manager.rb +0 -170
  47. data/lib/swarm_cli/ui/state/usage_tracker.rb +0 -62
  48. data/lib/swarm_cli/version.rb +0 -5
  49. data/lib/swarm_cli.rb +0 -46
@@ -1,895 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "reline"
4
- require "tty-spinner"
5
- require "tty-markdown"
6
- require "tty-box"
7
- require "pastel"
8
- require "async"
9
-
10
- module SwarmCLI
11
- # InteractiveREPL provides a professional, interactive terminal interface
12
- # for conversing with SwarmSDK agents.
13
- #
14
- # Features:
15
- # - Multiline input with intuitive submission (Enter on empty line or Ctrl+D)
16
- # - Beautiful Markdown rendering for agent responses
17
- # - Progress indicators during processing
18
- # - Command system (/help, /exit, /clear, etc.)
19
- # - Conversation history with context preservation
20
- # - Professional styling with Pastel and TTY tools
21
- #
22
- class InteractiveREPL
23
- COMMANDS = {
24
- "/help" => "Show available commands",
25
- "/clear" => "Clear the lead agent's conversation context",
26
- "/tools" => "List the lead agent's available tools",
27
- "/history" => "Show conversation history",
28
- "/defrag" => "Run memory defragmentation workflow (find and link related entries)",
29
- "/exit" => "Exit the REPL (or press Ctrl+D)",
30
- }.freeze
31
-
32
- # History configuration
33
- HISTORY_SIZE = 1000
34
-
35
- class << self
36
- # Get history file path (can be overridden with SWARM_HISTORY env var)
37
- def history_file
38
- ENV["SWARM_HISTORY"] || File.expand_path("~/.swarm/history")
39
- end
40
- end
41
-
42
- def initialize(swarm:, options:, initial_message: nil)
43
- @swarm = swarm
44
- @options = options
45
- @initial_message = initial_message
46
- @conversation_history = []
47
- @session_results = [] # Accumulate all results for session summary
48
- @validation_warnings_shown = false
49
-
50
- setup_ui_components
51
- setup_persistent_history
52
-
53
- # Create formatter for swarm execution output (interactive mode)
54
- @formatter = Formatters::HumanFormatter.new(
55
- output: $stdout,
56
- quiet: options.quiet?,
57
- truncate: options.truncate?,
58
- verbose: options.verbose?,
59
- mode: :interactive,
60
- )
61
- end
62
-
63
- def run
64
- display_welcome
65
-
66
- # Emit validation warnings before first prompt
67
- emit_validation_warnings_before_prompt
68
-
69
- # Send initial message if provided
70
- if @initial_message && !@initial_message.empty?
71
- handle_message(@initial_message)
72
- end
73
-
74
- main_loop
75
- display_goodbye
76
- display_session_summary
77
- rescue Interrupt
78
- puts "\n"
79
- display_goodbye
80
- display_session_summary
81
- exit(130)
82
- ensure
83
- # Defensive: ensure all spinners are stopped on exit
84
- @formatter&.spinner_manager&.stop_all
85
-
86
- # Save history on exit
87
- save_persistent_history
88
- end
89
-
90
- # Execute a message with Ctrl+C cancellation support
91
- # Public for testing
92
- #
93
- # Uses swarm.stop for thread-safe cancellation via IO.pipe signaling.
94
- # The INT trap handler calls swarm.stop which writes to the pipe,
95
- # waking the Async scheduler's stop listener to cancel all tasks.
96
- #
97
- # @param input [String] User input to execute
98
- # @return [SwarmSDK::Result, nil] Result or nil if cancelled
99
- def execute_with_cancellation(input, &log_callback)
100
- # Install trap ONLY during execution
101
- # swarm.stop is safe to call from trap context (IO.pipe write)
102
- old_trap = trap("INT") do
103
- @swarm.stop
104
- end
105
-
106
- result = @swarm.execute(input, &log_callback)
107
-
108
- result&.interrupted? ? nil : result
109
- ensure
110
- # CRITICAL: Restore old trap when done
111
- # This ensures Ctrl+C at the prompt still exits the REPL
112
- trap("INT", old_trap)
113
- end
114
-
115
- # Handle slash commands
116
- # Public for testing
117
- #
118
- # @param input [String] Command input (e.g., "/help", "/clear")
119
- def handle_command(input)
120
- command = input.split.first.downcase
121
-
122
- case command
123
- when "/help"
124
- display_help
125
- when "/clear"
126
- clear_context
127
- when "/tools"
128
- list_tools
129
- when "/history"
130
- display_history
131
- when "/defrag"
132
- defrag_memory
133
- when "/exit"
134
- # Break from main loop to trigger session summary
135
- throw(:exit_repl)
136
- else
137
- puts render_error("Unknown command: #{command}")
138
- puts @colors[:system].call("Type /help for available commands")
139
- end
140
- end
141
-
142
- # Save persistent history to file
143
- # Public for testing
144
- #
145
- # @return [void]
146
- def save_persistent_history
147
- history_file = self.class.history_file
148
- return unless history_file
149
-
150
- history = Reline::HISTORY.to_a
151
-
152
- # Limit to configured size
153
- if HISTORY_SIZE.positive? && history.size > HISTORY_SIZE
154
- history = history.last(HISTORY_SIZE)
155
- end
156
-
157
- # Write with secure permissions (owner read/write only)
158
- File.open(history_file, "w", 0o600, encoding: Encoding::UTF_8) do |f|
159
- # Handle multi-line entries by escaping newlines with backslash
160
- history.each do |entry|
161
- escaped = entry.scrub.split("\n").join("\\\n")
162
- f.puts(escaped)
163
- end
164
- end
165
- rescue Errno::EACCES, Errno::ENOENT
166
- # Can't write history - continue anyway
167
- nil
168
- end
169
-
170
- private
171
-
172
- def setup_ui_components
173
- @pastel = Pastel.new(enabled: $stdout.tty?)
174
-
175
- # Configure Reline for smooth, flicker-free input (like IRB)
176
- Reline.output = $stdout
177
- Reline.input = $stdin
178
-
179
- # Configure tab completion UI colors (Ruby 3.1+)
180
- configure_completion_ui
181
-
182
- # Enable automatic completions (show as you type)
183
- Reline.autocompletion = true
184
-
185
- # Configure word break characters
186
- Reline.completer_word_break_characters = " \t\n,;|&"
187
-
188
- # Disable default autocomplete (uses start_with? filtering)
189
- Reline.add_dialog_proc(:autocomplete, nil, nil)
190
-
191
- # Add custom fuzzy completion dialog (bypasses Reline's filtering)
192
- setup_fuzzy_completion
193
-
194
- # Rebind Tab to invoke our custom dialog (not the default :complete method)
195
- config = Reline.core.config
196
- config.add_default_key_binding_by_keymap(:emacs, [9], :fuzzy_complete)
197
- config.add_default_key_binding_by_keymap(:vi_insert, [9], :fuzzy_complete)
198
-
199
- # Configure history size
200
- Reline.core.config.history_size = HISTORY_SIZE
201
-
202
- # Setup colors using detached styles for performance
203
- @colors = {
204
- prompt: @pastel.bright_cyan.bold.detach,
205
- user_input: @pastel.white.detach,
206
- agent_text: @pastel.bright_white.detach,
207
- agent_label: @pastel.bright_blue.bold.detach,
208
- success: @pastel.bright_green.detach,
209
- success_icon: @pastel.bright_green.bold.detach,
210
- error: @pastel.bright_red.detach,
211
- error_icon: @pastel.bright_red.bold.detach,
212
- warning: @pastel.bright_yellow.detach,
213
- system: @pastel.dim.detach,
214
- system_bracket: @pastel.bright_black.detach,
215
- divider: @pastel.bright_black.detach,
216
- header: @pastel.bright_cyan.bold.detach,
217
- code: @pastel.bright_magenta.detach,
218
- }
219
- end
220
-
221
- def setup_persistent_history
222
- history_file = self.class.history_file
223
-
224
- # Ensure history directory exists
225
- FileUtils.mkdir_p(File.dirname(history_file))
226
-
227
- # Load history from file
228
- return unless File.exist?(history_file)
229
-
230
- File.open(history_file, "r:UTF-8") do |f|
231
- f.each_line do |line|
232
- line = line.chomp
233
-
234
- # Handle multi-line entries (backslash continuation)
235
- if Reline::HISTORY.last&.end_with?("\\")
236
- Reline::HISTORY.last.delete_suffix!("\\")
237
- Reline::HISTORY.last << "\n" << line
238
- else
239
- Reline::HISTORY << line unless line.empty?
240
- end
241
- end
242
- end
243
- rescue Errno::ENOENT, Errno::EACCES
244
- # History file doesn't exist or can't be read - that's OK
245
- nil
246
- end
247
-
248
- def display_welcome
249
- divider = @colors[:divider].call("─" * 60)
250
-
251
- puts ""
252
- puts divider
253
- puts @colors[:header].call("🚀 Swarm CLI Interactive REPL")
254
- puts divider
255
- puts ""
256
- puts @colors[:agent_text].call("Swarm: #{@swarm.name}")
257
- puts @colors[:system].call("Lead Agent: #{@swarm.lead_agent}")
258
- puts ""
259
- puts @colors[:system].call("Type your message and press Enter to submit")
260
- puts @colors[:system].call("Press Option+Enter (or ESC then Enter) for multi-line input")
261
- puts @colors[:system].call("Type #{@colors[:code].call("/help")} for commands or #{@colors[:code].call("/exit")} to quit")
262
- puts ""
263
- puts divider
264
- puts ""
265
- end
266
-
267
- def main_loop
268
- catch(:exit_repl) do
269
- loop do
270
- input = read_user_input
271
-
272
- break if input.nil? # Ctrl+D pressed
273
- next if input.strip.empty?
274
-
275
- if input.start_with?("/")
276
- handle_command(input.strip)
277
- else
278
- handle_message(input)
279
- end
280
-
281
- puts "" # Spacing between interactions
282
- end
283
- end
284
- end
285
-
286
- def read_user_input
287
- # Display stats separately (they scroll up naturally)
288
- display_prompt_stats
289
-
290
- # Build the prompt indicator with colors
291
- prompt_indicator = build_prompt_indicator
292
-
293
- # Use Reline.readmultiline for multi-line input support
294
- # - Option+ENTER (or ESC+ENTER): Adds a newline, continues editing
295
- # - Regular ENTER: Always submits immediately
296
- # Second parameter true = add to history for arrow up/down
297
- # Block always returns true = ENTER always submits
298
- input = Reline.readmultiline(prompt_indicator, true) { |_lines| true }
299
-
300
- return if input.nil? # Ctrl+D returns nil
301
-
302
- # Strip whitespace from the complete input
303
- input.strip
304
- end
305
-
306
- def display_prompt_stats
307
- # Only show stats if we have conversation history
308
- stats = build_prompt_stats
309
- puts stats if stats && !stats.empty?
310
- end
311
-
312
- def build_prompt_indicator
313
- # Reline supports ANSI colors without flickering!
314
- # Use your beautiful colored prompt
315
- @pastel.bright_cyan("You") +
316
- @pastel.bright_black(" ❯ ")
317
- end
318
-
319
- def build_prompt_stats
320
- return "" if @conversation_history.empty?
321
-
322
- parts = []
323
-
324
- # Agent name
325
- parts << @colors[:agent_label].call(@swarm.lead_agent.to_s)
326
-
327
- # Message count (user messages only)
328
- msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
329
- parts << "#{msg_count} #{msg_count == 1 ? "msg" : "msgs"}"
330
-
331
- # Get last result stats if available
332
- if @last_result
333
- # Token count
334
- tokens = @last_result.total_tokens
335
- if tokens > 0
336
- formatted_tokens = format_number(tokens)
337
- parts << "#{formatted_tokens} tokens"
338
- end
339
-
340
- # Cost
341
- cost = @last_result.total_cost
342
- if cost > 0
343
- formatted_cost = format_cost_value(cost)
344
- parts << formatted_cost
345
- end
346
-
347
- # Context percentage (from last log entry with usage info)
348
- if @last_context_percentage
349
- color_method = context_percentage_color(@last_context_percentage)
350
- colored_pct = @pastel.public_send(color_method, @last_context_percentage)
351
- parts << "#{colored_pct} context"
352
- end
353
- end
354
-
355
- "[#{parts.join(" • ")}]"
356
- end
357
-
358
- def format_number(num)
359
- if num >= 1_000_000
360
- "#{(num / 1_000_000.0).round(1)}M"
361
- elsif num >= 1_000
362
- "#{(num / 1_000.0).round(1)}K"
363
- else
364
- num.to_s
365
- end
366
- end
367
-
368
- def format_cost_value(cost)
369
- if cost < 0.01
370
- "$#{format("%.4f", cost)}"
371
- elsif cost < 1.0
372
- "$#{format("%.3f", cost)}"
373
- else
374
- "$#{format("%.2f", cost)}"
375
- end
376
- end
377
-
378
- def context_percentage_color(percentage_string)
379
- percentage = percentage_string.to_s.gsub("%", "").to_f
380
-
381
- if percentage < 50
382
- :green
383
- elsif percentage < 80
384
- :yellow
385
- else
386
- :red
387
- end
388
- end
389
-
390
- def handle_message(input)
391
- # Add to history
392
- @conversation_history << { role: "user", content: input }
393
-
394
- puts ""
395
-
396
- # Execute with cancellation support
397
- result = execute_with_cancellation(input) do |log_entry|
398
- # Skip model warnings - already emitted before first prompt
399
- next if log_entry[:type] == "model_lookup_warning"
400
-
401
- @formatter.on_log(log_entry)
402
-
403
- # Track context percentage from usage info
404
- if log_entry[:usage] && log_entry[:usage][:tokens_used_percentage]
405
- @last_context_percentage = log_entry[:usage][:tokens_used_percentage]
406
- end
407
- end
408
-
409
- # CRITICAL: Stop all spinners after execution completes
410
- # This ensures spinner doesn't interfere with error/success display or REPL prompt
411
- @formatter.spinner_manager.stop_all
412
-
413
- # Handle cancellation (result is nil when cancelled)
414
- if result.nil?
415
- puts ""
416
- puts @colors[:warning].call("✗ Request cancelled by user")
417
- puts ""
418
- return
419
- end
420
-
421
- # Check for errors
422
- if result.failure?
423
- @formatter.on_error(error: result.error, duration: result.duration)
424
- return
425
- end
426
-
427
- # Display success through formatter (minimal in interactive mode)
428
- @formatter.on_success(result: result)
429
-
430
- # Store result for prompt stats and session summary
431
- @last_result = result
432
- @session_results << result
433
-
434
- # Add response to history
435
- @conversation_history << { role: "agent", content: result.content }
436
- rescue StandardError => e
437
- # Defensive: ensure spinners are stopped on exception
438
- @formatter.spinner_manager.stop_all
439
- @formatter.on_error(error: e)
440
- end
441
-
442
- def emit_validation_warnings_before_prompt
443
- # Setup temporary logging to capture and display warnings
444
- SwarmSDK::LogCollector.on_log do |log_entry|
445
- @formatter.on_log(log_entry) if log_entry[:type] == "model_lookup_warning"
446
- end
447
-
448
- SwarmSDK::LogStream.emitter = SwarmSDK::LogCollector
449
-
450
- # Emit validation warnings as log events
451
- @swarm.emit_validation_warnings
452
-
453
- # Clean up
454
- SwarmSDK::LogCollector.reset!
455
- SwarmSDK::LogStream.reset!
456
-
457
- # Add spacing if warnings were shown
458
- puts "" if @swarm.validate.any?
459
- rescue StandardError
460
- # Ignore errors during validation emission
461
- begin
462
- SwarmSDK::LogCollector.reset!
463
- rescue
464
- nil
465
- end
466
- begin
467
- SwarmSDK::LogStream.reset!
468
- rescue
469
- nil
470
- end
471
- end
472
-
473
- def display_help
474
- help_box = TTY::Box.frame(
475
- @colors[:header].call("Available Commands:"),
476
- "",
477
- *COMMANDS.map do |cmd, desc|
478
- cmd_styled = @colors[:code].call(cmd.ljust(15))
479
- desc_styled = @colors[:system].call(desc)
480
- " #{cmd_styled} #{desc_styled}"
481
- end,
482
- "",
483
- @colors[:system].call("Input Tips:"),
484
- @colors[:system].call(" • Press Enter to submit your message"),
485
- @colors[:system].call(" • Press Option+Enter (or ESC then Enter) for multi-line input"),
486
- @colors[:system].call(" • Press Ctrl+C to cancel an ongoing request"),
487
- @colors[:system].call(" • Press Ctrl+D to exit"),
488
- @colors[:system].call(" • Use arrow keys for history and editing"),
489
- @colors[:system].call(" • Type / for commands or @ for file paths"),
490
- @colors[:system].call(" • Use Shift-Tab to navigate autocomplete menu"),
491
- border: :light,
492
- padding: [1, 2],
493
- align: :left,
494
- title: { top_left: " HELP " },
495
- style: {
496
- border: { fg: :bright_yellow },
497
- },
498
- )
499
-
500
- puts help_box
501
- end
502
-
503
- def clear_context
504
- # Get the lead agent
505
- lead = @swarm.agent(@swarm.lead_agent)
506
-
507
- # Clear the agent's conversation history
508
- lead.replace_messages([])
509
-
510
- # Clear REPL conversation history
511
- @conversation_history.clear
512
-
513
- # Display confirmation
514
- puts ""
515
- puts @colors[:success].call("✓ Conversation context cleared for #{@swarm.lead_agent}")
516
- puts @colors[:system].call(" Starting fresh - previous messages removed from context")
517
- puts ""
518
- end
519
-
520
- def list_tools
521
- # Get the lead agent
522
- lead = @swarm.agent(@swarm.lead_agent)
523
-
524
- # Get tools hash (tool_name => tool_instance)
525
- tools_hash = lead.tools
526
-
527
- puts ""
528
- puts @colors[:header].call("Available Tools for #{@swarm.lead_agent}:")
529
- puts @colors[:divider].call("─" * 60)
530
- puts ""
531
-
532
- if tools_hash.empty?
533
- puts @colors[:system].call("No tools available")
534
- return
535
- end
536
-
537
- # Group tools by category
538
- memory_tools = []
539
- standard_tools = []
540
- delegation_tools = []
541
- mcp_tools = []
542
- other_tools = []
543
-
544
- tools_hash.each_value do |tool|
545
- tool_name = tool.name
546
- case tool_name
547
- when /^Memory/, "LoadSkill"
548
- memory_tools << tool_name
549
- when /^WorkWith/
550
- delegation_tools << tool_name
551
- when /^mcp__/
552
- mcp_tools << tool_name
553
- when "Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob",
554
- "TodoWrite", "Think", "Clock", "WebFetch",
555
- "ScratchpadWrite", "ScratchpadRead", "ScratchpadList"
556
- standard_tools << tool_name
557
- else
558
- other_tools << tool_name
559
- end
560
- end
561
-
562
- # Display tools by category
563
- if standard_tools.any?
564
- puts @colors[:agent_label].call("Standard Tools:")
565
- standard_tools.sort.each do |name|
566
- puts @colors[:system].call(" • #{name}")
567
- end
568
- puts ""
569
- end
570
-
571
- if memory_tools.any?
572
- puts @colors[:agent_label].call("Memory Tools:")
573
- memory_tools.sort.each do |name|
574
- puts @colors[:system].call(" • #{name}")
575
- end
576
- puts ""
577
- end
578
-
579
- if delegation_tools.any?
580
- puts @colors[:agent_label].call("Delegation Tools:")
581
- delegation_tools.sort.each do |name|
582
- puts @colors[:system].call(" • #{name}")
583
- end
584
- puts ""
585
- end
586
-
587
- if mcp_tools.any?
588
- puts @colors[:agent_label].call("MCP Tools:")
589
- mcp_tools.sort.each do |name|
590
- puts @colors[:system].call(" • #{name}")
591
- end
592
- puts ""
593
- end
594
-
595
- if other_tools.any?
596
- puts @colors[:agent_label].call("Other Tools:")
597
- other_tools.sort.each do |name|
598
- puts @colors[:system].call(" • #{name}")
599
- end
600
- puts ""
601
- end
602
-
603
- puts @colors[:divider].call("─" * 60)
604
- puts @colors[:system].call("Total: #{tools_hash.size} tools")
605
- puts ""
606
- end
607
-
608
- def display_history
609
- if @conversation_history.empty?
610
- puts @colors[:system].call("No conversation history yet")
611
- return
612
- end
613
-
614
- puts @colors[:header].call("Conversation History:")
615
- puts @colors[:divider].call("─" * 60)
616
- puts ""
617
-
618
- @conversation_history.each_with_index do |entry, index|
619
- role_label = if entry[:role] == "user"
620
- @colors[:prompt].call("User")
621
- else
622
- @colors[:agent_label].call("Agent")
623
- end
624
-
625
- puts "#{index + 1}. #{role_label}:"
626
-
627
- # Truncate long messages in history view
628
- content = entry[:content]
629
- if content.length > 200
630
- content = content[0...200] + "..."
631
- end
632
-
633
- puts @colors[:system].call(" #{content.gsub("\n", "\n ")}")
634
- puts ""
635
- end
636
-
637
- puts @colors[:divider].call("─" * 60)
638
- end
639
-
640
- def defrag_memory
641
- puts ""
642
- puts @colors[:header].call("🔧 Memory Defragmentation Workflow")
643
- puts @colors[:divider].call("─" * 60)
644
- puts ""
645
-
646
- # Inject prompt to run find_related then link_related
647
- prompt = <<~PROMPT.strip
648
- Run memory defragmentation workflow:
649
-
650
- 1. First, run MemoryDefrag(action: "find_related") to discover related entries
651
- 2. Review the results carefully
652
- 3. Then run MemoryDefrag(action: "link_related", dry_run: false) to create bidirectional links
653
-
654
- Report what you found and what links were created.
655
- PROMPT
656
-
657
- handle_message(prompt)
658
- end
659
-
660
- def display_goodbye
661
- puts ""
662
- goodbye_text = @colors[:success].call("👋 Goodbye! Thanks for using Swarm CLI")
663
- puts goodbye_text
664
- puts ""
665
- end
666
-
667
- def display_session_summary
668
- return if @session_results.empty?
669
-
670
- # Calculate session totals
671
- total_tokens = @session_results.sum(&:total_tokens)
672
- total_cost = @session_results.sum(&:total_cost)
673
- total_llm_requests = @session_results.sum(&:llm_requests)
674
- total_tool_calls = @session_results.sum(&:tool_calls_count)
675
- all_agents = @session_results.flat_map(&:agents_involved).uniq
676
-
677
- # Get session duration (time from first to last message)
678
- session_duration = if @session_results.size > 1
679
- @session_results.map(&:duration).sum
680
- else
681
- @session_results.first&.duration || 0
682
- end
683
-
684
- # Render session summary
685
- divider = @colors[:divider].call("─" * 60)
686
- puts divider
687
- puts @colors[:header].call("📊 Session Summary")
688
- puts divider
689
- puts ""
690
-
691
- # Message count
692
- msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
693
- puts " #{@colors[:agent_label].call("Messages sent:")} #{msg_count}"
694
-
695
- # Agents used
696
- if all_agents.any?
697
- agents_list = all_agents.map { |agent| @colors[:agent_label].call(agent.to_s) }.join(", ")
698
- puts " #{@colors[:agent_label].call("Agents used:")} #{agents_list}"
699
- end
700
-
701
- # LLM requests
702
- puts " #{@colors[:system].call("LLM Requests:")} #{total_llm_requests}"
703
-
704
- # Tool calls
705
- puts " #{@colors[:system].call("Tool Calls:")} #{total_tool_calls}"
706
-
707
- # Tokens
708
- formatted_tokens = SwarmCLI::UI::Formatters::Number.format(total_tokens)
709
- puts " #{@colors[:system].call("Total Tokens:")} #{formatted_tokens}"
710
-
711
- # Cost (colored)
712
- formatted_cost = SwarmCLI::UI::Formatters::Cost.format(total_cost, pastel: @pastel)
713
- puts " #{@colors[:system].call("Total Cost:")} #{formatted_cost}"
714
-
715
- # Duration
716
- formatted_duration = SwarmCLI::UI::Formatters::Time.duration(session_duration)
717
- puts " #{@colors[:system].call("Session Duration:")} #{formatted_duration}"
718
-
719
- puts ""
720
- puts divider
721
- puts ""
722
- end
723
-
724
- def render_error(message)
725
- icon = @colors[:error_icon].call("✗")
726
- text = @colors[:error].call(message)
727
- "#{icon} #{text}"
728
- end
729
-
730
- def render_system_message(text)
731
- bracket_open = @colors[:system_bracket].call("[")
732
- bracket_close = @colors[:system_bracket].call("]")
733
- content = @colors[:system].call(text)
734
- "#{bracket_open}#{content}#{bracket_close}"
735
- end
736
-
737
- def configure_completion_ui
738
- # Only configure if Reline::Face is available (Ruby 3.1+)
739
- return unless defined?(Reline::Face)
740
-
741
- Reline::Face.config(:completion_dialog) do |conf|
742
- conf.define(:default, foreground: :white, background: :blue)
743
- conf.define(:enhanced, foreground: :black, background: :cyan) # Selected item
744
- conf.define(:scrollbar, foreground: :cyan, background: :blue)
745
- end
746
- rescue StandardError
747
- # Ignore errors if Face configuration fails
748
- end
749
-
750
- def setup_fuzzy_completion
751
- # Capture COMMANDS for use in lambda
752
- commands = COMMANDS
753
-
754
- # Capture file completion logic for use in lambda (since lambda runs in different context)
755
- file_completions = lambda do |target|
756
- has_at_prefix = target.start_with?("@")
757
- query = has_at_prefix ? target[1..] : target
758
-
759
- next Dir.glob("*").sort.first(20) if query.empty?
760
-
761
- # Find files matching query anywhere in path
762
- pattern = "**/*#{query}*"
763
- found = Dir.glob(pattern, File::FNM_CASEFOLD).reject do |path|
764
- path.split("/").any? { |part| part.start_with?(".") }
765
- end.sort.first(20)
766
-
767
- # Add @ prefix if needed
768
- has_at_prefix ? found.map { |p| "@#{p}" } : found
769
- end
770
-
771
- # Custom dialog proc for fuzzy file/command completion
772
- fuzzy_proc = lambda do
773
- # State: [pre, target, post, matches, pointer, navigating]
774
-
775
- # Check if this is a navigation key press
776
- is_nav_key = key&.match?(dialog.name)
777
-
778
- # If we were in navigation mode and user typed a regular key (not Tab), exit nav mode
779
- if !context.empty? && context.size >= 6 && context[5] && !is_nav_key
780
- context[5] = false # Exit navigation mode
781
- end
782
-
783
- # Early check: if user typed and current target has spaces, close dialog
784
- unless is_nav_key || context.empty?
785
- _, target_check, = retrieve_completion_block
786
- if target_check.include?(" ")
787
- context.clear
788
- return
789
- end
790
- end
791
-
792
- # Detect if we should recalculate matches
793
- should_recalculate = if context.empty?
794
- true # First time - initialize
795
- elsif is_nav_key
796
- false # Navigation key - don't recalculate, just cycle
797
- elsif context.size >= 6 && context[5]
798
- false # We're in navigation mode - keep matches stable
799
- else
800
- true # User typed something - recalculate
801
- end
802
-
803
- # Recalculate matches if user typed
804
- if should_recalculate
805
- preposing, target, postposing = retrieve_completion_block
806
-
807
- # Don't show completions if the target itself has spaces
808
- # (allows "@lib/swarm" in middle of sentence like "check @lib/swarm file")
809
- return if target.include?(" ")
810
-
811
- matches = if target.start_with?("/")
812
- # Command completions
813
- query = target[1..] || ""
814
- commands.keys.map(&:to_s).select do |cmd|
815
- query.empty? || cmd.downcase.include?(query.downcase)
816
- end.sort
817
- elsif target.start_with?("@") || target.include?("/")
818
- # File path completions - use captured lambda
819
- file_completions.call(target)
820
- end
821
-
822
- return if matches.nil? || matches.empty?
823
-
824
- # Store fresh values - not in navigation mode yet
825
- context.clear
826
- context.push(preposing, target, postposing, matches, 0, false)
827
- end
828
-
829
- # Use stored values
830
- stored_pre, _, stored_post, matches, pointer, _ = context
831
-
832
- # Handle navigation keys
833
- if is_nav_key
834
- # Check if Enter was pressed - close dialog without submitting
835
- # Must check key.char (not method_symbol, which is :fuzzy_complete when trapped)
836
- if key.char == "\r" || key.char == "\n"
837
- # Enter pressed - accept completion and close dialog
838
- # Clear context so dialog doesn't reappear
839
- context.clear
840
- return
841
- end
842
-
843
- # Update pointer (cycle through matches)
844
- # Tab is now bound to :fuzzy_complete, Shift-Tab to :completion_journey_up
845
- pointer = if key.method_symbol == :completion_journey_up
846
- # Shift-Tab - cycle backward
847
- (pointer - 1) % matches.size
848
- else
849
- # Tab (:fuzzy_complete) - cycle forward
850
- (pointer + 1) % matches.size
851
- end
852
-
853
- # Update line buffer with selected completion
854
- selected = matches[pointer]
855
-
856
- # Get current line editor state
857
- le = @line_editor
858
-
859
- new_line = stored_pre + selected + stored_post
860
- new_cursor = stored_pre.length + selected.bytesize
861
-
862
- # Update buffer using public APIs
863
- le.set_current_line(new_line)
864
- le.byte_pointer = new_cursor
865
-
866
- # Update state - mark as navigating so we don't recalculate
867
- context[4] = pointer
868
- context[5] = true # Now in navigation mode
869
- end
870
-
871
- # Set visual highlight
872
- dialog.pointer = pointer
873
-
874
- # Trap Shift-Tab and Enter (Tab is already bound to our dialog)
875
- dialog.trap_key = [[27, 91, 90], [13]]
876
-
877
- # Position dropdown
878
- x = [cursor_pos.x, 0].max
879
- y = 0
880
-
881
- # Return dialog
882
- Reline::DialogRenderInfo.new(
883
- pos: Reline::CursorPos.new(x, y),
884
- contents: matches,
885
- scrollbar: true,
886
- height: [15, matches.size].min,
887
- face: :completion_dialog,
888
- )
889
- end
890
-
891
- # Register the custom fuzzy dialog
892
- Reline.add_dialog_proc(:fuzzy_complete, fuzzy_proc, [])
893
- end
894
- end
895
- end