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,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmCLI
|
|
4
|
+
module V3
|
|
5
|
+
# Raw terminal input reader with history support.
|
|
6
|
+
#
|
|
7
|
+
# Manages raw terminal mode and reads characters in a background Thread,
|
|
8
|
+
# producing events on a Thread::Queue. The Async scheduler intercepts
|
|
9
|
+
# Thread#value in {#next_event}, yielding the fiber so the agent task
|
|
10
|
+
# can run concurrently.
|
|
11
|
+
#
|
|
12
|
+
# Events: +[:line, text]+, +[:interrupt]+, +[:eof]+, +[:exit]+
|
|
13
|
+
#
|
|
14
|
+
# @note This class directly controls terminal state via +stty+. Always
|
|
15
|
+
# call {#stop} or {#restore_terminal} before exiting.
|
|
16
|
+
class RawInputReader
|
|
17
|
+
# @param display [Display] the display coordinator for echoing input
|
|
18
|
+
def initialize(display)
|
|
19
|
+
@display = display
|
|
20
|
+
@queue = Thread::Queue.new
|
|
21
|
+
@history = []
|
|
22
|
+
@history_index = nil
|
|
23
|
+
@saved_buffer = nil
|
|
24
|
+
@original_stty = nil
|
|
25
|
+
@thread = nil
|
|
26
|
+
@running = false
|
|
27
|
+
@at_exit_registered = false
|
|
28
|
+
@last_ctrl_c = nil
|
|
29
|
+
@last_escape = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Enter raw terminal mode and start the input thread.
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
def start
|
|
36
|
+
@original_stty = %x(stty -g 2>/dev/null).chomp
|
|
37
|
+
system("stty -icanon -echo -isig min 1 2>/dev/null")
|
|
38
|
+
@running = true
|
|
39
|
+
@thread = Thread.new { read_loop }
|
|
40
|
+
|
|
41
|
+
return if @at_exit_registered
|
|
42
|
+
|
|
43
|
+
at_exit { restore_terminal }
|
|
44
|
+
@at_exit_registered = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Stop the input thread and restore terminal state.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
def stop
|
|
51
|
+
@running = false
|
|
52
|
+
@thread&.kill
|
|
53
|
+
@thread&.join(0.1)
|
|
54
|
+
@thread = nil
|
|
55
|
+
restore_terminal
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Restore terminal to its original state (idempotent).
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
def restore_terminal
|
|
62
|
+
return unless @original_stty && !@original_stty.empty?
|
|
63
|
+
|
|
64
|
+
system("stty #{@original_stty} 2>/dev/null")
|
|
65
|
+
@original_stty = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns the next input event, yielding the Async fiber while waiting.
|
|
69
|
+
#
|
|
70
|
+
# Async's scheduler intercepts Thread#value and suspends the fiber,
|
|
71
|
+
# letting the agent task run while we wait for user input.
|
|
72
|
+
#
|
|
73
|
+
# @return [Array] event tuple, e.g. +[:line, "hello"]+
|
|
74
|
+
def next_event
|
|
75
|
+
Thread.new { @queue.pop }.value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Load history from a file (one entry per line).
|
|
79
|
+
#
|
|
80
|
+
# @param path [String] path to the history file
|
|
81
|
+
# @return [void]
|
|
82
|
+
def load_history_file(path)
|
|
83
|
+
return unless File.exist?(path)
|
|
84
|
+
|
|
85
|
+
File.open(path, "r:UTF-8") do |f|
|
|
86
|
+
f.each_line do |line|
|
|
87
|
+
line = line.chomp
|
|
88
|
+
@history << line unless line.empty?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Save history to a file, keeping the last +max+ entries.
|
|
96
|
+
#
|
|
97
|
+
# @param path [String] path to the history file
|
|
98
|
+
# @param max [Integer] maximum entries to keep
|
|
99
|
+
# @return [void]
|
|
100
|
+
def save_history_file(path, max: 1000)
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
102
|
+
lines = @history.last(max)
|
|
103
|
+
File.open(path, "w", 0o600) { |f| lines.each { |l| f.puts(l) } }
|
|
104
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Main input loop — runs in a dedicated Thread.
|
|
111
|
+
def read_loop
|
|
112
|
+
while @running
|
|
113
|
+
char = $stdin.getc
|
|
114
|
+
break unless char
|
|
115
|
+
|
|
116
|
+
case char
|
|
117
|
+
when "\x03" then handle_ctrl_c
|
|
118
|
+
when "\x04" then @queue << [:eof]
|
|
119
|
+
when "\x14" # Ctrl+T
|
|
120
|
+
@queue << [:toggle_autocomplete]
|
|
121
|
+
reset_ctrl_c
|
|
122
|
+
reset_escape
|
|
123
|
+
when "\r", "\n"
|
|
124
|
+
handle_enter
|
|
125
|
+
reset_ctrl_c
|
|
126
|
+
reset_escape
|
|
127
|
+
when "\x7F", "\b"
|
|
128
|
+
# Close dropdown if open
|
|
129
|
+
@display.dropdown_close if @display.dropdown_active?
|
|
130
|
+
@display.backspace
|
|
131
|
+
reset_ctrl_c
|
|
132
|
+
reset_escape
|
|
133
|
+
when "\t"
|
|
134
|
+
# Tab navigates down in dropdown, or triggers autocomplete
|
|
135
|
+
if @display.dropdown_active?
|
|
136
|
+
@display.dropdown_select_next
|
|
137
|
+
else
|
|
138
|
+
@queue << [:tab]
|
|
139
|
+
end
|
|
140
|
+
reset_ctrl_c
|
|
141
|
+
reset_escape
|
|
142
|
+
when "\e"
|
|
143
|
+
handle_escape_sequence
|
|
144
|
+
# Don't reset_ctrl_c here - it would clear the ESC hint we just showed
|
|
145
|
+
else
|
|
146
|
+
if char.match?(/[[:print:]]/)
|
|
147
|
+
@display.type_char(char)
|
|
148
|
+
@queue << [:char_typed, char]
|
|
149
|
+
reset_ctrl_c
|
|
150
|
+
reset_escape
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
rescue IOError
|
|
155
|
+
@queue << [:eof]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Ctrl-C: first press emits :interrupt, second within 0.5s emits :exit
|
|
159
|
+
def handle_ctrl_c
|
|
160
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
if @last_ctrl_c && (now - @last_ctrl_c) < 0.5
|
|
162
|
+
@last_ctrl_c = nil
|
|
163
|
+
@display.clear_hint
|
|
164
|
+
@queue << [:exit]
|
|
165
|
+
else
|
|
166
|
+
@last_ctrl_c = now
|
|
167
|
+
@display.show_hint("Press Ctrl+C again to quit", duration: 1.5)
|
|
168
|
+
@queue << [:interrupt]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def reset_ctrl_c
|
|
173
|
+
@last_ctrl_c = nil
|
|
174
|
+
@display.clear_hint
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def reset_escape
|
|
178
|
+
@last_escape = nil
|
|
179
|
+
@display.clear_hint
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Handle Enter — accept dropdown selection or submit line.
|
|
183
|
+
def handle_enter
|
|
184
|
+
return if @display.text_input.blank?
|
|
185
|
+
|
|
186
|
+
# If dropdown is active, accept selection without submitting
|
|
187
|
+
if @display.dropdown_active?
|
|
188
|
+
@queue << [:dropdown_enter]
|
|
189
|
+
return
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
line = @display.enter
|
|
193
|
+
add_history(line) unless line.strip.empty?
|
|
194
|
+
@queue << [:line, line]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Add a non-empty line to history, avoiding consecutive duplicates
|
|
198
|
+
def add_history(line)
|
|
199
|
+
return if line.empty? || @history.last == line
|
|
200
|
+
|
|
201
|
+
@history << line
|
|
202
|
+
@history_index = nil
|
|
203
|
+
@saved_buffer = nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Parse escape sequences for arrow keys and Alt+Enter.
|
|
207
|
+
#
|
|
208
|
+
# Alt+Enter sends ESC followed by CR/LF — inserts a newline.
|
|
209
|
+
# Arrow keys send ESC [ A/B — navigates history.
|
|
210
|
+
# Double ESC within 0.5s clears the input buffer.
|
|
211
|
+
def handle_escape_sequence
|
|
212
|
+
# Check if there's more input (arrow keys, Alt+Enter, etc.)
|
|
213
|
+
if IO.select([$stdin], nil, nil, 0.05)
|
|
214
|
+
seq = $stdin.getc
|
|
215
|
+
|
|
216
|
+
# Alt+Enter: ESC followed by CR or LF — insert newline
|
|
217
|
+
if seq == "\r" || seq == "\n"
|
|
218
|
+
@display.type_char("\n")
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# If not an arrow key sequence, treat as standalone ESC
|
|
223
|
+
if seq != "["
|
|
224
|
+
handle_standalone_escape
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
return unless IO.select([$stdin], nil, nil, 0.05)
|
|
229
|
+
|
|
230
|
+
code = $stdin.getc
|
|
231
|
+
case code
|
|
232
|
+
when "A" # Up — dropdown takes priority over history
|
|
233
|
+
if @display.dropdown_active?
|
|
234
|
+
@display.dropdown_select_previous
|
|
235
|
+
else
|
|
236
|
+
navigate_history(:up) unless @display.text_input.multiline? && @display.move_up
|
|
237
|
+
end
|
|
238
|
+
when "B" # Down — dropdown takes priority over history
|
|
239
|
+
if @display.dropdown_active?
|
|
240
|
+
@display.dropdown_select_next
|
|
241
|
+
else
|
|
242
|
+
navigate_history(:down) unless @display.text_input.multiline? && @display.move_down
|
|
243
|
+
end
|
|
244
|
+
when "C" then @display.move_right
|
|
245
|
+
when "D" then @display.move_left
|
|
246
|
+
when "Z"
|
|
247
|
+
# Shift-Tab: ESC [ Z - currently unused (reserved for future)
|
|
248
|
+
@queue << [:shift_tab]
|
|
249
|
+
end
|
|
250
|
+
else
|
|
251
|
+
# Standalone ESC — no follow-up input
|
|
252
|
+
handle_standalone_escape
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def handle_standalone_escape
|
|
257
|
+
# Close dropdown on ESC
|
|
258
|
+
if @display.dropdown_active?
|
|
259
|
+
@display.dropdown_close
|
|
260
|
+
@last_escape = nil
|
|
261
|
+
return
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
265
|
+
if @last_escape && (now - @last_escape) < 0.5
|
|
266
|
+
@last_escape = nil
|
|
267
|
+
@display.clear_input
|
|
268
|
+
else
|
|
269
|
+
@last_escape = now
|
|
270
|
+
@display.show_hint("Press ESC again to clear input")
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Navigate through command history with Up/Down arrows
|
|
275
|
+
def navigate_history(direction)
|
|
276
|
+
case direction
|
|
277
|
+
when :up
|
|
278
|
+
if @history_index.nil?
|
|
279
|
+
return if @history.empty?
|
|
280
|
+
|
|
281
|
+
@saved_buffer = @display.current_buffer
|
|
282
|
+
@history_index = @history.size - 1
|
|
283
|
+
elsif @history_index > 0
|
|
284
|
+
@history_index -= 1
|
|
285
|
+
else
|
|
286
|
+
return
|
|
287
|
+
end
|
|
288
|
+
@display.replace_buffer(@history[@history_index])
|
|
289
|
+
when :down
|
|
290
|
+
return if @history_index.nil?
|
|
291
|
+
|
|
292
|
+
if @history_index < @history.size - 1
|
|
293
|
+
@history_index += 1
|
|
294
|
+
@display.replace_buffer(@history[@history_index])
|
|
295
|
+
else
|
|
296
|
+
@display.replace_buffer(@saved_buffer || "")
|
|
297
|
+
@history_index = nil
|
|
298
|
+
@saved_buffer = nil
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmCLI
|
|
4
|
+
module V3
|
|
5
|
+
# Tool that triggers a process reboot with continuation message.
|
|
6
|
+
#
|
|
7
|
+
# Writes a signal file scoped to the current PID. The CLI checks for
|
|
8
|
+
# pending signals after each +agent.ask()+ completes and calls
|
|
9
|
+
# +Kernel.exec+ to replace the process with a fresh Ruby invocation.
|
|
10
|
+
#
|
|
11
|
+
# Signal files are PID-scoped (+signal_<pid>.json+) so multiple CLI
|
|
12
|
+
# sessions sharing the same agent directory don't interfere.
|
|
13
|
+
#
|
|
14
|
+
# @example CLI integration
|
|
15
|
+
# RebootTool.session_pid = Process.pid
|
|
16
|
+
# SwarmSDK::V3::Tools::Registry.register(:Reboot, RebootTool)
|
|
17
|
+
class RebootTool < SwarmSDK::V3::Tools::Base
|
|
18
|
+
class << self
|
|
19
|
+
# @return [Array<Symbol>] Constructor requirements
|
|
20
|
+
def creation_requirements
|
|
21
|
+
[:directory]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# PID of the CLI session that owns this tool instance.
|
|
25
|
+
# Set by the CLI at startup.
|
|
26
|
+
#
|
|
27
|
+
# @return [Integer, nil]
|
|
28
|
+
attr_accessor :session_pid
|
|
29
|
+
|
|
30
|
+
# Check if a reboot signal is pending for the given PID.
|
|
31
|
+
#
|
|
32
|
+
# @param directory [String] Agent working directory
|
|
33
|
+
# @param pid [Integer] Process ID to check
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def signal_pending?(directory, pid)
|
|
36
|
+
File.exist?(signal_path_for(directory, pid))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read and delete the signal file, returning the parsed data.
|
|
40
|
+
#
|
|
41
|
+
# @param directory [String] Agent working directory
|
|
42
|
+
# @param pid [Integer] Process ID whose signal to consume
|
|
43
|
+
# @return [Hash, nil] Signal data or nil if no signal exists
|
|
44
|
+
def consume_signal(directory, pid)
|
|
45
|
+
path = signal_path_for(directory, pid)
|
|
46
|
+
return unless File.exist?(path)
|
|
47
|
+
|
|
48
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
49
|
+
File.delete(path)
|
|
50
|
+
data
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Compute the signal file path for a given PID.
|
|
54
|
+
#
|
|
55
|
+
# @param directory [String] Agent working directory
|
|
56
|
+
# @param pid [Integer] Process ID
|
|
57
|
+
# @return [String] Absolute path to the signal file
|
|
58
|
+
def signal_path_for(directory, pid)
|
|
59
|
+
File.join(File.expand_path(directory), ".swarm", "reboot", "signal_#{pid}.json")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
description <<~DESC
|
|
64
|
+
Trigger a process reboot to reload source code changes.
|
|
65
|
+
|
|
66
|
+
Use this after modifying your own source files (with Edit/Write) to
|
|
67
|
+
reload the changes. The continuation_message should describe what
|
|
68
|
+
you were doing and what to do next, since short-term memory is lost
|
|
69
|
+
on reboot. Be detailed — this message is your only context after restart.
|
|
70
|
+
DESC
|
|
71
|
+
|
|
72
|
+
param :continuation_message,
|
|
73
|
+
type: "string",
|
|
74
|
+
desc: "Detailed message describing what to do after reboot. " \
|
|
75
|
+
"This is your only context — include what you changed, why, and what to do next.",
|
|
76
|
+
required: true
|
|
77
|
+
|
|
78
|
+
param :reason,
|
|
79
|
+
type: "string",
|
|
80
|
+
desc: "Brief reason for the reboot (for logging)",
|
|
81
|
+
required: false
|
|
82
|
+
|
|
83
|
+
# @param directory [String] Agent working directory
|
|
84
|
+
def initialize(directory:)
|
|
85
|
+
super()
|
|
86
|
+
@directory = File.expand_path(directory)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [String] Tool display name
|
|
90
|
+
def name
|
|
91
|
+
"Reboot"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Write a reboot signal file for the CLI to detect.
|
|
95
|
+
#
|
|
96
|
+
# @param continuation_message [String] Message to resume with after reboot
|
|
97
|
+
# @param reason [String, nil] Brief reason for the reboot
|
|
98
|
+
# @return [String] Confirmation message
|
|
99
|
+
def execute(continuation_message:, reason: nil)
|
|
100
|
+
pid = self.class.session_pid
|
|
101
|
+
return error("session_pid not set — Reboot tool requires CLI integration") unless pid
|
|
102
|
+
|
|
103
|
+
if continuation_message.nil? || continuation_message.strip.empty?
|
|
104
|
+
return validation_error("continuation_message is required")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
signal = {
|
|
108
|
+
continuation_message: continuation_message,
|
|
109
|
+
reason: reason,
|
|
110
|
+
timestamp: Time.now.iso8601,
|
|
111
|
+
pid: pid,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
path = self.class.signal_path_for(@directory, pid)
|
|
115
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
116
|
+
File.write(path, JSON.pretty_generate(signal))
|
|
117
|
+
|
|
118
|
+
"Reboot signal written. The process will restart after this response completes. " \
|
|
119
|
+
"Your continuation message has been saved and will be sent as the first prompt after reboot."
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmCLI
|
|
4
|
+
module V3
|
|
5
|
+
# Pure data model for the input prompt line with cursor tracking.
|
|
6
|
+
#
|
|
7
|
+
# Manages the prompt text, typing buffer, and cursor position with no
|
|
8
|
+
# I/O side effects. The {Display} coordinator calls {#render} and
|
|
9
|
+
# uses {#cursor_position} to place the terminal cursor.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# input = TextInput.new(prompt_text: "you> ")
|
|
13
|
+
# input.type_char("h")
|
|
14
|
+
# input.type_char("i")
|
|
15
|
+
# input.render # => "\e[32myou> \e[0mhi"
|
|
16
|
+
# input.submit # => "hi"
|
|
17
|
+
class TextInput
|
|
18
|
+
# @return [String] current buffer contents
|
|
19
|
+
attr_reader :buffer
|
|
20
|
+
|
|
21
|
+
# @return [Integer] cursor position in the buffer (0 = before first char)
|
|
22
|
+
attr_reader :cursor
|
|
23
|
+
|
|
24
|
+
# @return [Integer] visible length of the prompt (without ANSI codes)
|
|
25
|
+
attr_reader :prompt_visible_length
|
|
26
|
+
|
|
27
|
+
# @param prompt_text [String] visible prompt label (e.g. "you> ")
|
|
28
|
+
def initialize(prompt_text: "you> ")
|
|
29
|
+
@prompt_text = prompt_text
|
|
30
|
+
@prompt_str = ANSIColors.green(prompt_text)
|
|
31
|
+
@prompt_visible_length = prompt_text.length
|
|
32
|
+
@buffer = +""
|
|
33
|
+
@cursor = 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Insert a character at the cursor position.
|
|
37
|
+
#
|
|
38
|
+
# @param char [String] single character to insert
|
|
39
|
+
# @return [void]
|
|
40
|
+
def type_char(char)
|
|
41
|
+
@buffer.insert(@cursor, char)
|
|
42
|
+
@cursor += char.length
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Delete the character before the cursor.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def backspace
|
|
49
|
+
return if @cursor.zero?
|
|
50
|
+
|
|
51
|
+
@buffer.slice!(@cursor - 1)
|
|
52
|
+
@cursor -= 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Return the buffer contents and clear it.
|
|
56
|
+
#
|
|
57
|
+
# @return [String] the text that was in the buffer
|
|
58
|
+
def submit
|
|
59
|
+
text = @buffer.dup
|
|
60
|
+
@buffer = +""
|
|
61
|
+
@cursor = 0
|
|
62
|
+
text
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Replace the entire buffer and move cursor to end.
|
|
66
|
+
#
|
|
67
|
+
# @param text [String, nil] replacement text
|
|
68
|
+
# @return [void]
|
|
69
|
+
def replace(text)
|
|
70
|
+
@buffer = +(text || "")
|
|
71
|
+
@cursor = @buffer.length
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether the buffer is empty or only whitespace.
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def blank?
|
|
78
|
+
@buffer.strip.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether the buffer contains newlines.
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def multiline?
|
|
85
|
+
@buffer.include?("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Move cursor one position left.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def move_left
|
|
92
|
+
@cursor -= 1 if @cursor > 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Move cursor one position right.
|
|
96
|
+
#
|
|
97
|
+
# @return [void]
|
|
98
|
+
def move_right
|
|
99
|
+
@cursor += 1 if @cursor < @buffer.length
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Move cursor up one line (multiline only).
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def move_up
|
|
106
|
+
row, col = cursor_row_col
|
|
107
|
+
return if row.zero?
|
|
108
|
+
|
|
109
|
+
target_line = buffer_lines[row - 1]
|
|
110
|
+
|
|
111
|
+
# Calculate target column accounting for prompt on line 0
|
|
112
|
+
if row == 1
|
|
113
|
+
# Moving from line 1 to line 0 (which has the prompt)
|
|
114
|
+
# Visual columns should align, so subtract prompt length
|
|
115
|
+
target_col = [col - @prompt_visible_length, 0].max
|
|
116
|
+
target_col = [target_col, target_line.length].min
|
|
117
|
+
else
|
|
118
|
+
# Moving between lines that don't have the prompt
|
|
119
|
+
target_col = [col, target_line.length].min
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@cursor = line_start_offset(row - 1) + target_col
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Move cursor down one line (multiline only).
|
|
126
|
+
#
|
|
127
|
+
# @return [void]
|
|
128
|
+
def move_down
|
|
129
|
+
row, col = cursor_row_col
|
|
130
|
+
lines = buffer_lines
|
|
131
|
+
return if row >= lines.size - 1
|
|
132
|
+
|
|
133
|
+
target_line = lines[row + 1]
|
|
134
|
+
|
|
135
|
+
# Calculate target column accounting for prompt on line 0
|
|
136
|
+
if row.zero?
|
|
137
|
+
# Moving from line 0 (which has the prompt) to line 1
|
|
138
|
+
# Visual columns should align, so add prompt length
|
|
139
|
+
target_col = col + @prompt_visible_length
|
|
140
|
+
target_col = [target_col, target_line.length].min
|
|
141
|
+
else
|
|
142
|
+
# Moving between lines that don't have the prompt
|
|
143
|
+
target_col = [col, target_line.length].min
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@cursor = line_start_offset(row + 1) + target_col
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Render the full prompt line (prompt string + buffer).
|
|
150
|
+
#
|
|
151
|
+
# @return [String] the rendered prompt line with ANSI styling
|
|
152
|
+
def render
|
|
153
|
+
"#{@prompt_str}#{@buffer}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Calculate cursor position as terminal row/column offsets from
|
|
157
|
+
# the start of the prompt, accounting for line wrapping.
|
|
158
|
+
#
|
|
159
|
+
# @param width [Integer] terminal width in columns
|
|
160
|
+
# @return [Array(Integer, Integer)] [rows_from_start, column]
|
|
161
|
+
def cursor_position(width)
|
|
162
|
+
return [0, @prompt_visible_length + @cursor] if width <= 0
|
|
163
|
+
|
|
164
|
+
text_before = @buffer[0, @cursor] || ""
|
|
165
|
+
lines = text_before.split("\n", -1)
|
|
166
|
+
|
|
167
|
+
row = 0
|
|
168
|
+
lines.each_with_index do |line, idx|
|
|
169
|
+
visible = (idx.zero? ? @prompt_visible_length : 0) + line.length
|
|
170
|
+
row += visible / width
|
|
171
|
+
end
|
|
172
|
+
row += lines.size - 1 if lines.size > 1
|
|
173
|
+
|
|
174
|
+
last_line = lines.last || ""
|
|
175
|
+
last_prefix = lines.size <= 1 ? @prompt_visible_length : 0
|
|
176
|
+
col = (last_prefix + last_line.length) % width
|
|
177
|
+
|
|
178
|
+
[row, col]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Calculate extra lines from wrapping and explicit newlines.
|
|
182
|
+
#
|
|
183
|
+
# @param width [Integer] terminal width in columns
|
|
184
|
+
# @return [Integer] number of extra lines (0 if no wrapping)
|
|
185
|
+
def wrapped_lines(width)
|
|
186
|
+
return 0 if width <= 0
|
|
187
|
+
|
|
188
|
+
lines = buffer_lines
|
|
189
|
+
total = 0
|
|
190
|
+
|
|
191
|
+
lines.each_with_index do |line, idx|
|
|
192
|
+
visible = (idx.zero? ? @prompt_visible_length : 0) + line.length
|
|
193
|
+
total += visible > 0 ? (visible - 1) / width : 0
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
total += lines.size - 1 if lines.size > 1
|
|
197
|
+
total
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
# Split buffer into lines.
|
|
203
|
+
#
|
|
204
|
+
# @return [Array<String>]
|
|
205
|
+
def buffer_lines
|
|
206
|
+
@buffer.split("\n", -1)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Row and column of cursor within the buffer lines (not terminal).
|
|
210
|
+
#
|
|
211
|
+
# @return [Array(Integer, Integer)] [line_index, column_in_line]
|
|
212
|
+
def cursor_row_col
|
|
213
|
+
before = @buffer[0, @cursor] || ""
|
|
214
|
+
lines = before.split("\n", -1)
|
|
215
|
+
return [0, 0] if lines.empty?
|
|
216
|
+
|
|
217
|
+
[lines.size - 1, lines.last.length]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Byte offset in @buffer where a given line starts.
|
|
221
|
+
#
|
|
222
|
+
# @param line_index [Integer]
|
|
223
|
+
# @return [Integer]
|
|
224
|
+
def line_start_offset(line_index)
|
|
225
|
+
offset = 0
|
|
226
|
+
@buffer.split("\n", -1).each_with_index do |line, idx|
|
|
227
|
+
return offset if idx == line_index
|
|
228
|
+
|
|
229
|
+
offset += line.length + 1 # +1 for the \n
|
|
230
|
+
end
|
|
231
|
+
offset
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
data/lib/swarm_cli/v3.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# V3 CLI Entry Point
|
|
4
|
+
#
|
|
5
|
+
# This file provides a standalone entry point for SwarmCLI V3 that doesn't
|
|
6
|
+
# depend on the V2 CLI code. It loads only the minimal dependencies needed
|
|
7
|
+
# for the V3 interactive CLI.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# require "swarm_cli/v3"
|
|
11
|
+
# SwarmCLI::V3::CLI.start(ARGV)
|
|
12
|
+
|
|
13
|
+
require "io/console"
|
|
14
|
+
require "optparse"
|
|
15
|
+
|
|
16
|
+
require "tty/markdown"
|
|
17
|
+
|
|
18
|
+
require "swarm_sdk/v3"
|
|
19
|
+
|
|
20
|
+
require "zeitwerk"
|
|
21
|
+
|
|
22
|
+
module SwarmCLI
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
|
|
25
|
+
# V3 is the next-generation CLI for SwarmSDK V3 agents.
|
|
26
|
+
#
|
|
27
|
+
# Features:
|
|
28
|
+
# - Interactive REPL with always-available input
|
|
29
|
+
# - Single-prompt execution mode
|
|
30
|
+
# - Memory maintenance commands
|
|
31
|
+
# - Minimal dependencies (custom ANSI styling, raw input handling)
|
|
32
|
+
module V3
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# V3 CLI Zeitwerk setup
|
|
37
|
+
v3_cli_dir = File.join(__dir__, "v3")
|
|
38
|
+
already_managed = false
|
|
39
|
+
Zeitwerk::Registry.loaders.each do |loader|
|
|
40
|
+
loader.dirs.each { |dir| already_managed = true if v3_cli_dir.start_with?(dir) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
unless already_managed
|
|
44
|
+
v3_cli_loader = Zeitwerk::Loader.new
|
|
45
|
+
v3_cli_loader.tag = "swarm_cli_v3"
|
|
46
|
+
v3_cli_loader.push_dir(v3_cli_dir, namespace: SwarmCLI::V3)
|
|
47
|
+
v3_cli_loader.inflector.inflect(
|
|
48
|
+
"ansi_colors" => "ANSIColors",
|
|
49
|
+
"cli" => "CLI",
|
|
50
|
+
)
|
|
51
|
+
v3_cli_loader.setup
|
|
52
|
+
end
|