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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/exe/swarm3 +11 -0
- data/lib/swarm_cli/v3/activity_indicator.rb +168 -0
- data/lib/swarm_cli/v3/ansi_colors.rb +70 -0
- data/lib/swarm_cli/v3/cli.rb +721 -0
- data/lib/swarm_cli/v3/command_completer.rb +112 -0
- data/lib/swarm_cli/v3/display.rb +607 -0
- data/lib/swarm_cli/v3/dropdown.rb +130 -0
- data/lib/swarm_cli/v3/event_renderer.rb +161 -0
- data/lib/swarm_cli/v3/file_completer.rb +143 -0
- data/lib/swarm_cli/v3/raw_input_reader.rb +304 -0
- data/lib/swarm_cli/v3/reboot_tool.rb +123 -0
- data/lib/swarm_cli/v3/text_input.rb +235 -0
- data/lib/swarm_cli/v3.rb +52 -0
- metadata +30 -245
- data/exe/swarm +0 -6
- data/lib/swarm_cli/cli.rb +0 -201
- data/lib/swarm_cli/command_registry.rb +0 -61
- data/lib/swarm_cli/commands/mcp_serve.rb +0 -130
- data/lib/swarm_cli/commands/mcp_tools.rb +0 -148
- data/lib/swarm_cli/commands/migrate.rb +0 -55
- data/lib/swarm_cli/commands/run.rb +0 -173
- data/lib/swarm_cli/config_loader.rb +0 -98
- data/lib/swarm_cli/formatters/human_formatter.rb +0 -811
- data/lib/swarm_cli/formatters/json_formatter.rb +0 -62
- data/lib/swarm_cli/interactive_repl.rb +0 -895
- data/lib/swarm_cli/mcp_serve_options.rb +0 -44
- data/lib/swarm_cli/mcp_tools_options.rb +0 -59
- data/lib/swarm_cli/migrate_options.rb +0 -54
- data/lib/swarm_cli/migrator.rb +0 -132
- data/lib/swarm_cli/options.rb +0 -151
- data/lib/swarm_cli/ui/components/agent_badge.rb +0 -33
- data/lib/swarm_cli/ui/components/content_block.rb +0 -120
- data/lib/swarm_cli/ui/components/divider.rb +0 -57
- data/lib/swarm_cli/ui/components/panel.rb +0 -62
- data/lib/swarm_cli/ui/components/usage_stats.rb +0 -70
- data/lib/swarm_cli/ui/formatters/cost.rb +0 -49
- data/lib/swarm_cli/ui/formatters/number.rb +0 -58
- data/lib/swarm_cli/ui/formatters/text.rb +0 -77
- data/lib/swarm_cli/ui/formatters/time.rb +0 -73
- data/lib/swarm_cli/ui/icons.rb +0 -36
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +0 -188
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +0 -45
- data/lib/swarm_cli/ui/state/depth_tracker.rb +0 -40
- data/lib/swarm_cli/ui/state/spinner_manager.rb +0 -170
- data/lib/swarm_cli/ui/state/usage_tracker.rb +0 -62
- data/lib/swarm_cli/version.rb +0 -5
- 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
|