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,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmCLI
|
|
4
|
+
module V3
|
|
5
|
+
# Command completion logic for slash commands.
|
|
6
|
+
#
|
|
7
|
+
# CommandCompleter provides methods to extract command targets from text buffers
|
|
8
|
+
# and find matching slash commands. It has no knowledge of the UI or display.
|
|
9
|
+
#
|
|
10
|
+
# @example Extracting command word
|
|
11
|
+
# buffer = "/mem"
|
|
12
|
+
# cursor = 4 # After "mem"
|
|
13
|
+
# result = CommandCompleter.extract_command_word(buffer, cursor)
|
|
14
|
+
# # => ["", "/mem", ""]
|
|
15
|
+
#
|
|
16
|
+
# @example Finding matches
|
|
17
|
+
# matches = CommandCompleter.find_matches("/mem")
|
|
18
|
+
# # => ["/memory"]
|
|
19
|
+
class CommandCompleter
|
|
20
|
+
# Available slash commands
|
|
21
|
+
COMMANDS = [
|
|
22
|
+
"/clear",
|
|
23
|
+
"/memory",
|
|
24
|
+
"/defrag",
|
|
25
|
+
"/queue",
|
|
26
|
+
"/reboot",
|
|
27
|
+
"/exit",
|
|
28
|
+
"/quit",
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Extract the command at cursor position.
|
|
33
|
+
#
|
|
34
|
+
# Searches backward from cursor to find a `/` at the start of the line,
|
|
35
|
+
# then extracts from that `/` to the cursor position.
|
|
36
|
+
#
|
|
37
|
+
# @param buffer [String] the full text buffer
|
|
38
|
+
# @param cursor [Integer] the cursor position (0-based index)
|
|
39
|
+
#
|
|
40
|
+
# @return [Array<String>, nil] [pre, target, post] or nil if no / found
|
|
41
|
+
# - pre: text before the /
|
|
42
|
+
# - target: the / and characters up to cursor
|
|
43
|
+
# - post: text after cursor
|
|
44
|
+
#
|
|
45
|
+
# @example Cursor at end of command
|
|
46
|
+
# extract_command_word("/mem", 4)
|
|
47
|
+
# # => ["", "/mem", ""]
|
|
48
|
+
#
|
|
49
|
+
# @example Cursor in middle of command
|
|
50
|
+
# extract_command_word("/memory", 4)
|
|
51
|
+
# # => ["", "/mem", "ory"]
|
|
52
|
+
#
|
|
53
|
+
# @example No / at start of line
|
|
54
|
+
# extract_command_word("check /mem", 10)
|
|
55
|
+
# # => nil (slash not at start)
|
|
56
|
+
def extract_command_word(buffer, cursor)
|
|
57
|
+
# Command must be at start of line
|
|
58
|
+
return unless buffer.start_with?("/")
|
|
59
|
+
|
|
60
|
+
# Find end of command after cursor (next space or end of buffer)
|
|
61
|
+
target_end = cursor
|
|
62
|
+
target_end += 1 while target_end < buffer.length && buffer[target_end] !~ /\s/
|
|
63
|
+
|
|
64
|
+
pre = ""
|
|
65
|
+
target = buffer[0...cursor]
|
|
66
|
+
post = buffer[cursor...target_end] || ""
|
|
67
|
+
|
|
68
|
+
[pre, target, post]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Find matching commands for a query (with / prefix).
|
|
72
|
+
#
|
|
73
|
+
# @param target [String] the search target (must start with /)
|
|
74
|
+
# @param max [Integer] maximum number of results to return
|
|
75
|
+
# @param skills [Array<String>] additional skill names to include
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<String>] array of matching commands (with / prefix)
|
|
78
|
+
#
|
|
79
|
+
# @example Exact match
|
|
80
|
+
# find_matches("/clear")
|
|
81
|
+
# # => ["/clear"]
|
|
82
|
+
#
|
|
83
|
+
# @example Prefix match
|
|
84
|
+
# find_matches("/me")
|
|
85
|
+
# # => ["/memory"]
|
|
86
|
+
#
|
|
87
|
+
# @example With skills
|
|
88
|
+
# find_matches("/an", skills: ["analyze", "annotate"])
|
|
89
|
+
# # => ["/analyze", "/annotate"]
|
|
90
|
+
#
|
|
91
|
+
# @example Multiple matches
|
|
92
|
+
# find_matches("/")
|
|
93
|
+
# # => ["/clear", "/memory", "/defrag", "/queue", "/reboot"]
|
|
94
|
+
def find_matches(target, max: 5, skills: [])
|
|
95
|
+
return [] unless target.start_with?("/")
|
|
96
|
+
|
|
97
|
+
query = target.downcase
|
|
98
|
+
|
|
99
|
+
# Combine built-in commands with skill names
|
|
100
|
+
skill_commands = skills.map { |name| "/#{name}" }
|
|
101
|
+
all_commands = COMMANDS + skill_commands
|
|
102
|
+
|
|
103
|
+
# Find commands that start with the query
|
|
104
|
+
matches = all_commands.select { |cmd| cmd.downcase.start_with?(query) }
|
|
105
|
+
|
|
106
|
+
# Sort by length (shorter = more specific)
|
|
107
|
+
matches.sort_by(&:length).first(max)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmCLI
|
|
4
|
+
module V3
|
|
5
|
+
# Mutex-protected coordinator for terminal output.
|
|
6
|
+
#
|
|
7
|
+
# Owns a {TextInput} and an {ActivityIndicator}, and ensures all screen
|
|
8
|
+
# writes are serialized so agent events and user keystrokes never visually
|
|
9
|
+
# collide. The input line (prompt + buffer) is always visible at the bottom;
|
|
10
|
+
# agent output "slides in" above it.
|
|
11
|
+
#
|
|
12
|
+
# The footer is composed of (top to bottom):
|
|
13
|
+
# 1. Optional working indicator (blank line + "Working..." line)
|
|
14
|
+
# 2. Horizontal separator
|
|
15
|
+
# 3. Prompt line (may wrap and span multiple lines)
|
|
16
|
+
class Display
|
|
17
|
+
# @return [TextInput] the text input model (read-only access for reader)
|
|
18
|
+
attr_reader :text_input
|
|
19
|
+
|
|
20
|
+
# @param io [IO] output stream (defaults to $stdout, use StringIO for tests)
|
|
21
|
+
# @param width [Integer, nil] terminal width override (nil = auto-detect)
|
|
22
|
+
def initialize(io: $stdout, width: nil)
|
|
23
|
+
@io = io
|
|
24
|
+
@width_override = width
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@active = false
|
|
27
|
+
@text_input = TextInput.new
|
|
28
|
+
@indicator = ActivityIndicator.new(on_tick: -> { tick })
|
|
29
|
+
# Tracks which row of the prompt area the terminal cursor is on
|
|
30
|
+
# (0 = first line of prompt, relative to prompt start).
|
|
31
|
+
# Used by reposition_cursor to navigate from current position.
|
|
32
|
+
@terminal_cursor_row = 0
|
|
33
|
+
@hint_message = nil
|
|
34
|
+
@hint_timer = nil
|
|
35
|
+
@status_message = nil # Persistent status message
|
|
36
|
+
@dropdown = nil # Dropdown instance when autocomplete is active
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Start showing the input footer.
|
|
40
|
+
#
|
|
41
|
+
# @return [void]
|
|
42
|
+
def activate
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@active = true
|
|
45
|
+
redraw_footer
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Stop showing the input footer and clear it from the screen.
|
|
50
|
+
#
|
|
51
|
+
# @return [void]
|
|
52
|
+
def deactivate
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
return unless @active
|
|
55
|
+
|
|
56
|
+
clear_footer
|
|
57
|
+
@active = false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Signal that the agent is working.
|
|
62
|
+
#
|
|
63
|
+
# @return [void]
|
|
64
|
+
def start_working
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
if @active
|
|
67
|
+
clear_footer
|
|
68
|
+
@indicator.start
|
|
69
|
+
redraw_footer
|
|
70
|
+
else
|
|
71
|
+
@indicator.start
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Update the activity indicator status suffix.
|
|
77
|
+
#
|
|
78
|
+
# @param text [String, nil] status text to show
|
|
79
|
+
# @return [void]
|
|
80
|
+
def update_activity(text)
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
return unless @indicator.working?
|
|
83
|
+
|
|
84
|
+
@indicator.status = text
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Signal that the agent has finished working.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def stop_working
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
return unless @indicator.working?
|
|
94
|
+
|
|
95
|
+
clear_footer if @active
|
|
96
|
+
@indicator.stop
|
|
97
|
+
redraw_footer if @active
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Echo a typed character at the cursor position.
|
|
102
|
+
#
|
|
103
|
+
# @param char [String] single character
|
|
104
|
+
# @return [void]
|
|
105
|
+
def type_char(char)
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
108
|
+
@text_input.type_char(char)
|
|
109
|
+
|
|
110
|
+
# Always redraw to ensure bottom separator is present
|
|
111
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Handle a backspace keypress.
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
118
|
+
def backspace
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
121
|
+
@text_input.backspace
|
|
122
|
+
|
|
123
|
+
# Always redraw to ensure bottom separator is present
|
|
124
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Move cursor left.
|
|
129
|
+
#
|
|
130
|
+
# @return [void]
|
|
131
|
+
def move_left
|
|
132
|
+
@mutex.synchronize do
|
|
133
|
+
@text_input.move_left
|
|
134
|
+
reposition_cursor
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Move cursor right.
|
|
139
|
+
#
|
|
140
|
+
# @return [void]
|
|
141
|
+
def move_right
|
|
142
|
+
@mutex.synchronize do
|
|
143
|
+
@text_input.move_right
|
|
144
|
+
reposition_cursor
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Move cursor up one line within multiline buffer.
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean] true if cursor moved, false if already on first line
|
|
151
|
+
def move_up
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
old = @text_input.cursor
|
|
154
|
+
@text_input.move_up
|
|
155
|
+
moved = @text_input.cursor != old
|
|
156
|
+
reposition_cursor if moved
|
|
157
|
+
moved
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Move cursor down one line within multiline buffer.
|
|
162
|
+
#
|
|
163
|
+
# @return [Boolean] true if cursor moved, false if already on last line
|
|
164
|
+
def move_down
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
old = @text_input.cursor
|
|
167
|
+
@text_input.move_down
|
|
168
|
+
moved = @text_input.cursor != old
|
|
169
|
+
reposition_cursor if moved
|
|
170
|
+
moved
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Finalize the current input line and return the buffer text.
|
|
175
|
+
#
|
|
176
|
+
# @return [String] the submitted text
|
|
177
|
+
def enter
|
|
178
|
+
@mutex.synchronize do
|
|
179
|
+
line = @text_input.submit
|
|
180
|
+
clear_footer if @active
|
|
181
|
+
write("#{@text_input.render}#{line}\n")
|
|
182
|
+
redraw_footer if @active
|
|
183
|
+
flush
|
|
184
|
+
line
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Replace the buffer contents (for history navigation).
|
|
189
|
+
#
|
|
190
|
+
# @param text [String, nil] replacement text
|
|
191
|
+
# @return [void]
|
|
192
|
+
def replace_buffer(text)
|
|
193
|
+
@mutex.synchronize do
|
|
194
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
195
|
+
@text_input.replace(text)
|
|
196
|
+
redraw_prompt_with_separator_from(old_lines) if @active
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Clear the input buffer (for double-ESC).
|
|
201
|
+
#
|
|
202
|
+
# @return [void]
|
|
203
|
+
def clear_input
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
206
|
+
@text_input.replace("")
|
|
207
|
+
@hint_message = nil # Clear hint directly
|
|
208
|
+
clear_hint_timer
|
|
209
|
+
redraw_prompt_with_separator_from(old_lines) if @active
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Show a temporary hint message below the input area.
|
|
214
|
+
#
|
|
215
|
+
# @param message [String] the hint text to display
|
|
216
|
+
# @param duration [Float] seconds to show the hint (default 1.5)
|
|
217
|
+
# @return [void]
|
|
218
|
+
def show_hint(message, duration: 1.5)
|
|
219
|
+
@mutex.synchronize do
|
|
220
|
+
clear_hint_timer
|
|
221
|
+
@hint_message = message
|
|
222
|
+
|
|
223
|
+
if @active
|
|
224
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
225
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
@hint_timer = Thread.new do
|
|
229
|
+
sleep(duration)
|
|
230
|
+
@mutex.synchronize do
|
|
231
|
+
@hint_message = nil
|
|
232
|
+
@hint_timer = nil
|
|
233
|
+
|
|
234
|
+
if @active
|
|
235
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
236
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Clear the hint message immediately.
|
|
244
|
+
#
|
|
245
|
+
# @return [void]
|
|
246
|
+
def clear_hint
|
|
247
|
+
return unless @hint_message
|
|
248
|
+
|
|
249
|
+
clear_hint_timer
|
|
250
|
+
@hint_message = nil
|
|
251
|
+
|
|
252
|
+
if @active
|
|
253
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
254
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Show a persistent status message in the status area.
|
|
259
|
+
# Unlike hints, status messages don't auto-clear.
|
|
260
|
+
#
|
|
261
|
+
# @param message [String] the status text to display
|
|
262
|
+
# @return [void]
|
|
263
|
+
def show_status(message)
|
|
264
|
+
@mutex.synchronize do
|
|
265
|
+
@status_message = message
|
|
266
|
+
|
|
267
|
+
if @active
|
|
268
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
269
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Clear the persistent status message.
|
|
275
|
+
#
|
|
276
|
+
# @return [void]
|
|
277
|
+
def clear_status
|
|
278
|
+
return unless @status_message
|
|
279
|
+
|
|
280
|
+
@status_message = nil
|
|
281
|
+
|
|
282
|
+
if @active
|
|
283
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
284
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Show dropdown in status area.
|
|
289
|
+
#
|
|
290
|
+
# @param dropdown [Dropdown] the dropdown to display
|
|
291
|
+
# @return [void]
|
|
292
|
+
def show_dropdown(dropdown)
|
|
293
|
+
@mutex.synchronize do
|
|
294
|
+
@dropdown = dropdown
|
|
295
|
+
@hint_message = nil # Clear hints when dropdown shows
|
|
296
|
+
@status_message = nil # Clear status when dropdown shows
|
|
297
|
+
|
|
298
|
+
if @active
|
|
299
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
300
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Navigate dropdown selection to next item.
|
|
306
|
+
#
|
|
307
|
+
# @return [void]
|
|
308
|
+
def dropdown_select_next
|
|
309
|
+
@mutex.synchronize do
|
|
310
|
+
return unless @dropdown
|
|
311
|
+
|
|
312
|
+
@dropdown.select_next
|
|
313
|
+
|
|
314
|
+
if @active
|
|
315
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
316
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Navigate dropdown selection to previous item.
|
|
322
|
+
#
|
|
323
|
+
# @return [void]
|
|
324
|
+
def dropdown_select_previous
|
|
325
|
+
@mutex.synchronize do
|
|
326
|
+
return unless @dropdown
|
|
327
|
+
|
|
328
|
+
@dropdown.select_previous
|
|
329
|
+
|
|
330
|
+
if @active
|
|
331
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
332
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Get selected item and close dropdown.
|
|
338
|
+
#
|
|
339
|
+
# @return [String, nil] the selected item or nil if no dropdown active
|
|
340
|
+
def dropdown_accept
|
|
341
|
+
@mutex.synchronize do
|
|
342
|
+
return unless @dropdown
|
|
343
|
+
|
|
344
|
+
selected = @dropdown.selected_item
|
|
345
|
+
@dropdown = nil
|
|
346
|
+
|
|
347
|
+
if @active
|
|
348
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
349
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
selected
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Get first item and close dropdown (for Tab behavior).
|
|
357
|
+
#
|
|
358
|
+
# @return [String, nil] the first item or nil if no dropdown active
|
|
359
|
+
def dropdown_accept_first
|
|
360
|
+
@mutex.synchronize do
|
|
361
|
+
return unless @dropdown
|
|
362
|
+
|
|
363
|
+
first = @dropdown.first_item
|
|
364
|
+
@dropdown = nil
|
|
365
|
+
|
|
366
|
+
if @active
|
|
367
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
368
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
first
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Close dropdown without selection.
|
|
376
|
+
#
|
|
377
|
+
# @return [void]
|
|
378
|
+
def dropdown_close
|
|
379
|
+
@mutex.synchronize do
|
|
380
|
+
return unless @dropdown
|
|
381
|
+
|
|
382
|
+
@dropdown = nil
|
|
383
|
+
|
|
384
|
+
if @active
|
|
385
|
+
old_lines = @text_input.wrapped_lines(terminal_width)
|
|
386
|
+
redraw_prompt_with_separator_from(old_lines)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Check whether dropdown is active.
|
|
392
|
+
#
|
|
393
|
+
# @return [Boolean] true if dropdown is shown
|
|
394
|
+
def dropdown_active?
|
|
395
|
+
@mutex.synchronize { !@dropdown.nil? }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Return a copy of the current input buffer (thread-safe).
|
|
399
|
+
#
|
|
400
|
+
# @return [String]
|
|
401
|
+
def current_buffer
|
|
402
|
+
@mutex.synchronize { @text_input.buffer.dup }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Print agent output above the always-visible footer.
|
|
406
|
+
#
|
|
407
|
+
# @param text [String] output text to display
|
|
408
|
+
# @return [void]
|
|
409
|
+
def agent_print(text)
|
|
410
|
+
@mutex.synchronize do
|
|
411
|
+
if @active
|
|
412
|
+
clear_footer
|
|
413
|
+
write(text)
|
|
414
|
+
write("\n") unless text.end_with?("\n")
|
|
415
|
+
redraw_footer
|
|
416
|
+
else
|
|
417
|
+
write(text)
|
|
418
|
+
write("\n") unless text.end_with?("\n")
|
|
419
|
+
end
|
|
420
|
+
flush
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
private
|
|
425
|
+
|
|
426
|
+
# Called by the ActivityIndicator ticker thread.
|
|
427
|
+
def tick
|
|
428
|
+
@mutex.synchronize do
|
|
429
|
+
return unless @indicator.working? && @active
|
|
430
|
+
|
|
431
|
+
clear_footer
|
|
432
|
+
redraw_footer
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def terminal_width
|
|
437
|
+
@width_override || IO.console&.winsize&.last || 80
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def cursor_at_end?
|
|
441
|
+
@text_input.cursor == @text_input.buffer.length
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Clear the entire footer from the screen.
|
|
445
|
+
# Navigates from the tracked cursor row, not from an assumed position.
|
|
446
|
+
def clear_footer
|
|
447
|
+
width = terminal_width
|
|
448
|
+
@text_input.wrapped_lines(width)
|
|
449
|
+
|
|
450
|
+
# From current cursor row, move up to start of indicator/separator area
|
|
451
|
+
# @terminal_cursor_row is the line within the text input (0 = first line)
|
|
452
|
+
# We need to move up (cursor_row + 1) to reach the top separator
|
|
453
|
+
lines_above_cursor = @terminal_cursor_row + 1
|
|
454
|
+
lines_above_cursor += @indicator.footer_lines
|
|
455
|
+
lines_above_cursor += status_area_lines # Account for status area if shown
|
|
456
|
+
|
|
457
|
+
lines_above_cursor.times { write("\e[A") }
|
|
458
|
+
write("\r\e[J")
|
|
459
|
+
@terminal_cursor_row = 0
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Redraw the full footer: indicator, top separator, prompt, bottom separator, status area, then position cursor.
|
|
463
|
+
def redraw_footer
|
|
464
|
+
indicator_line = @indicator.render
|
|
465
|
+
write(indicator_line) if indicator_line
|
|
466
|
+
write_separator
|
|
467
|
+
write(@text_input.render)
|
|
468
|
+
write_bottom_separator
|
|
469
|
+
write_status_area
|
|
470
|
+
place_cursor
|
|
471
|
+
flush
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Redraw just the prompt line (single-line, cursor at end).
|
|
475
|
+
def redraw_prompt_line
|
|
476
|
+
write("\r\e[K#{@text_input.render}")
|
|
477
|
+
write_bottom_separator
|
|
478
|
+
place_cursor
|
|
479
|
+
flush
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Redraw separator + prompt area using a pre-captured line count.
|
|
483
|
+
#
|
|
484
|
+
# @param old_prompt_lines [Integer] number of wrapped lines before the change
|
|
485
|
+
# @param had_hint [Boolean] whether a hint was shown in the previous display (unused, kept for compatibility)
|
|
486
|
+
def redraw_prompt_with_separator_from(old_prompt_lines, had_hint: false)
|
|
487
|
+
# Navigate up from tracked cursor position to top separator
|
|
488
|
+
# @terminal_cursor_row is 0 for first line of input, 1 for second line, etc.
|
|
489
|
+
# We need to move up (cursor_row + 1) to reach the top separator
|
|
490
|
+
# The status area is BELOW the cursor, so it doesn't affect upward navigation
|
|
491
|
+
up = @terminal_cursor_row + 1
|
|
492
|
+
up.times { write("\e[A") }
|
|
493
|
+
write("\r\e[J")
|
|
494
|
+
write_separator
|
|
495
|
+
write(@text_input.render)
|
|
496
|
+
write_bottom_separator
|
|
497
|
+
write_status_area
|
|
498
|
+
place_cursor
|
|
499
|
+
flush
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def write_separator
|
|
503
|
+
write(ANSIColors.dim("\u{2500}" * terminal_width))
|
|
504
|
+
write("\n")
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def write_bottom_separator
|
|
508
|
+
write("\n")
|
|
509
|
+
write(ANSIColors.dim("\u{2500}" * terminal_width))
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Write the status area (dropdown, hints, or persistent status).
|
|
513
|
+
# Dropdown takes priority, then hints, then status messages.
|
|
514
|
+
def write_status_area
|
|
515
|
+
if @dropdown
|
|
516
|
+
# Render dropdown (multiple lines)
|
|
517
|
+
@dropdown.render.each do |line|
|
|
518
|
+
write("\n")
|
|
519
|
+
write(line)
|
|
520
|
+
end
|
|
521
|
+
elsif @hint_message
|
|
522
|
+
write("\n")
|
|
523
|
+
write(ANSIColors.dim(@hint_message))
|
|
524
|
+
elsif @status_message
|
|
525
|
+
write("\n")
|
|
526
|
+
write(ANSIColors.italic(@status_message))
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Count how many lines the status area occupies
|
|
531
|
+
def status_area_lines
|
|
532
|
+
if @dropdown
|
|
533
|
+
@dropdown.render.size
|
|
534
|
+
elsif @hint_message || @status_message
|
|
535
|
+
1
|
|
536
|
+
else
|
|
537
|
+
0
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def clear_hint_timer
|
|
542
|
+
return unless @hint_timer
|
|
543
|
+
|
|
544
|
+
@hint_timer.kill
|
|
545
|
+
@hint_timer = nil
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Position the terminal cursor at the TextInput cursor location
|
|
549
|
+
# and update @terminal_cursor_row tracking.
|
|
550
|
+
#
|
|
551
|
+
# After rendering, terminal cursor is at the end of the bottom separator line (or status area if shown).
|
|
552
|
+
# We need to move up to the correct position in the text input.
|
|
553
|
+
def place_cursor
|
|
554
|
+
width = terminal_width
|
|
555
|
+
extra_lines = @text_input.wrapped_lines(width)
|
|
556
|
+
cursor_row, cursor_col = @text_input.cursor_position(width)
|
|
557
|
+
|
|
558
|
+
# wrapped_lines returns EXTRA lines from wrapping (0 for single line)
|
|
559
|
+
# Total terminal lines = extra_lines + 1 (for the base line)
|
|
560
|
+
# We're on the separator line (or status area), need to move up to cursor_row
|
|
561
|
+
# From separator/status to last text line = 1 line up (+ status_area_lines if shown)
|
|
562
|
+
# From last text line to cursor_row = (extra_lines - cursor_row) lines up
|
|
563
|
+
up = (extra_lines - cursor_row) + 1
|
|
564
|
+
up += status_area_lines # Extra lines for status area
|
|
565
|
+
up.times { write("\e[A") } if up > 0
|
|
566
|
+
|
|
567
|
+
# Move to cursor column
|
|
568
|
+
write("\r")
|
|
569
|
+
write("\e[#{cursor_col}C") if cursor_col > 0
|
|
570
|
+
|
|
571
|
+
@terminal_cursor_row = cursor_row
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Reposition cursor for arrow key movement (no content change).
|
|
575
|
+
# Uses tracked @terminal_cursor_row to navigate from current position.
|
|
576
|
+
def reposition_cursor
|
|
577
|
+
width = terminal_width
|
|
578
|
+
return if width <= 0
|
|
579
|
+
|
|
580
|
+
cursor_row, cursor_col = @text_input.cursor_position(width)
|
|
581
|
+
|
|
582
|
+
# Navigate from current row to target row
|
|
583
|
+
row_delta = cursor_row - @terminal_cursor_row
|
|
584
|
+
if row_delta < 0
|
|
585
|
+
(-row_delta).times { write("\e[A") }
|
|
586
|
+
elsif row_delta > 0
|
|
587
|
+
row_delta.times { write("\e[B") }
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Move to cursor column
|
|
591
|
+
write("\r")
|
|
592
|
+
write("\e[#{cursor_col}C") if cursor_col > 0
|
|
593
|
+
|
|
594
|
+
@terminal_cursor_row = cursor_row
|
|
595
|
+
flush
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def write(text)
|
|
599
|
+
@io.write(text)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def flush
|
|
603
|
+
@io.flush
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|