ruby-progress 1.2.4 → 1.3.2

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.
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'json'
5
+ require 'securerandom'
4
6
  require_relative 'ripple_options'
7
+ require_relative '../output_capture'
5
8
 
6
9
  # Enhanced Ripple CLI with unified flags (extracted from bin/prg)
7
10
  module RippleCLI
@@ -30,8 +33,13 @@ module RippleCLI
30
33
  )
31
34
  exit
32
35
  elsif options[:daemon]
33
- # For daemon mode, detach so shell has no tracked job
34
- PrgCLI.daemonize
36
+ # For daemon mode, detach so shell has no tracked job unless the user
37
+ # requested a non-detaching background child via --no-detach.
38
+ if options[:no_detach]
39
+ PrgCLI.backgroundize
40
+ else
41
+ PrgCLI.daemonize
42
+ end
35
43
 
36
44
  # For daemon mode, default message if none provided
37
45
  text = options[:message] || ARGV.join(' ')
@@ -60,17 +68,42 @@ module RippleCLI
60
68
  end
61
69
 
62
70
  def self.run_with_command(text, options)
63
- captured_output = nil
64
- RubyProgress::Ripple.progress(text, options) do
71
+ if $stdout.tty?
72
+ # Interactive TTY: use PTY-based capture so the animation can run while the
73
+ # command executes. We only print captured stdout if options[:output] == :stdout.
74
+ oc = RubyProgress::OutputCapture.new(command: options[:command], lines: options[:output_lines] || 3, position: options[:output_position] || :above)
75
+ oc.start
76
+
77
+ # Create rippler. Attach output capture only when the user requested
78
+ # live stdout display via --stdout; otherwise start the PTY reader so
79
+ # we can collect the child's exit status but do not call redraw.
80
+ rippler = RubyProgress::Ripple.new(text, options)
81
+ rippler.instance_variable_set(:@output_capture, oc) if options[:output] == :stdout
82
+
83
+ thread = Thread.new { loop { rippler.advance } }
84
+ oc.wait
85
+ thread.kill
86
+
87
+ captured_lines = oc.lines
88
+ captured_output = captured_lines.join("\n")
89
+ success = oc.exit_status.nil? || oc.exit_status.zero?
90
+ else
91
+ # Non-interactive / CI: fallback to legacy synchronous capture
65
92
  captured_output = `#{options[:command]} 2>&1`
93
+ success = $CHILD_STATUS.success?
66
94
  end
67
95
 
68
- success = $CHILD_STATUS.success?
69
-
70
96
  puts captured_output if options[:output] == :stdout
97
+
71
98
  if options[:success_message] || options[:complete_checkmark]
72
99
  message = success ? options[:success_message] : options[:fail_message] || options[:success_message]
73
- RubyProgress::Ripple.complete(text, message, options[:complete_checkmark], success)
100
+ RubyProgress::Ripple.complete(
101
+ text,
102
+ message,
103
+ options[:complete_checkmark],
104
+ success,
105
+ icons: { success: options[:success_icon], error: options[:error_icon] }
106
+ )
74
107
  end
75
108
  exit success ? 0 : 1
76
109
  end
@@ -90,7 +123,6 @@ module RippleCLI
90
123
  pid_file = options[:pid_file] || RubyProgress::Daemon.default_pid_file
91
124
  FileUtils.mkdir_p(File.dirname(pid_file))
92
125
  File.write(pid_file, Process.pid.to_s)
93
-
94
126
  begin
95
127
  # For Ripple, re-use the existing animation loop via a simple loop
96
128
  RubyProgress::Utils.hide_cursor
@@ -102,6 +134,9 @@ module RippleCLI
102
134
  Signal.trap('TERM') { stop_requested = true }
103
135
  Signal.trap('HUP') { stop_requested = true }
104
136
 
137
+ job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
138
+ job_thread = Thread.new { process_daemon_jobs_for_rippler(job_dir, rippler, options) }
139
+
105
140
  rippler.advance until stop_requested
106
141
  ensure
107
142
  RubyProgress::Utils.clear_line
@@ -128,7 +163,8 @@ module RippleCLI
128
163
  message,
129
164
  success: success_val,
130
165
  show_checkmark: check,
131
- output_stream: :stdout
166
+ output_stream: :stdout,
167
+ icons: { success: options[:success_icon], error: options[:error_icon] }
132
168
  )
133
169
  end
134
170
  rescue StandardError
@@ -142,9 +178,52 @@ module RippleCLI
142
178
  end
143
179
  end
144
180
 
181
+ # stop job thread and cleanup
182
+ job_thread&.kill
145
183
  FileUtils.rm_f(pid_file)
146
184
  end
147
185
  end
148
186
 
187
+ def self.process_daemon_jobs_for_rippler(job_dir, rippler, options)
188
+ RubyProgress::Daemon.process_jobs(job_dir) do |job|
189
+ jid = job['id'] || SecureRandom.uuid
190
+ log_path = begin
191
+ File.join(File.dirname(job_dir), "#{jid}.log")
192
+ rescue StandardError
193
+ nil
194
+ end
195
+
196
+ oc = RubyProgress::OutputCapture.new(
197
+ command: job['command'],
198
+ lines: options[:output_lines] || 3,
199
+ position: options[:output_position] || :above,
200
+ log_path: log_path
201
+ )
202
+ oc.start
203
+
204
+ rippler.instance_variable_set(:@output_capture, oc)
205
+ oc.wait
206
+ captured = oc.lines.join("\n")
207
+ exit_status = oc.exit_status
208
+ rippler.instance_variable_set(:@output_capture, nil)
209
+
210
+ success = exit_status.to_i.zero?
211
+ if job['message']
212
+ RubyProgress::Utils.display_completion(
213
+ job['message'],
214
+ success: success,
215
+ show_checkmark: job['checkmark'] || false,
216
+ output_stream: :stdout,
217
+ icons: { success: options[:success_icon], error: options[:error_icon] }
218
+ )
219
+ end
220
+
221
+ { 'exit_status' => exit_status, 'output' => captured, 'log_path' => log_path }
222
+ rescue StandardError
223
+ # ignore per-job errors; process_jobs will write result
224
+ nil
225
+ end
226
+ end
227
+
149
228
  # Options parsing moved to ripple_options.rb
150
229
  end
@@ -17,6 +17,8 @@ module RippleCLI
17
17
  fail_message: nil,
18
18
  complete_checkmark: false,
19
19
  output: :error,
20
+ output_position: :above,
21
+ output_lines: 3,
20
22
  message: nil
21
23
  }
22
24
 
@@ -57,10 +59,26 @@ module RippleCLI
57
59
  options[:command] = command
58
60
  end
59
61
 
62
+ opts.on('--output-position POSITION', 'Position to render captured output: above or below (default: above)') do |pos|
63
+ options[:output_position] = pos.to_sym
64
+ end
65
+
66
+ opts.on('--output-lines N', Integer, 'Number of output lines to reserve for captured output (default: 3)') do |n|
67
+ options[:output_lines] = n
68
+ end
69
+
60
70
  opts.on('--success MESSAGE', 'Success message to display') do |msg|
61
71
  options[:success_message] = msg
62
72
  end
63
73
 
74
+ opts.on('--success-icon ICON', 'Custom success icon to show with completion messages') do |ic|
75
+ options[:success_icon] = ic
76
+ end
77
+
78
+ opts.on('--error-icon ICON', 'Custom error icon to show with failure messages') do |ic|
79
+ options[:error_icon] = ic
80
+ end
81
+
64
82
  opts.on('--error MESSAGE', 'Error message to display') do |msg|
65
83
  options[:fail_message] = msg
66
84
  end
@@ -84,6 +102,10 @@ module RippleCLI
84
102
  options[:daemon] = true
85
103
  end
86
104
 
105
+ opts.on('--no-detach', 'When used with --daemon: run background child but do not fully detach from the terminal') do
106
+ options[:no_detach] = true
107
+ end
108
+
87
109
  opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
88
110
  options[:pid_file] = file
89
111
  end
@@ -10,7 +10,10 @@ module TwirlCLI
10
10
  # so the `TwirlCLI` module stays small and focused on dispatching.
11
11
  module Options
12
12
  def self.parse_cli_options
13
- options = {}
13
+ options = {
14
+ output_position: :above,
15
+ output_lines: 3
16
+ }
14
17
 
15
18
  OptionParser.new do |opts|
16
19
  opts.banner = 'Usage: prg twirl [options]'
@@ -29,6 +32,12 @@ module TwirlCLI
29
32
  options[:style] = style
30
33
  end
31
34
 
35
+ opts.on('-d', '--direction DIRECTION', 'Animation direction (forward/bidirectional or f/b)') do |direction|
36
+ # Twirl is a spinner and doesn't visibly change with direction, but accept the
37
+ # flag for parity with other subcommands (Worm/Ripple) so scripts can use it.
38
+ options[:direction] = direction =~ /^f/i ? :forward_only : :bidirectional
39
+ end
40
+
32
41
  opts.on('--ends CHARS', 'Start/end characters (even number of chars, split in half)') do |chars|
33
42
  options[:ends] = chars
34
43
  end
@@ -40,10 +49,26 @@ module TwirlCLI
40
49
  options[:command] = command
41
50
  end
42
51
 
52
+ opts.on('--output-position POSITION', 'Position to render captured output: above or below (default: above)') do |pos|
53
+ options[:output_position] = pos.to_sym
54
+ end
55
+
56
+ opts.on('--output-lines N', Integer, 'Number of output lines to reserve for captured output (default: 3)') do |n|
57
+ options[:output_lines] = n
58
+ end
59
+
43
60
  opts.on('--success MESSAGE', 'Success message to display') do |text|
44
61
  options[:success] = text
45
62
  end
46
63
 
64
+ opts.on('--success-icon ICON', 'Custom success icon to show with completion messages') do |ic|
65
+ options[:success_icon] = ic
66
+ end
67
+
68
+ opts.on('--error-icon ICON', 'Custom error icon to show with failure messages') do |ic|
69
+ options[:error_icon] = ic
70
+ end
71
+
47
72
  opts.on('--error MESSAGE', 'Error message to display') do |text|
48
73
  options[:error] = text
49
74
  end
@@ -63,6 +88,10 @@ module TwirlCLI
63
88
  options[:daemon] = true
64
89
  end
65
90
 
91
+ opts.on('--no-detach', 'When used with --daemon/--daemon-as: run background child but do not fully detach from the terminal') do
92
+ options[:no_detach] = true
93
+ end
94
+
66
95
  opts.on('--daemon-as NAME', 'Run in daemon mode with custom name (creates /tmp/ruby-progress/NAME.pid)') do |name|
67
96
  options[:daemon] = true
68
97
  options[:daemon_name] = name
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'json'
5
5
  require_relative 'twirl_spinner'
6
+ require_relative '../output_capture'
6
7
 
7
8
  # Top-level runtime helper module for the Twirl CLI.
8
9
  #
@@ -22,8 +23,25 @@ module TwirlRunner
22
23
  RubyProgress::Utils.hide_cursor
23
24
  spinner_thread = Thread.new { loop { spinner.animate } }
24
25
 
25
- captured_output = `#{options[:command]} 2>&1`
26
- success = $CHILD_STATUS.success?
26
+ if $stdout.tty? && options[:stdout]
27
+ oc = RubyProgress::OutputCapture.new(
28
+ command: options[:command],
29
+ lines: options[:output_lines] || 3,
30
+ position: options[:output_position] || :above
31
+ )
32
+ oc.start
33
+
34
+ spinner.instance_variable_set(:@output_capture, oc)
35
+
36
+ # wait for command while spinner thread runs
37
+ oc.wait
38
+ captured_lines = oc.lines
39
+ captured_output = captured_lines.join("\n")
40
+ success = true
41
+ else
42
+ captured_output = `#{options[:command]} 2>&1`
43
+ success = $CHILD_STATUS.success?
44
+ end
27
45
 
28
46
  spinner_thread.kill
29
47
  RubyProgress::Utils.clear_line
@@ -40,7 +58,8 @@ module TwirlRunner
40
58
  RubyProgress::Utils.display_completion(
41
59
  final_msg,
42
60
  success: success,
43
- show_checkmark: options[:checkmark]
61
+ show_checkmark: options[:checkmark],
62
+ icons: { success: options[:success_icon], error: options[:error_icon] }
44
63
  )
45
64
  end
46
65
 
@@ -60,7 +79,8 @@ module TwirlRunner
60
79
  RubyProgress::Utils.display_completion(
61
80
  options[:success] || 'Complete',
62
81
  success: true,
63
- show_checkmark: options[:checkmark]
82
+ show_checkmark: options[:checkmark],
83
+ icons: { success: options[:success_icon], error: options[:error_icon] }
64
84
  )
65
85
  end
66
86
  end
@@ -82,6 +102,41 @@ module TwirlRunner
82
102
 
83
103
  begin
84
104
  RubyProgress::Utils.hide_cursor
105
+
106
+ # Start job processor thread for twirl
107
+ job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
108
+ job_thread = Thread.new do
109
+ RubyProgress::Daemon.process_jobs(job_dir) do |job|
110
+ oc = RubyProgress::OutputCapture.new(
111
+ command: job['command'],
112
+ lines: options[:output_lines] || 3,
113
+ position: options[:output_position] || :above
114
+ )
115
+ oc.start
116
+
117
+ spinner.instance_variable_set(:@output_capture, oc)
118
+ oc.wait
119
+ captured = oc.lines.join("\n")
120
+ exit_status = oc.exit_status
121
+ spinner.instance_variable_set(:@output_capture, nil)
122
+
123
+ success = exit_status.to_i.zero?
124
+ if job['message']
125
+ RubyProgress::Utils.display_completion(
126
+ job['message'],
127
+ success: success,
128
+ show_checkmark: job['checkmark'] || false,
129
+ output_stream: :stdout,
130
+ icons: { success: options[:success_icon], error: options[:error_icon] }
131
+ )
132
+ end
133
+
134
+ { 'exit_status' => exit_status, 'output' => captured }
135
+ rescue StandardError
136
+ # ignore
137
+ end
138
+ end
139
+
85
140
  spinner.animate until stop_requested
86
141
  ensure
87
142
  RubyProgress::Utils.clear_line
@@ -100,7 +155,8 @@ module TwirlRunner
100
155
  message,
101
156
  success: success_val,
102
157
  show_checkmark: check,
103
- output_stream: :stdout
158
+ output_stream: :stdout,
159
+ icons: { success: options[:success_icon], error: options[:error_icon] }
104
160
  )
105
161
  end
106
162
  rescue StandardError
@@ -114,6 +170,7 @@ module TwirlRunner
114
170
  end
115
171
  end
116
172
 
173
+ job_thread&.kill
117
174
  FileUtils.rm_f(pid_file)
118
175
  end
119
176
  end
@@ -10,14 +10,29 @@ require_relative '../utils'
10
10
  class TwirlSpinner
11
11
  def initialize(message, options = {})
12
12
  @message = message
13
- @style = parse_style(options[:style] || 'dots')
14
13
  @speed = parse_speed(options[:speed] || 'medium')
15
- @frames = RubyProgress::INDICATORS[@style] || RubyProgress::INDICATORS[:dots]
14
+
15
+ style_opt = options[:style].to_s
16
+ if style_opt.start_with?('custom=')
17
+ chars = style_opt.sub('custom=', '')
18
+ @frames = if chars.length >= 3
19
+ chars.chars
20
+ elsif chars.length == 2
21
+ [chars[0], chars[1], chars[1]]
22
+ else
23
+ [chars, chars, chars]
24
+ end
25
+ @style = :custom
26
+ else
27
+ @style = parse_style(style_opt.empty? ? 'dots' : style_opt)
28
+ @frames = RubyProgress::INDICATORS[@style] || RubyProgress::INDICATORS[:dots]
29
+ end
16
30
  @start_chars, @end_chars = RubyProgress::Utils.parse_ends(options[:ends])
17
31
  @index = 0
18
32
  end
19
33
 
20
34
  def animate
35
+ @output_capture&.redraw($stderr)
21
36
  if @message && !@message.empty?
22
37
  $stderr.print "\r\e[2K#{@start_chars}#{@message} #{@frames[@index]}#{@end_chars}"
23
38
  else
@@ -35,6 +50,8 @@ class TwirlSpinner
35
50
 
36
51
  style_lower = style_input.to_s.downcase.strip
37
52
 
53
+ # parse_style returns a symbol key for RubyProgress::INDICATORS
54
+
38
55
  indicator_keys = RubyProgress::INDICATORS.keys.map(&:to_s)
39
56
  return style_lower.to_sym if indicator_keys.include?(style_lower)
40
57
 
@@ -6,6 +6,14 @@ require_relative 'worm_options'
6
6
 
7
7
  # Enhanced Worm CLI (extracted from bin/prg)
8
8
  module WormCLI
9
+ def self.resolve_pid_file(options, name_key = :daemon_name)
10
+ return options[:pid_file] if options[:pid_file]
11
+
12
+ return "/tmp/ruby-progress/#{options[name_key]}.pid" if options[name_key]
13
+
14
+ RubyProgress::Daemon.default_pid_file
15
+ end
16
+
9
17
  def self.run
10
18
  options = WormCLI::Options.parse_cli_options
11
19
 
@@ -25,8 +33,14 @@ module WormCLI
25
33
  )
26
34
  exit
27
35
  elsif options[:daemon]
28
- # Detach before starting daemon logic so there's no tracked shell job
29
- PrgCLI.daemonize
36
+ # Detach (or background without detaching) before starting daemon logic
37
+ # so the invoking shell/script continues immediately.
38
+ if options[:no_detach]
39
+ PrgCLI.backgroundize
40
+ else
41
+ PrgCLI.daemonize
42
+ end
43
+
30
44
  run_daemon_mode(options)
31
45
  else
32
46
  progress = RubyProgress::Worm.new(options)
@@ -47,29 +61,57 @@ module WormCLI
47
61
  progress = RubyProgress::Worm.new(options)
48
62
 
49
63
  begin
64
+ # Start job processor thread for worm
65
+ job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
66
+ job_thread = Thread.new do
67
+ RubyProgress::Daemon.process_jobs(job_dir) do |job|
68
+ jid = job['id'] || SecureRandom.uuid
69
+ log_path = begin
70
+ File.join(File.dirname(job_dir), "#{jid}.log")
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ oc = RubyProgress::OutputCapture.new(
76
+ command: job['command'],
77
+ lines: options[:output_lines] || 3,
78
+ position: options[:output_position] || :above,
79
+ log_path: log_path
80
+ )
81
+ oc.start
82
+
83
+ progress.instance_variable_set(:@output_capture, oc)
84
+ oc.wait
85
+ captured = oc.lines.join("\n")
86
+ exit_status = oc.exit_status
87
+ progress.instance_variable_set(:@output_capture, nil)
88
+
89
+ success = exit_status.to_i.zero?
90
+ if job['message']
91
+ RubyProgress::Utils.display_completion(
92
+ job['message'],
93
+ success: success,
94
+ show_checkmark: job['checkmark'] || false,
95
+ output_stream: :stdout,
96
+ icons: { success: options[:success_icon], error: options[:error_icon] }
97
+ )
98
+ end
99
+
100
+ { 'exit_status' => exit_status, 'output' => captured, 'log_path' => log_path }
101
+ rescue StandardError
102
+ # ignore per-job errors
103
+ end
104
+ end
105
+
50
106
  progress.run_daemon_mode(
51
107
  success_message: options[:success],
52
108
  show_checkmark: options[:checkmark],
53
- control_message_file: RubyProgress::Daemon.control_message_file(pid_file)
109
+ control_message_file: RubyProgress::Daemon.control_message_file(pid_file),
110
+ icons: { success: options[:success_icon], error: options[:error_icon] }
54
111
  )
55
112
  ensure
113
+ job_thread&.kill
56
114
  FileUtils.rm_f(pid_file)
57
115
  end
58
116
  end
59
-
60
- def self.resolve_pid_file(options, name_key)
61
- return options[:pid_file] if options[:pid_file]
62
-
63
- if options[name_key]
64
- "/tmp/ruby-progress/#{options[name_key]}.pid"
65
- else
66
- RubyProgress::Daemon.default_pid_file
67
- end
68
- end
69
-
70
- # parse_cli_options is intentionally kept together; further extraction possible
71
- # parse_cli_options is intentionally delegated to WormCLI::Options
72
- def self.parse_cli_options
73
- WormCLI::Options.parse_cli_options
74
- end
75
117
  end
@@ -9,8 +9,11 @@ module WormCLI
9
9
  # the main dispatcher to keep the CLI module small and focused.
10
10
  module Options
11
11
  def self.parse_cli_options
12
- options = {}
13
-
12
+ options = {
13
+ output_position: :above,
14
+ output_lines: 3
15
+ }
16
+ # rubocop:disable Metrics/BlockLength
14
17
  begin
15
18
  OptionParser.new do |opts|
16
19
  opts.banner = 'Usage: prg worm [options]'
@@ -48,10 +51,26 @@ module WormCLI
48
51
  options[:command] = command
49
52
  end
50
53
 
54
+ opts.on('--output-position POSITION', 'Position to render captured output: above or below (default: above)') do |pos|
55
+ options[:output_position] = pos.to_sym
56
+ end
57
+
58
+ opts.on('--output-lines N', Integer, 'Number of output lines to reserve for captured output (default: 3)') do |n|
59
+ options[:output_lines] = n
60
+ end
61
+
51
62
  opts.on('--success MESSAGE', 'Success message to display') do |text|
52
63
  options[:success] = text
53
64
  end
54
65
 
66
+ opts.on('--success-icon ICON', 'Custom success icon to show with completion messages') do |ic|
67
+ options[:success_icon] = ic
68
+ end
69
+
70
+ opts.on('--error-icon ICON', 'Custom error icon to show with failure messages') do |ic|
71
+ options[:error_icon] = ic
72
+ end
73
+
55
74
  opts.on('--error MESSAGE', 'Error message to display') do |text|
56
75
  options[:error] = text
57
76
  end
@@ -76,6 +95,16 @@ module WormCLI
76
95
  options[:daemon_name] = name
77
96
  end
78
97
 
98
+ # Accept --daemon-name as an alias for --daemon-as for compatibility
99
+ opts.on('--daemon-name NAME', 'Alias for --daemon-as (compat)') do |name|
100
+ options[:daemon] = true
101
+ options[:daemon_name] = name
102
+ end
103
+
104
+ opts.on('--no-detach', 'When used with --daemon/--daemon-as: run background child but do not fully detach from the terminal') do
105
+ options[:no_detach] = true
106
+ end
107
+
79
108
  opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
80
109
  options[:pid_file] = file
81
110
  end
@@ -149,7 +178,7 @@ module WormCLI
149
178
  puts "Run 'prg worm --help' for more information."
150
179
  exit 1
151
180
  end
152
-
181
+ # rubocop:enable Metrics/BlockLength
153
182
  options
154
183
  end
155
184
  end
@@ -4,6 +4,7 @@
4
4
  require 'open3'
5
5
  require 'json'
6
6
  require_relative '../utils'
7
+ require_relative '../output_capture'
7
8
 
8
9
  # Runtime helper methods for RubyProgress::Worm
9
10
  #
@@ -56,21 +57,36 @@ module WormRunner
56
57
  stdout_content = nil
57
58
 
58
59
  begin
59
- stdout_content = animate do
60
- Open3.popen3(@command) do |_stdin, stdout, stderr, wait_thr|
61
- captured_stdout = stdout.read
62
- stderr_content = stderr.read
63
- exit_code = wait_thr.value.exitstatus
64
-
65
- unless wait_thr.value.success?
66
- error_msg = @error_text || "Command failed with exit code #{exit_code}"
67
- error_msg += ": #{stderr_content.strip}" if stderr_content && !stderr_content.empty?
68
- raise StandardError, error_msg
69
- end
70
-
71
- captured_stdout
72
- end
73
- end
60
+ stdout_content = if $stdout.tty? && @output_stdout
61
+ oc = RubyProgress::OutputCapture.new(
62
+ command: @command,
63
+ lines: @output_lines || 3,
64
+ position: @output_position || :above
65
+ )
66
+ oc.start
67
+ @output_capture = oc
68
+ animate do
69
+ oc.wait
70
+ end
71
+ @output_capture = nil
72
+ oc.lines.join("\n")
73
+ else
74
+ animate do
75
+ Open3.popen3(@command) do |_stdin, stdout, stderr, wait_thr|
76
+ captured_stdout = stdout.read
77
+ stderr_content = stderr.read
78
+ exit_code = wait_thr.value.exitstatus
79
+
80
+ unless wait_thr.value.success?
81
+ error_msg = @error_text || "Command failed with exit code #{exit_code}"
82
+ error_msg += ": #{stderr_content.strip}" if stderr_content && !stderr_content.empty?
83
+ raise StandardError, error_msg
84
+ end
85
+
86
+ captured_stdout
87
+ end
88
+ end
89
+ end
74
90
 
75
91
  puts stdout_content if @output_stdout && stdout_content
76
92
  rescue StandardError
@@ -103,7 +119,7 @@ module WormRunner
103
119
  @running = false
104
120
  end
105
121
 
106
- def run_daemon_mode(success_message: nil, show_checkmark: false, control_message_file: nil)
122
+ def run_daemon_mode(success_message: nil, show_checkmark: false, control_message_file: nil, icons: {})
107
123
  @running = true
108
124
  stop_requested = false
109
125
 
@@ -146,7 +162,8 @@ module WormRunner
146
162
  final_message,
147
163
  success: final_success,
148
164
  show_checkmark: final_checkmark,
149
- output_stream: :stdout
165
+ output_stream: :stdout,
166
+ icons: icons
150
167
  )
151
168
  end
152
169
 
@@ -161,6 +178,7 @@ module WormRunner
161
178
  @direction ||= 1
162
179
 
163
180
  message_part = @message && !@message.empty? ? "#{@message} " : ''
181
+ @output_capture&.redraw($stderr)
164
182
  $stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(@position, @direction)}#{@end_chars}"
165
183
  $stderr.flush
166
184
 
@@ -184,6 +202,7 @@ module WormRunner
184
202
 
185
203
  while @running
186
204
  message_part = @message && !@message.empty? ? "#{@message} " : ''
205
+ @output_capture&.redraw($stderr)
187
206
  $stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(position, direction)}#{@end_chars}"
188
207
  $stderr.flush
189
208
 
@@ -209,6 +228,7 @@ module WormRunner
209
228
 
210
229
  while @running && !stop_requested_proc.call
211
230
  message_part = @message && !@message.empty? ? "#{@message} " : ''
231
+ @output_capture&.redraw($stderr)
212
232
 
213
233
  $stderr.print "\r\e[2K"
214
234