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,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
@@ -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