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
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module V3
5
+ # Reusable dropdown component for displaying and navigating a list of items.
6
+ #
7
+ # The Dropdown component provides a simple, modular interface for list selection
8
+ # without any knowledge of file systems, autocomplete, or terminal rendering.
9
+ # It manages state (items, selection) and provides methods for navigation and
10
+ # rendering.
11
+ #
12
+ # @example Basic usage
13
+ # dropdown = Dropdown.new(items: ["option1", "option2", "option3"])
14
+ # dropdown.select_next # Move down
15
+ # dropdown.selected_item # => "option2"
16
+ # dropdown.render # => ["1. option1", "2. \e[7moption2\e[0m", ...]
17
+ #
18
+ # @example Limited visibility
19
+ # dropdown = Dropdown.new(items: (1..100).to_a, max_visible: 5)
20
+ # dropdown.render.size # => 5 (only shows first 5 items)
21
+ class Dropdown
22
+ # @return [Array<String>] the list of items in the dropdown
23
+ attr_reader :items
24
+
25
+ # @return [Integer] the currently selected index (0-based)
26
+ attr_reader :selected_index
27
+
28
+ # Create a new dropdown with the given items.
29
+ #
30
+ # @param items [Array<String>] the list of items to display
31
+ # @param max_visible [Integer] maximum number of items to show at once
32
+ #
33
+ # @raise [ArgumentError] if items is empty
34
+ #
35
+ # @example
36
+ # dropdown = Dropdown.new(items: ["file1.rb", "file2.rb"], max_visible: 5)
37
+ def initialize(items:, max_visible: 5)
38
+ raise ArgumentError, "items cannot be empty" if items.empty?
39
+
40
+ @items = items
41
+ @selected_index = 0
42
+ @max_visible = max_visible
43
+ end
44
+
45
+ # Move selection to the previous item (wraps to bottom).
46
+ #
47
+ # @return [void]
48
+ #
49
+ # @example
50
+ # dropdown.selected_index # => 2
51
+ # dropdown.select_previous
52
+ # dropdown.selected_index # => 1
53
+ def select_previous
54
+ @selected_index = (@selected_index - 1) % @items.size
55
+ end
56
+
57
+ # Move selection to the next item (wraps to top).
58
+ #
59
+ # @return [void]
60
+ #
61
+ # @example
62
+ # dropdown.selected_index # => 1
63
+ # dropdown.select_next
64
+ # dropdown.selected_index # => 2
65
+ def select_next
66
+ @selected_index = (@selected_index + 1) % @items.size
67
+ end
68
+
69
+ # Get the currently selected item.
70
+ #
71
+ # @return [String] the selected item
72
+ #
73
+ # @example
74
+ # dropdown.select_next
75
+ # dropdown.selected_item # => "option2"
76
+ def selected_item
77
+ @items[@selected_index]
78
+ end
79
+
80
+ # Get the first item in the list.
81
+ #
82
+ # @return [String] the first item
83
+ #
84
+ # @example
85
+ # dropdown.first_item # => "option1"
86
+ def first_item
87
+ @items.first
88
+ end
89
+
90
+ # Render the dropdown as a numbered list with highlighting.
91
+ #
92
+ # The selected item is rendered with reverse video highlighting.
93
+ # Non-selected items are rendered with dim styling.
94
+ # Only the first max_visible items are included.
95
+ #
96
+ # @return [Array<String>] array of formatted lines
97
+ #
98
+ # @example
99
+ # dropdown = Dropdown.new(items: ["a", "b", "c"], max_visible: 5)
100
+ # dropdown.select_next
101
+ # dropdown.render
102
+ # # => ["1. \e[2ma\e[0m", "2. \e[7mb\e[0m", "3. \e[2mc\e[0m"]
103
+ def render
104
+ visible_items = @items.first(@max_visible)
105
+
106
+ visible_items.map.with_index do |item, idx|
107
+ prefix = "#{idx + 1}. "
108
+ if idx == @selected_index
109
+ ANSIColors.reverse(prefix + item)
110
+ else
111
+ ANSIColors.dim(prefix + item)
112
+ end
113
+ end
114
+ end
115
+
116
+ # Check if the dropdown has no items.
117
+ #
118
+ # @return [Boolean] true if items array is empty
119
+ #
120
+ # @note This should never be true due to the constructor validation,
121
+ # but is provided for completeness.
122
+ #
123
+ # @example
124
+ # dropdown.empty? # => false
125
+ def empty?
126
+ @items.empty?
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module V3
5
+ # Stateless formatter for V3 agent events.
6
+ #
7
+ # Converts event hashes (emitted by {SwarmSDK::V3::EventStream}) into
8
+ # styled terminal strings. Returns +nil+ for unrecognized event types
9
+ # so the caller can silently skip them.
10
+ #
11
+ # @example
12
+ # event = { type: "tool_call", tool: "Read", arguments: { file_path: "foo.rb" } }
13
+ # EventRenderer.format(event)
14
+ # # => " \u{1F4D6} \e[2mRead(file_path: \"foo.rb\")\e[0m"
15
+ module EventRenderer
16
+ TOOL_EMOJIS = {
17
+ "Read" => "\u{1F4D6}",
18
+ "Write" => "\u{270F}\u{FE0F}",
19
+ "Edit" => "\u{1F527}",
20
+ "Bash" => "\u{1F4BB}",
21
+ "Grep" => "\u{1F50D}",
22
+ "Glob" => "\u{1F4C2}",
23
+ "Think" => "\u{1F4AD}",
24
+ "Reboot" => "\u{1F504}",
25
+ "Clock" => "\u{1F550}",
26
+ "SubTask" => "\u{1F41D}",
27
+ }.freeze
28
+
29
+ class << self
30
+ # Format an event hash into a styled terminal string.
31
+ #
32
+ # @param event [Hash] event data with at least a +:type+ key
33
+ # @return [String, nil] formatted string, or nil if the event type is unknown
34
+ #
35
+ # @example tool_call event
36
+ # EventRenderer.format(type: "tool_call", tool: "Bash", arguments: { command: "ls" })
37
+ #
38
+ # @example subtask event
39
+ # EventRenderer.format(type: "subtask_spawned", title: "research", depth: 1)
40
+ def format(event)
41
+ case event[:type]
42
+ when "tool_call"
43
+ emoji = TOOL_EMOJIS[event[:tool]] || "\u{1F527}"
44
+ args = format_tool_args(event[:arguments])
45
+ " #{emoji} #{ANSIColors.dim("#{event[:tool]}(#{args})")}"
46
+ when "tool_result"
47
+ format_tool_result(event[:result_preview])
48
+ when "subtask_spawned"
49
+ ANSIColors.magenta(" \u{1F41D} SubTask spawned: #{event[:title]} (depth: #{event[:depth]})")
50
+ when "subtask_completed"
51
+ ANSIColors.green(" \u{2705} SubTask completed: #{event[:title]}")
52
+ when "subtask_failed"
53
+ ANSIColors.red(" \u{274C} SubTask failed: #{event[:title]} \u{2014} #{event[:error]}")
54
+ when "memory_retrieval", "memory_wait_ingestion", "memory_eviction"
55
+ label = event[:type].sub("memory_", "").tr("_", " ")
56
+ ANSIColors.dim(" \u{1F9E0} #{label} (#{event[:elapsed_ms]}ms)")
57
+ when "tool_skipped"
58
+ ANSIColors.yellow(" \u{23ED}\u{FE0F} #{event[:tool]} skipped (steering)")
59
+ when "steering_injected"
60
+ ANSIColors.yellow(" \u{1F3AF} Steering injected (#{event[:message_count]} message#{"s" if event[:message_count] != 1})")
61
+ when "follow_up_injected"
62
+ ANSIColors.cyan(" \u{1F4CB} Follow-up processing (#{event[:message_count]} message#{"s" if event[:message_count] != 1})")
63
+ when "memory_defrag_start"
64
+ ANSIColors.cyan(" \u{1F9F9} Starting defrag (#{event[:total_cards]} cards, #{event[:total_clusters]} clusters)")
65
+ when "memory_defrag_complete"
66
+ ANSIColors.green(" \u{2705} Defrag complete in #{event[:elapsed_ms]}ms")
67
+ end
68
+ end
69
+
70
+ # Format a defrag progress event for activity indicator display.
71
+ #
72
+ # @param event [Hash] defrag progress event
73
+ # @return [String, nil] progress string or nil
74
+ def format_defrag_progress(event)
75
+ return unless event[:type] == "memory_defrag_progress"
76
+
77
+ pct = (event[:overall_current].to_f / event[:overall_total] * 100).round
78
+ "\u{1F9F9} [#{pct}%] #{event[:description]}"
79
+ end
80
+
81
+ # Format a memory event as a short activity status string.
82
+ #
83
+ # Used by the activity indicator to show memory operations inline
84
+ # on the "Working..." line rather than as separate output lines.
85
+ #
86
+ # @param event [Hash] memory event with +:type+ and +:elapsed_ms+
87
+ # @return [String, nil] status string or nil if not a memory event
88
+ def format_activity(event)
89
+ case event[:type]
90
+ when "memory_retrieval", "memory_wait_ingestion", "memory_eviction"
91
+ label = event[:type].sub("memory_", "").tr("_", " ")
92
+ "\u{1F9E0} #{label}: #{event[:elapsed_ms]}ms"
93
+ end
94
+ end
95
+
96
+ # Format tool result preview with multiline support.
97
+ #
98
+ # Shows up to 3 lines, truncates long lines, and indicates when content
99
+ # is truncated. Each line is indented and dimmed.
100
+ #
101
+ # @param text [String, nil] tool result text
102
+ # @param max_lines [Integer] maximum lines to show (default: 3)
103
+ # @param max_line_length [Integer] maximum length per line (default: 100)
104
+ # @return [String] formatted multiline string
105
+ def format_tool_result(text, max_lines: 3, max_line_length: 100)
106
+ return ANSIColors.dim(" \u{21B3} (empty)") if text.nil? || text.to_s.strip.empty?
107
+
108
+ lines = text.to_s.lines.map(&:chomp)
109
+ truncated_vertically = lines.size > max_lines
110
+
111
+ # Take first max_lines
112
+ display_lines = lines.first(max_lines)
113
+
114
+ # Truncate each line horizontally
115
+ formatted = display_lines.map do |line|
116
+ truncated_line = if line.length > max_line_length
117
+ "#{line[0...max_line_length]}\u{2026}"
118
+ else
119
+ line
120
+ end
121
+ ANSIColors.dim(" \u{21B3} #{truncated_line}")
122
+ end
123
+
124
+ # Add ellipsis indicator if truncated vertically
125
+ if truncated_vertically
126
+ remaining = lines.size - max_lines
127
+ formatted << ANSIColors.dim(" \u{21B3} \u{2026} (#{remaining} more line#{"s" if remaining != 1})")
128
+ end
129
+
130
+ formatted.join("\n")
131
+ end
132
+
133
+ # Truncate text to a maximum length, replacing newlines with return arrows.
134
+ #
135
+ # @param text [String, nil] text to truncate
136
+ # @param max [Integer] maximum length
137
+ # @return [String] truncated text (may include a trailing ellipsis)
138
+ def truncate(text, max)
139
+ return "" if text.nil?
140
+
141
+ text = text.to_s.tr("\n", "\u{21B5}")
142
+ text.length > max ? "#{text[0...max]}\u{2026}" : text
143
+ end
144
+
145
+ # Format tool arguments as a compact key: value string.
146
+ #
147
+ # @param args [Hash, nil] tool arguments
148
+ # @return [String] formatted arguments
149
+ def format_tool_args(args)
150
+ return "" if args.nil? || args.empty?
151
+
152
+ pairs = args.map do |k, v|
153
+ val = v.is_a?(String) ? "\"#{truncate(v, 50)}\"" : truncate(v.inspect, 50)
154
+ "#{k}: #{val}"
155
+ end
156
+ truncate(pairs.join(", "), 120)
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module V3
5
+ # File path completion logic for autocomplete.
6
+ #
7
+ # FileCompleter provides methods to extract completion targets from text buffers
8
+ # and find matching file paths using ripgrep (rg). It has no knowledge of the UI,
9
+ # keyboard input, or display rendering - it only handles the file matching logic.
10
+ #
11
+ # @example Extracting completion word
12
+ # buffer = "check @lib/swa"
13
+ # cursor = 14 # After 'swa'
14
+ # result = FileCompleter.extract_completion_word(buffer, cursor)
15
+ # # => ["check ", "@lib/swa", ""]
16
+ #
17
+ # @example Finding matches
18
+ # matches = FileCompleter.find_matches("@lib/swa", max: 5)
19
+ # # => ["@lib/swarm_sdk/", "@lib/swarm_cli/", ...]
20
+ class FileCompleter
21
+ class << self
22
+ # Extract the word at cursor position that should trigger completion.
23
+ #
24
+ # Searches backward from cursor to find the nearest @ symbol, then extracts
25
+ # from that @ to the cursor position. Returns nil if no @ is found before cursor.
26
+ #
27
+ # @param buffer [String] the full text buffer
28
+ # @param cursor [Integer] the cursor position (0-based index)
29
+ #
30
+ # @return [Array<String>, nil] [pre, target, post] or nil if no @ found
31
+ # - pre: text before the @
32
+ # - target: the @ and characters up to cursor
33
+ # - post: text after cursor
34
+ #
35
+ # @example Cursor at end of word
36
+ # extract_completion_word("check @README.md", 16)
37
+ # # => ["check ", "@README.md", ""]
38
+ #
39
+ # @example Cursor in middle of word
40
+ # extract_completion_word("check @README.md", 10)
41
+ # # => ["check ", "@READ", "ME.md"]
42
+ #
43
+ # @example No @ before cursor
44
+ # extract_completion_word("check file.txt", 10)
45
+ # # => nil
46
+ def extract_completion_word(buffer, cursor)
47
+ # Find @ symbol before cursor
48
+ before_cursor = buffer[0...cursor]
49
+ at_index = before_cursor.rindex("@")
50
+ return unless at_index
51
+
52
+ # Extract target from @ to cursor
53
+ target_start = at_index
54
+ target_end = cursor
55
+
56
+ # Find end of word after cursor (next space or end of buffer)
57
+ target_end += 1 while target_end < buffer.length && buffer[target_end] !~ /\s/
58
+
59
+ pre = buffer[0...target_start]
60
+ target = buffer[target_start...cursor]
61
+ post = buffer[cursor...target_end] || ""
62
+
63
+ [pre, target, post]
64
+ end
65
+
66
+ # Find matching files for a query (with @ prefix).
67
+ #
68
+ # Uses ripgrep (rg) for fast file searching with fuzzy case-insensitive matching.
69
+ # Falls back to empty array if rg is not available or fails.
70
+ #
71
+ # @param target [String] the search target (must start with @)
72
+ # @param max [Integer] maximum number of results to return
73
+ #
74
+ # @return [Array<String>] array of completion strings (with @ prefix)
75
+ #
76
+ # @example Empty query shows current directory
77
+ # find_matches("@", max: 5)
78
+ # # => ["@README.md", "@Gemfile", "@lib/", "@test/", "@bin/"]
79
+ #
80
+ # @example Fuzzy matching
81
+ # find_matches("@lib/swa", max: 5)
82
+ # # => ["@lib/swarm_sdk/", "@lib/swarm_cli/", ...]
83
+ #
84
+ # @note Requires ripgrep (rg) to be installed. Returns [] if not available.
85
+ def find_matches(target, max: 5)
86
+ return [] unless target.start_with?("@")
87
+
88
+ query = target[1..] # Strip @
89
+
90
+ # Empty query -> show current directory contents (non-hidden)
91
+ if query.empty?
92
+ matches = %x(rg --files --max-count=#{max} 2>/dev/null).split("\n")
93
+ return matches.first(max).map { |p| "@#{p}" }
94
+ end
95
+
96
+ # Use rg --files with grep for fuzzy matching
97
+ # Get more results than requested so we can sort by quality
98
+ escaped_query = Regexp.escape(query)
99
+ matches = %x(rg --files 2>/dev/null | rg -i '#{escaped_query}' --max-count=#{max * 3}).split("\n")
100
+
101
+ # Sort by match quality (exact matches first, then by relevance)
102
+ sorted = matches.sort_by { |path| -score_match(path, query) }
103
+ sorted.first(max).map { |p| "@#{p}" }
104
+ rescue StandardError => _e
105
+ # Fallback to empty if rg fails
106
+ []
107
+ end
108
+
109
+ # Score a match based on quality (higher = better).
110
+ # Prioritizes exact matches, then start-of-filename matches,
111
+ # then shorter/shallower paths.
112
+ #
113
+ # @param path [String] the file path
114
+ # @param query [String] the search query (without @)
115
+ # @return [Integer] match quality score (higher is better)
116
+ def score_match(path, query)
117
+ score = 0
118
+ basename = File.basename(path)
119
+ query_lower = query.downcase
120
+ basename_lower = basename.downcase
121
+
122
+ # Exact match gets highest priority
123
+ score += 1000 if basename_lower == query_lower
124
+
125
+ # Match at start of filename gets high priority
126
+ score += 500 if basename_lower.start_with?(query_lower)
127
+
128
+ # Prefer shorter filenames (more specific/complete matches)
129
+ score += (100 - basename.length).clamp(0, 100)
130
+
131
+ # Prefer shallower paths (current directory over subdirectories)
132
+ depth = path.count("/")
133
+ score -= depth * 20
134
+
135
+ # Small bonus for any match (already filtered by rg)
136
+ score += 10
137
+
138
+ score
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end