taski 0.2.2 → 0.3.0

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.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Spinner animation with dots-style characters
6
+ class SpinnerAnimation
7
+ SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
8
+ FRAME_DELAY = 0.1
9
+
10
+ def initialize
11
+ @frame = 0
12
+ @running = false
13
+ @thread = nil
14
+ end
15
+
16
+ def start(terminal, task_name, &display_callback)
17
+ return if @running
18
+
19
+ @running = true
20
+ @frame = 0
21
+
22
+ @thread = Thread.new do
23
+ while @running
24
+ current_char = SPINNER_CHARS[@frame % SPINNER_CHARS.length]
25
+ display_callback&.call(current_char, task_name)
26
+
27
+ @frame += 1
28
+ sleep FRAME_DELAY
29
+ end
30
+ rescue
31
+ # Silently handle thread errors
32
+ end
33
+ end
34
+
35
+ def stop
36
+ @running = false
37
+ @thread&.join(0.2)
38
+ @thread = nil
39
+ end
40
+
41
+ def running?
42
+ @running
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_colors"
4
+
5
+ module Taski
6
+ module Progress
7
+ # Handles formatting of task display messages
8
+ class TaskFormatter
9
+ include DisplayColors
10
+
11
+ def format_completed_task(status)
12
+ color = status.success? ? COLORS[:green] : COLORS[:red]
13
+ "#{color}#{COLORS[:bold]}#{status.icon} #{status.name}#{COLORS[:reset]} #{COLORS[:dim]}#{status.format_duration}#{COLORS[:reset]}"
14
+ end
15
+
16
+ def format_current_task(spinner_char, task_name)
17
+ "#{COLORS[:cyan]}#{spinner_char}#{COLORS[:reset]} #{COLORS[:bold]}#{task_name}#{COLORS[:reset]}"
18
+ end
19
+
20
+ def format_output_line(line)
21
+ " #{COLORS[:dim]}#{line}#{COLORS[:reset]}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Represents task execution status
6
+ class TaskStatus
7
+ attr_reader :name, :duration, :error
8
+
9
+ def initialize(name:, duration: nil, error: nil)
10
+ @name = name
11
+ @duration = duration
12
+ @error = error
13
+ end
14
+
15
+ def success?
16
+ @error.nil?
17
+ end
18
+
19
+ def failure?
20
+ !success?
21
+ end
22
+
23
+ def duration_ms
24
+ return nil unless @duration
25
+ (@duration * 1000).round(1)
26
+ end
27
+
28
+ def icon
29
+ success? ? "✅" : "❌"
30
+ end
31
+
32
+ def format_duration
33
+ return "" unless duration_ms
34
+ "(#{duration_ms}ms)"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Terminal control operations with ANSI escape sequences
6
+ class TerminalController
7
+ # ANSI escape sequences
8
+ MOVE_UP = "\033[A"
9
+ CLEAR_LINE = "\033[K"
10
+ MOVE_UP_AND_CLEAR = "#{MOVE_UP}#{CLEAR_LINE}"
11
+
12
+ def initialize(output)
13
+ @output = output
14
+ end
15
+
16
+ def clear_lines(count)
17
+ return if count == 0
18
+
19
+ count.times { @output.print MOVE_UP_AND_CLEAR }
20
+ end
21
+
22
+ def puts(text)
23
+ @output.puts text
24
+ end
25
+
26
+ def print(text)
27
+ @output.print text
28
+ end
29
+
30
+ def flush
31
+ @output.flush
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,356 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
3
+ require_relative "progress/terminal_controller"
4
+ require_relative "progress/spinner_animation"
5
+ require_relative "progress/output_capture"
6
+ require_relative "progress/display_manager"
4
7
 
5
8
  module Taski
6
- # Terminal control operations with ANSI escape sequences
7
- class TerminalController
8
- # ANSI escape sequences
9
- MOVE_UP = "\033[A"
10
- CLEAR_LINE = "\033[K"
11
- MOVE_UP_AND_CLEAR = "#{MOVE_UP}#{CLEAR_LINE}"
9
+ # Backward compatibility aliases
10
+ TerminalController = Progress::TerminalController
11
+ SpinnerAnimation = Progress::SpinnerAnimation
12
+ OutputCapture = Progress::OutputCapture
13
+ TaskStatus = Progress::TaskStatus
12
14
 
13
- def initialize(output)
14
- @output = output
15
- end
16
-
17
- def clear_lines(count)
18
- return if count == 0
19
-
20
- count.times { @output.print MOVE_UP_AND_CLEAR }
21
- end
22
-
23
- def puts(text)
24
- @output.puts text
25
- end
26
-
27
- def print(text)
28
- @output.print text
29
- end
30
-
31
- def flush
32
- @output.flush
33
- end
34
- end
35
-
36
- # Spinner animation with dots-style characters
37
- class SpinnerAnimation
38
- SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
39
- FRAME_DELAY = 0.1
40
-
41
- def initialize
42
- @frame = 0
43
- @running = false
44
- @thread = nil
45
- end
46
-
47
- def start(terminal, task_name, &display_callback)
48
- return if @running
49
-
50
- @running = true
51
- @frame = 0
52
-
53
- @thread = Thread.new do
54
- while @running
55
- current_char = SPINNER_CHARS[@frame % SPINNER_CHARS.length]
56
- display_callback&.call(current_char, task_name)
57
-
58
- @frame += 1
59
- sleep FRAME_DELAY
60
- end
61
- rescue
62
- # Silently handle thread errors
63
- end
64
- end
65
-
66
- def stop
67
- @running = false
68
- @thread&.join(0.2)
69
- @thread = nil
70
- end
71
-
72
- def running?
73
- @running
74
- end
75
- end
76
-
77
- # Captures stdout and maintains last N lines like tail -f
78
- class OutputCapture
79
- MAX_LINES = 10
80
- DISPLAY_LINES = 5
81
-
82
- def initialize(main_output)
83
- @main_output = main_output
84
- @buffer = []
85
- @capturing = false
86
- @original_stdout = nil
87
- @pipe_reader = nil
88
- @pipe_writer = nil
89
- @capture_thread = nil
90
- end
91
-
92
- def start
93
- return if @capturing
94
-
95
- @buffer.clear
96
- setup_stdout_redirection
97
- @capturing = true
98
-
99
- start_capture_thread
100
- end
101
-
102
- def stop
103
- return unless @capturing
104
-
105
- @capturing = false
106
-
107
- # Restore stdout
108
- restore_stdout
109
-
110
- # Clean up pipes and thread
111
- cleanup_capture_thread
112
- cleanup_pipes
113
- end
114
-
115
- def last_lines
116
- @buffer.last(DISPLAY_LINES)
117
- end
118
-
119
- def capturing?
120
- @capturing
121
- end
122
-
123
- private
124
-
125
- def setup_stdout_redirection
126
- @original_stdout = $stdout
127
- @pipe_reader, @pipe_writer = IO.pipe
128
- $stdout = @pipe_writer
129
- end
130
-
131
- def restore_stdout
132
- return unless @original_stdout
133
-
134
- $stdout = @original_stdout
135
- @original_stdout = nil
136
- end
137
-
138
- def start_capture_thread
139
- @capture_thread = Thread.new do
140
- while (line = @pipe_reader.gets)
141
- line = line.chomp
142
- next if line.empty?
143
- next if skip_line?(line)
144
-
145
- add_line_to_buffer(line)
146
- end
147
- rescue IOError
148
- # Pipe closed, normal termination
149
- end
150
- end
151
-
152
- def skip_line?(line)
153
- # Skip logger lines (they appear separately)
154
- line.match?(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/)
155
- end
156
-
157
- def add_line_to_buffer(line)
158
- @buffer << line
159
- @buffer.shift while @buffer.length > MAX_LINES
160
- end
161
-
162
- def cleanup_capture_thread
163
- @capture_thread&.join(0.1)
164
- @capture_thread = nil
165
- end
166
-
167
- def cleanup_pipes
168
- [@pipe_writer, @pipe_reader].each do |pipe|
169
- pipe&.close
170
- rescue IOError
171
- # Already closed, ignore
172
- end
173
- @pipe_writer = @pipe_reader = nil
174
- end
175
- end
176
-
177
- # Represents task execution status
178
- class TaskStatus
179
- attr_reader :name, :duration, :error
180
-
181
- def initialize(name:, duration: nil, error: nil)
182
- @name = name
183
- @duration = duration
184
- @error = error
185
- end
186
-
187
- def success?
188
- @error.nil?
189
- end
190
-
191
- def failure?
192
- !success?
193
- end
194
-
195
- def duration_ms
196
- return nil unless @duration
197
- (@duration * 1000).round(1)
198
- end
199
-
200
- def icon
201
- success? ? "✅" : "❌"
202
- end
203
-
204
- def format_duration
205
- return "" unless duration_ms
206
- "(#{duration_ms}ms)"
207
- end
208
- end
209
-
210
- # Main progress display controller
15
+ # Main progress display controller - refactored for better separation of concerns
211
16
  class ProgressDisplay
212
- # ANSI colors
213
- COLORS = {
214
- reset: "\033[0m",
215
- bold: "\033[1m",
216
- dim: "\033[2m",
217
- cyan: "\033[36m",
218
- green: "\033[32m",
219
- red: "\033[31m"
220
- }.freeze
221
-
222
- def initialize(output: $stdout, force_enable: nil)
17
+ def initialize(output: $stdout, enable: true, include_captured_output: nil)
223
18
  @output = output
224
- @terminal = TerminalController.new(output)
225
- @spinner = SpinnerAnimation.new
226
- @output_capture = OutputCapture.new(output)
19
+ @terminal = Progress::TerminalController.new(output)
20
+ @spinner = Progress::SpinnerAnimation.new
21
+ @output_capture = Progress::OutputCapture.new(output)
227
22
 
228
- # Enable if TTY or force enabled or environment variable set
229
- @enabled = force_enable.nil? ? (output.tty? || ENV["TASKI_FORCE_PROGRESS"] == "1") : force_enable
23
+ # Default to including captured output in test environments (when output != $stdout)
24
+ # This ensures test output is visible in the test output stream
25
+ include_captured_output = include_captured_output.nil? ? (output != $stdout) : include_captured_output
26
+ @display_manager = Progress::DisplayManager.new(@terminal, @spinner, @output_capture, include_captured_output: include_captured_output)
230
27
 
231
- @completed_tasks = []
232
- @current_display_lines = 0
28
+ @enabled = ENV["TASKI_PROGRESS_DISABLE"] != "1" && enable
233
29
  end
234
30
 
235
31
  def start_task(task_name, dependencies: [])
236
- puts "DEBUG: start_task called for #{task_name}, enabled: #{@enabled}" if ENV["TASKI_DEBUG"]
237
32
  return unless @enabled
238
33
 
239
- clear_current_display
240
- @output_capture.start
241
-
242
- start_spinner_display(task_name)
34
+ @display_manager.start_task_display(task_name)
243
35
  end
244
36
 
245
37
  def complete_task(task_name, duration:)
246
38
  return unless @enabled
247
39
 
248
- status = TaskStatus.new(name: task_name, duration: duration)
249
- finish_task(status)
40
+ @display_manager.complete_task_display(task_name, duration: duration)
250
41
  end
251
42
 
252
43
  def fail_task(task_name, error:, duration:)
253
44
  return unless @enabled
254
45
 
255
- status = TaskStatus.new(name: task_name, duration: duration, error: error)
256
- finish_task(status)
46
+ @display_manager.fail_task_display(task_name, error: error, duration: duration)
257
47
  end
258
48
 
259
49
  def clear
260
50
  return unless @enabled
261
51
 
262
- @spinner.stop
263
- @output_capture.stop
264
- clear_current_display
265
-
266
- # Display final summary of all completed tasks
267
- if @completed_tasks.any?
268
- @completed_tasks.each do |status|
269
- @terminal.puts format_completed_task(status)
270
- end
271
- @terminal.flush
272
- end
273
-
274
- @completed_tasks.clear
275
- @current_display_lines = 0
52
+ @display_manager.clear_all_displays
276
53
  end
277
54
 
278
55
  def enabled?
279
56
  @enabled
280
57
  end
281
-
282
- private
283
-
284
- def start_spinner_display(task_name)
285
- @spinner.start(@terminal, task_name) do |spinner_char, name|
286
- display_current_state(spinner_char, name)
287
- end
288
- end
289
-
290
- def display_current_state(spinner_char, task_name)
291
- clear_current_display
292
-
293
- lines_count = 0
294
-
295
- # Only display current task with spinner (no past completed tasks during execution)
296
- @terminal.puts format_current_task(spinner_char, task_name)
297
- lines_count += 1
298
-
299
- # Display output lines
300
- @output_capture.last_lines.each do |line|
301
- @terminal.puts format_output_line(line)
302
- lines_count += 1
303
- end
304
-
305
- @current_display_lines = lines_count
306
- @terminal.flush
307
- end
308
-
309
- def finish_task(status)
310
- @spinner.stop
311
-
312
- # Capture output before stopping
313
- captured_output = @output_capture.last_lines
314
- @output_capture.stop
315
- clear_current_display
316
-
317
- # In test environments (when terminal is StringIO), include captured output
318
- if @terminal.is_a?(StringIO) && captured_output.any?
319
- captured_output.each do |line|
320
- @terminal.puts line.chomp
321
- end
322
- end
323
-
324
- @completed_tasks << status
325
- display_final_state
326
- end
327
-
328
- def display_final_state
329
- # Only display the newly completed task (last one)
330
- if @completed_tasks.any?
331
- latest_task = @completed_tasks.last
332
- @terminal.puts format_completed_task(latest_task)
333
- end
334
- @terminal.flush
335
- @current_display_lines = 1 # Only one line for the latest task
336
- end
337
-
338
- def format_completed_task(status)
339
- color = status.success? ? COLORS[:green] : COLORS[:red]
340
- "#{color}#{COLORS[:bold]}#{status.icon} #{status.name}#{COLORS[:reset]} #{COLORS[:dim]}#{status.format_duration}#{COLORS[:reset]}"
341
- end
342
-
343
- def format_current_task(spinner_char, task_name)
344
- "#{COLORS[:cyan]}#{spinner_char}#{COLORS[:reset]} #{COLORS[:bold]}#{task_name}#{COLORS[:reset]}"
345
- end
346
-
347
- def format_output_line(line)
348
- " #{COLORS[:dim]}#{line}#{COLORS[:reset]}"
349
- end
350
-
351
- def clear_current_display
352
- @terminal.clear_lines(@current_display_lines)
353
- @current_display_lines = 0
354
- end
355
58
  end
356
59
  end