taski 0.5.0 → 0.7.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +168 -21
  4. data/docs/GUIDE.md +394 -0
  5. data/examples/README.md +65 -17
  6. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  7. data/examples/clean_demo.rb +204 -0
  8. data/examples/data_pipeline_demo.rb +1 -1
  9. data/examples/group_demo.rb +113 -0
  10. data/examples/large_tree_demo.rb +519 -0
  11. data/examples/reexecution_demo.rb +93 -80
  12. data/examples/simple_progress_demo.rb +80 -0
  13. data/examples/system_call_demo.rb +56 -0
  14. data/lib/taski/{context.rb → args.rb} +3 -3
  15. data/lib/taski/execution/base_progress_display.rb +348 -0
  16. data/lib/taski/execution/execution_context.rb +383 -0
  17. data/lib/taski/execution/executor.rb +405 -134
  18. data/lib/taski/execution/plain_progress_display.rb +76 -0
  19. data/lib/taski/execution/registry.rb +17 -1
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/simple_progress_display.rb +173 -0
  22. data/lib/taski/execution/task_output_pipe.rb +42 -0
  23. data/lib/taski/execution/task_output_router.rb +287 -0
  24. data/lib/taski/execution/task_wrapper.rb +215 -52
  25. data/lib/taski/execution/tree_progress_display.rb +349 -212
  26. data/lib/taski/execution/worker_pool.rb +104 -0
  27. data/lib/taski/section.rb +16 -3
  28. data/lib/taski/static_analysis/visitor.rb +3 -0
  29. data/lib/taski/task.rb +218 -37
  30. data/lib/taski/test_helper/errors.rb +13 -0
  31. data/lib/taski/test_helper/minitest.rb +38 -0
  32. data/lib/taski/test_helper/mock_registry.rb +51 -0
  33. data/lib/taski/test_helper/mock_wrapper.rb +46 -0
  34. data/lib/taski/test_helper/rspec.rb +38 -0
  35. data/lib/taski/test_helper.rb +214 -0
  36. data/lib/taski/version.rb +1 -1
  37. data/lib/taski.rb +211 -23
  38. data/sig/taski.rbs +207 -27
  39. metadata +25 -8
  40. data/docs/advanced-features.md +0 -625
  41. data/docs/api-guide.md +0 -509
  42. data/docs/error-handling.md +0 -684
  43. data/examples/section_progress_demo.rb +0 -78
@@ -1,15 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
3
  require "stringio"
4
+ require_relative "base_progress_display"
5
5
 
6
6
  module Taski
7
7
  module Execution
8
8
  # Tree-based progress display that shows task execution in a tree structure
9
9
  # similar to Task.tree, with real-time status updates and stdout capture.
10
- class TreeProgressDisplay
10
+ class TreeProgressDisplay < BaseProgressDisplay
11
11
  SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
12
12
 
13
+ # Output display settings
14
+ OUTPUT_RESERVED_WIDTH = 30 # Characters reserved for tree structure
15
+ OUTPUT_MIN_LENGTH = 70 # Minimum visible output length
16
+ OUTPUT_SEPARATOR = " > " # Separator before task output
17
+ GROUP_SEPARATOR = " | " # Separator between group name and task name
18
+ TRUNCATION_ELLIPSIS = "..." # Ellipsis for truncated output
19
+
20
+ # Display settings
21
+ RENDER_INTERVAL = 0.1 # Seconds between display updates
22
+ DEFAULT_TERMINAL_WIDTH = 80 # Default terminal width when unknown
23
+ DEFAULT_TERMINAL_HEIGHT = 24 # Default terminal height when unknown
24
+
13
25
  # ANSI color codes (matching Task.tree)
14
26
  COLORS = {
15
27
  reset: "\e[0m",
@@ -27,49 +39,105 @@ module Taski
27
39
 
28
40
  # Status icons
29
41
  ICONS = {
42
+ # Run lifecycle states
30
43
  pending: "⏸", # Pause for waiting
31
44
  running_prefix: "", # Will use spinner
32
45
  completed: "✓",
33
46
  failed: "✗",
34
- skipped: "⊘" # Prohibition sign for unselected impl candidates
47
+ skipped: "⊘", # Prohibition sign for unselected impl candidates
48
+ # Clean lifecycle states
49
+ cleaning_prefix: "", # Will use spinner
50
+ clean_completed: "♻",
51
+ clean_failed: "✗"
35
52
  }.freeze
36
53
 
37
- # Shared helper methods
54
+ ##
55
+ # Checks if a class is a Taski::Section subclass.
56
+ # @param klass [Class] The class to check.
57
+ # @return [Boolean] true if the class is a Section.
38
58
  def self.section_class?(klass)
39
59
  defined?(Taski::Section) && klass < Taski::Section
40
60
  end
41
61
 
62
+ ##
63
+ # Checks if a class is nested within another class by name prefix.
64
+ # @param child_class [Class] The potential nested class.
65
+ # @param parent_class [Class] The potential parent class.
66
+ # @return [Boolean] true if child_class name starts with parent_class name and "::".
42
67
  def self.nested_class?(child_class, parent_class)
43
68
  child_name = child_class.name.to_s
44
69
  parent_name = parent_class.name.to_s
45
70
  child_name.start_with?("#{parent_name}::")
46
71
  end
47
72
 
73
+ # Build a tree structure from a root task class.
74
+ # This is the shared tree building logic used by both static and progress display.
75
+ #
76
+ # @param task_class [Class] The task class to build tree for
77
+ # @param ancestors [Set] Set of ancestor task classes for circular detection
78
+ # @return [Hash, nil] Tree node hash or nil if circular
79
+ #
80
+ # Tree node structure:
81
+ # {
82
+ # task_class: Class, # The task class
83
+ # is_section: Boolean, # Whether this is a Section
84
+ # is_circular: Boolean, # Whether this is a circular reference
85
+ # is_impl_candidate: Boolean, # Whether this is an impl candidate
86
+ # children: Array<Hash> # Child nodes
87
+ # }
88
+ def self.build_tree_node(task_class, ancestors = Set.new)
89
+ is_circular = ancestors.include?(task_class)
90
+
91
+ node = {
92
+ task_class: task_class,
93
+ is_section: section_class?(task_class),
94
+ is_circular: is_circular,
95
+ is_impl_candidate: false,
96
+ children: []
97
+ }
98
+
99
+ # Don't traverse children for circular references
100
+ return node if is_circular
101
+
102
+ new_ancestors = ancestors + [task_class]
103
+ dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
104
+ is_section = section_class?(task_class)
105
+
106
+ dependencies.each do |dep|
107
+ child_node = build_tree_node(dep, new_ancestors)
108
+ child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
109
+ node[:children] << child_node
110
+ end
111
+
112
+ node
113
+ end
114
+
48
115
  # Render a static tree structure for a task class (used by Task.tree)
49
116
  # @param root_task_class [Class] The root task class
50
117
  # @return [String] The rendered tree string
51
118
  def self.render_static_tree(root_task_class)
52
- renderer = StaticTreeRenderer.new
53
- renderer.render(root_task_class)
119
+ tree = build_tree_node(root_task_class)
120
+ formatter = StaticTreeFormatter.new
121
+ formatter.format(tree)
54
122
  end
55
123
 
56
- # Internal renderer for static tree display (no progress tracking)
57
- class StaticTreeRenderer
58
- def render(root_task_class)
124
+ # Formatter for static tree display (no progress tracking, uses task numbers)
125
+ class StaticTreeFormatter
126
+ def format(tree)
59
127
  @task_index_map = {}
60
- build_tree(root_task_class, "", false, Set.new)
128
+ format_node(tree, "", false)
61
129
  end
62
130
 
63
131
  private
64
132
 
65
- def build_tree(task_class, prefix, is_impl, ancestors)
133
+ def format_node(node, prefix, is_impl)
134
+ task_class = node[:task_class]
66
135
  type_label = colored_type_label(task_class)
67
136
  impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
68
137
  task_number = get_task_number(task_class)
69
138
  name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
70
139
 
71
- # Detect circular reference
72
- if ancestors.include?(task_class)
140
+ if node[:is_circular]
73
141
  circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
74
142
  return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
75
143
  end
@@ -79,26 +147,21 @@ module Taski
79
147
  # Register task number if not already registered
80
148
  @task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
81
149
 
82
- new_ancestors = ancestors + [task_class]
83
- dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
84
- is_section = TreeProgressDisplay.section_class?(task_class)
85
-
86
- dependencies.each_with_index do |dep, index|
87
- is_last = (index == dependencies.size - 1)
88
- is_impl_candidate = is_section && TreeProgressDisplay.nested_class?(dep, task_class)
89
- result += render_dependency_branch(dep, prefix, is_last, is_impl_candidate, new_ancestors)
150
+ node[:children].each_with_index do |child, index|
151
+ is_last = (index == node[:children].size - 1)
152
+ result += format_child_branch(child, prefix, is_last)
90
153
  end
91
154
 
92
155
  result
93
156
  end
94
157
 
95
- def render_dependency_branch(dep, prefix, is_last, is_impl, ancestors)
158
+ def format_child_branch(child, prefix, is_last)
96
159
  connector = is_last ? "└── " : "├── "
97
160
  extension = is_last ? " " : "│ "
98
- dep_tree = build_tree(dep, "#{prefix}#{extension}", is_impl, ancestors)
161
+ child_tree = format_node(child, "#{prefix}#{extension}", child[:is_impl_candidate])
99
162
 
100
163
  result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
101
- lines = dep_tree.lines
164
+ lines = child_tree.lines
102
165
  result += lines.first
103
166
  lines.drop(1).each { |line| result += line }
104
167
  result
@@ -118,141 +181,54 @@ module Taski
118
181
  end
119
182
  end
120
183
 
121
- class TaskProgress
122
- attr_accessor :state, :start_time, :end_time, :error, :duration
123
- attr_accessor :is_impl_candidate
124
-
125
- def initialize
126
- @state = :pending
127
- @start_time = nil
128
- @end_time = nil
129
- @error = nil
130
- @duration = nil
131
- @is_impl_candidate = false
132
- end
133
- end
134
-
135
184
  def initialize(output: $stdout)
136
- @output = output
137
- @tasks = {}
138
- @monitor = Monitor.new
185
+ super
139
186
  @spinner_index = 0
140
187
  @renderer_thread = nil
141
188
  @running = false
142
- @nest_level = 0 # Track nested executor calls
143
- @root_task_class = nil
144
189
  @tree_structure = nil
145
190
  @section_impl_map = {} # Section -> selected impl class
146
- @last_line_count = 0
191
+ @last_line_count = 0 # Track number of lines drawn for cursor movement
147
192
  end
148
193
 
149
- # Set the root task to build tree structure
150
- # Only sets root task if not already set (prevents nested executor overwrite)
151
- # @param root_task_class [Class] The root task class
152
- def set_root_task(root_task_class)
153
- @monitor.synchronize do
154
- return if @root_task_class # Don't overwrite existing root task
155
- @root_task_class = root_task_class
156
- build_tree_structure
157
- end
158
- end
194
+ protected
159
195
 
160
- # Register which impl was selected for a section
161
- # @param section_class [Class] The section class
162
- # @param impl_class [Class] The selected implementation class
163
- def register_section_impl(section_class, impl_class)
164
- @monitor.synchronize do
165
- @section_impl_map[section_class] = impl_class
166
- end
196
+ # Template method: Called when root task is set
197
+ def on_root_task_set
198
+ build_tree_structure
167
199
  end
168
200
 
169
- # @param task_class [Class] The task class to register
170
- def register_task(task_class)
171
- @monitor.synchronize do
172
- return if @tasks.key?(task_class)
173
- @tasks[task_class] = TaskProgress.new
174
- end
201
+ # Template method: Called when a section impl is registered
202
+ def on_section_impl_registered(section_class, impl_class)
203
+ @section_impl_map[section_class] = impl_class
175
204
  end
176
205
 
177
- # @param task_class [Class] The task class to check
178
- # @return [Boolean] true if the task is registered
179
- def task_registered?(task_class)
180
- @monitor.synchronize do
181
- @tasks.key?(task_class)
182
- end
206
+ # Template method: Determine if display should activate
207
+ def should_activate?
208
+ tty?
183
209
  end
184
210
 
185
- # @param task_class [Class] The task class to update
186
- # @param state [Symbol] The new state (:pending, :running, :completed, :failed)
187
- # @param duration [Float] Duration in milliseconds (for completed tasks)
188
- # @param error [Exception] Error object (for failed tasks)
189
- def update_task(task_class, state:, duration: nil, error: nil)
190
- @monitor.synchronize do
191
- progress = @tasks[task_class]
192
- return unless progress
193
-
194
- progress.state = state
195
- progress.duration = duration if duration
196
- progress.error = error if error
197
-
198
- case state
199
- when :running
200
- progress.start_time = Time.now
201
- when :completed, :failed
202
- progress.end_time = Time.now
203
- end
204
- end
205
- end
206
-
207
- # @param task_class [Class] The task class
208
- # @return [Symbol] The task state
209
- def task_state(task_class)
210
- @monitor.synchronize do
211
- @tasks[task_class]&.state
212
- end
213
- end
214
-
215
- def start
216
- should_start = false
217
- @monitor.synchronize do
218
- @nest_level += 1
219
- return if @nest_level > 1 # Already running from outer executor
220
- return if @running
221
- return unless @output.tty?
222
-
223
- @running = true
224
- should_start = true
225
- end
226
-
227
- return unless should_start
228
-
229
- # Hide cursor (outside monitor to avoid holding lock during I/O)
230
- @output.print "\e[?25l"
211
+ # Template method: Called when display starts
212
+ def on_start
213
+ @running = true
214
+ @output.print "\e[?1049h" # Switch to alternate screen buffer
215
+ @output.print "\e[H" # Move cursor to home (top-left)
216
+ @output.print "\e[?25l" # Hide cursor
231
217
  @renderer_thread = Thread.new do
232
218
  loop do
233
219
  break unless @running
234
220
  render_live
235
- sleep 0.1
221
+ sleep RENDER_INTERVAL
236
222
  end
237
223
  end
238
224
  end
239
225
 
240
- def stop
241
- should_stop = false
242
- @monitor.synchronize do
243
- @nest_level -= 1 if @nest_level > 0
244
- return unless @nest_level == 0
245
- return unless @running
246
-
247
- @running = false
248
- should_stop = true
249
- end
250
-
251
- return unless should_stop
252
-
226
+ # Template method: Called when display stops
227
+ def on_stop
228
+ @running = false
253
229
  @renderer_thread&.join
254
- # Show cursor
255
- @output.print "\e[?25h"
230
+ @output.print "\e[?25h" # Show cursor
231
+ @output.print "\e[?1049l" # Switch back to main screen buffer
256
232
  render_final
257
233
  end
258
234
 
@@ -262,97 +238,97 @@ module Taski
262
238
  def build_tree_structure
263
239
  return unless @root_task_class
264
240
 
265
- @tree_structure = build_tree_node(@root_task_class, Set.new)
241
+ @tree_structure = self.class.build_tree_node(@root_task_class)
266
242
  register_tasks_from_tree(@tree_structure)
267
243
  end
268
244
 
269
- # Build a single tree node
270
- def build_tree_node(task_class, ancestors)
271
- return nil if ancestors.include?(task_class)
272
-
273
- node = {
274
- task_class: task_class,
275
- is_section: section_class?(task_class),
276
- children: [],
277
- is_impl_candidate: false
278
- }
279
-
280
- new_ancestors = ancestors + [task_class]
281
- dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
282
- is_section = section_class?(task_class)
283
-
284
- dependencies.each do |dep|
285
- child_node = build_tree_node(dep, new_ancestors)
286
- if child_node
287
- # Only mark as impl candidate if parent is Section AND
288
- # the dependency is a nested class of that Section
289
- child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
290
- node[:children] << child_node
291
- end
292
- end
293
-
294
- node
295
- end
296
-
297
- # Register all tasks from tree structure
298
- def register_tasks_from_tree(node)
299
- return unless node
300
-
301
- task_class = node[:task_class]
302
- register_task(task_class)
303
-
304
- # Mark as impl candidate if applicable
305
- if node[:is_impl_candidate]
306
- @tasks[task_class].is_impl_candidate = true
307
- end
308
-
309
- node[:children].each { |child| register_tasks_from_tree(child) }
310
- end
311
-
312
245
  def render_live
246
+ # Poll for new output from task pipes
247
+ @output_capture&.poll
248
+
313
249
  lines = nil
314
- line_count = nil
315
250
 
316
251
  @monitor.synchronize do
317
252
  @spinner_index += 1
318
253
  lines = build_tree_display
319
- line_count = @last_line_count
320
254
  end
321
255
 
322
256
  return if lines.nil? || lines.empty?
323
257
 
324
- # Move cursor up to beginning of display area
325
- if line_count && line_count > 0
326
- @output.print "\e[#{line_count}A\r"
327
- end
258
+ # Build complete frame in buffer for single write (flicker-free)
259
+ buffer = build_frame_buffer(lines)
328
260
 
329
- # Redraw all lines
261
+ # Write entire frame in single operation
262
+ @output.print buffer
263
+ @output.flush
264
+
265
+ @last_line_count = lines.size
266
+ end
267
+
268
+ ##
269
+ # Builds a complete frame buffer for flicker-free rendering.
270
+ # Uses cursor home positioning and line-by-line overwrite instead of clear.
271
+ # @param lines [Array<String>] The lines to render.
272
+ # @return [String] The complete frame buffer ready for single write.
273
+ def build_frame_buffer(lines)
274
+ buffer = +""
275
+
276
+ # Move cursor to home position (top-left) for overwrite
277
+ buffer << "\e[H"
278
+
279
+ # Build each line with clear-to-end-of-line for clean overwrite
330
280
  lines.each do |line|
331
- @output.print "\e[K#{line}\n"
281
+ buffer << line
282
+ buffer << "\e[K" # Clear from cursor to end of line (removes old content)
283
+ buffer << "\n"
332
284
  end
333
285
 
334
- @monitor.synchronize do
335
- @last_line_count = lines.length
286
+ # Clear any extra lines from previous render if current has fewer lines
287
+ if @last_line_count > lines.size
288
+ (@last_line_count - lines.size).times do
289
+ buffer << "\e[K\n" # Clear line and move to next
290
+ end
336
291
  end
337
292
 
338
- @output.flush
293
+ buffer
339
294
  end
340
295
 
341
296
  def render_final
342
297
  @monitor.synchronize do
343
- lines = build_tree_display
344
- return if lines.empty?
298
+ return unless @root_task_class
345
299
 
346
- # Clear previous animated output
347
- if @last_line_count && @last_line_count > 0
348
- @last_line_count.times do
349
- @output.print "\e[1A\e[K"
350
- end
351
- end
300
+ root_progress = @tasks[@root_task_class]
301
+ return unless root_progress
302
+
303
+ # Print single summary line instead of full tree
304
+ @output.puts build_summary_line(@root_task_class, root_progress)
305
+ end
306
+ end
307
+
308
+ def build_summary_line(task_class, progress)
309
+ # Determine overall status and icon
310
+ if progress.run_state == :failed || progress.clean_state == :clean_failed
311
+ icon = "#{COLORS[:error]}#{ICONS[:failed]}#{COLORS[:reset]}"
312
+ status = "#{COLORS[:error]}failed#{COLORS[:reset]}"
313
+ else
314
+ icon = "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
315
+ status = "#{COLORS[:success]}completed#{COLORS[:reset]}"
316
+ end
317
+
318
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
352
319
 
353
- # Print final state
354
- lines.each { |line| @output.puts line }
320
+ # Calculate total duration
321
+ duration_str = ""
322
+ if progress.run_duration
323
+ duration_str = " (#{progress.run_duration}ms)"
355
324
  end
325
+
326
+ # Count completed tasks
327
+ completed_count = @tasks.values.count { |p| p.run_state == :completed }
328
+ total_count = @tasks.values.count { |p| p.run_state != :pending || p == progress }
329
+ task_count_str = " [#{completed_count}/#{total_count} tasks]"
330
+
331
+ "#{icon} #{name} #{status}#{duration_str}#{task_count_str}"
356
332
  end
357
333
 
358
334
  # Build display lines from tree structure
@@ -431,11 +407,12 @@ module Taski
431
407
  return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
432
408
  end
433
409
 
434
- status_icon = task_status_icon(progress.state, is_selected)
410
+ status_icons = combined_status_icons(progress)
435
411
  name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
436
- details = task_details(progress)
412
+ details = combined_task_details(progress)
413
+ output_suffix = task_output_suffix(task_class, progress.state)
437
414
 
438
- "#{status_icon} #{impl_prefix}#{name} #{type_label}#{details}"
415
+ "#{status_icons} #{impl_prefix}#{name} #{type_label}#{details}#{output_suffix}"
439
416
  end
440
417
 
441
418
  def format_unknown_task(task_class, is_selected = true)
@@ -450,12 +427,26 @@ module Taski
450
427
  end
451
428
  end
452
429
 
453
- def task_status_icon(state, is_selected)
454
- # If not selected (either direct impl candidate or child of unselected), show skipped
455
- unless is_selected
456
- return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]}"
457
- end
430
+ ##
431
+ # Returns combined status icons for both run and clean phases.
432
+ # Shows run icon first, then clean icon if clean phase has started.
433
+ # @param [TaskProgress] progress - The task progress object with run_state and clean_state.
434
+ # @return [String] The combined ANSI-colored icons.
435
+ def combined_status_icons(progress)
436
+ run_icon = run_status_icon(progress.run_state)
458
437
 
438
+ # If clean phase hasn't started, only show run icon
439
+ return run_icon unless progress.clean_state
440
+
441
+ clean_icon = clean_status_icon(progress.clean_state)
442
+ "#{run_icon} #{clean_icon}"
443
+ end
444
+
445
+ ##
446
+ # Returns the status icon for run phase.
447
+ # @param [Symbol] state - The run state (:pending, :running, :completed, :failed).
448
+ # @return [String] The ANSI-colored icon.
449
+ def run_status_icon(state)
459
450
  case state
460
451
  when :completed
461
452
  "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
@@ -468,10 +459,36 @@ module Taski
468
459
  end
469
460
  end
470
461
 
462
+ ##
463
+ # Returns the status icon for clean phase.
464
+ # @param [Symbol] state - The clean state (:cleaning, :clean_completed, :clean_failed).
465
+ # @return [String] The ANSI-colored icon.
466
+ def clean_status_icon(state)
467
+ case state
468
+ when :cleaning
469
+ "#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
470
+ when :clean_completed
471
+ "#{COLORS[:success]}#{ICONS[:clean_completed]}#{COLORS[:reset]}"
472
+ when :clean_failed
473
+ "#{COLORS[:error]}#{ICONS[:clean_failed]}#{COLORS[:reset]}"
474
+ else
475
+ ""
476
+ end
477
+ end
478
+
479
+ ##
480
+ # Returns the current spinner character for animation.
481
+ # Cycles through SPINNER_FRAMES based on the current spinner index.
482
+ # @return [String] The current spinner frame character.
471
483
  def spinner_char
472
484
  SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
473
485
  end
474
486
 
487
+ ##
488
+ # Returns a colored type label for the task class.
489
+ # @param task_class [Class] The task class to get the label for.
490
+ # @param is_selected [Boolean] Whether the task is selected (affects color).
491
+ # @return [String] The colored type label (Section or Task).
475
492
  def type_label_for(task_class, is_selected = true)
476
493
  if section_class?(task_class)
477
494
  is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
@@ -480,24 +497,144 @@ module Taski
480
497
  end
481
498
  end
482
499
 
483
- def task_details(progress)
484
- case progress.state
500
+ ##
501
+ # Returns combined details for both run and clean phases.
502
+ # @param [TaskProgress] progress - Progress object with run_state, clean_state, etc.
503
+ # @return [String] Combined details for both phases.
504
+ def combined_task_details(progress)
505
+ run_detail = run_phase_details(progress)
506
+ clean_detail = clean_phase_details(progress)
507
+
508
+ if clean_detail.empty?
509
+ run_detail
510
+ else
511
+ "#{run_detail}#{clean_detail}"
512
+ end
513
+ end
514
+
515
+ ##
516
+ # Returns details for the run phase only.
517
+ # @param [TaskProgress] progress - Progress object.
518
+ # @return [String] Run phase details.
519
+ def run_phase_details(progress)
520
+ case progress.run_state
485
521
  when :completed
486
- " #{COLORS[:success]}(#{progress.duration}ms)#{COLORS[:reset]}"
522
+ return "" unless progress.run_duration
523
+ " #{COLORS[:success]}(#{progress.run_duration}ms)#{COLORS[:reset]}"
487
524
  when :failed
488
525
  " #{COLORS[:error]}(failed)#{COLORS[:reset]}"
489
526
  when :running
490
- elapsed = ((Time.now - progress.start_time) * 1000).round(0)
527
+ return "" unless progress.run_start_time
528
+ elapsed = ((Time.now - progress.run_start_time) * 1000).round(0)
491
529
  " #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
492
530
  else
493
531
  ""
494
532
  end
495
533
  end
496
534
 
535
+ ##
536
+ # Returns details for the clean phase only.
537
+ # @param [TaskProgress] progress - Progress object.
538
+ # @return [String] Clean phase details.
539
+ def clean_phase_details(progress)
540
+ case progress.clean_state
541
+ when :cleaning
542
+ return "" unless progress.clean_start_time
543
+ elapsed = ((Time.now - progress.clean_start_time) * 1000).round(0)
544
+ " #{COLORS[:running]}(cleaning #{elapsed}ms)#{COLORS[:reset]}"
545
+ when :clean_completed
546
+ return "" unless progress.clean_duration
547
+ " #{COLORS[:success]}(cleaned #{progress.clean_duration}ms)#{COLORS[:reset]}"
548
+ when :clean_failed
549
+ " #{COLORS[:error]}(clean failed)#{COLORS[:reset]}"
550
+ else
551
+ ""
552
+ end
553
+ end
554
+
555
+ # Get task output suffix to display next to task
556
+ ##
557
+ # Produces a trailing output suffix for a task when it is actively producing output.
558
+ #
559
+ # Fetches the last captured stdout/stderr line for the given task and returns a
560
+ # formatted, dimmed suffix containing that line only when the task `state` is
561
+ # `:running` or `:cleaning` and an output capture is available. The returned
562
+ # string is truncated to fit the terminal width (with a minimum visible length)
563
+ # and includes surrounding dim/reset color codes.
564
+ # @param [Class] task_class - The task class whose output to query.
565
+ # @param [Symbol] state - The task lifecycle state (only `:running` and `:cleaning` produce output).
566
+ # @return [String] A formatted, possibly truncated output suffix prefixed with a dim pipe, or an empty string when no output should be shown.
567
+ def task_output_suffix(task_class, state)
568
+ return "" unless state == :running || state == :cleaning
569
+ return "" unless @output_capture
570
+
571
+ last_line = @output_capture.last_line_for(task_class)
572
+ return "" unless last_line && !last_line.empty?
573
+
574
+ # Get current group name if any
575
+ progress = @tasks[task_class]
576
+ group_prefix = ""
577
+ if progress&.current_group_index
578
+ current_group = progress.groups[progress.current_group_index]
579
+ group_prefix = "#{current_group.name}#{GROUP_SEPARATOR}" if current_group
580
+ end
581
+
582
+ # Truncate if too long (leave space for tree structure)
583
+ terminal_cols = terminal_width
584
+ max_output_length = terminal_cols - OUTPUT_RESERVED_WIDTH
585
+ max_output_length = OUTPUT_MIN_LENGTH if max_output_length < OUTPUT_MIN_LENGTH
586
+
587
+ full_output = "#{group_prefix}#{last_line}"
588
+ truncated = if full_output.length > max_output_length
589
+ full_output[0, max_output_length - TRUNCATION_ELLIPSIS.length] + TRUNCATION_ELLIPSIS
590
+ else
591
+ full_output
592
+ end
593
+
594
+ "#{COLORS[:dim]}#{OUTPUT_SEPARATOR}#{truncated}#{COLORS[:reset]}"
595
+ end
596
+
597
+ ##
598
+ # Returns the terminal width in columns.
599
+ # Defaults to 80 if the output IO doesn't support winsize.
600
+ # @return [Integer] The terminal width in columns.
601
+ def terminal_width
602
+ if @output.respond_to?(:winsize)
603
+ _, cols = @output.winsize
604
+ cols || DEFAULT_TERMINAL_WIDTH
605
+ else
606
+ DEFAULT_TERMINAL_WIDTH
607
+ end
608
+ end
609
+
610
+ ##
611
+ # Returns the terminal height in rows.
612
+ # Defaults to 24 if the output IO doesn't support winsize.
613
+ # @return [Integer] The terminal height in rows.
614
+ def terminal_height
615
+ if @output.respond_to?(:winsize)
616
+ rows, _ = @output.winsize
617
+ rows || DEFAULT_TERMINAL_HEIGHT
618
+ else
619
+ DEFAULT_TERMINAL_HEIGHT
620
+ end
621
+ end
622
+
623
+ ##
624
+ # Checks if a class is a Taski::Section subclass.
625
+ # Delegates to the class method.
626
+ # @param klass [Class] The class to check.
627
+ # @return [Boolean] true if the class is a Section.
497
628
  def section_class?(klass)
498
629
  self.class.section_class?(klass)
499
630
  end
500
631
 
632
+ ##
633
+ # Checks if a class is nested within another class.
634
+ # Delegates to the class method.
635
+ # @param child_class [Class] The potential nested class.
636
+ # @param parent_class [Class] The potential parent class.
637
+ # @return [Boolean] true if child_class is nested within parent_class.
501
638
  def nested_class?(child_class, parent_class)
502
639
  self.class.nested_class?(child_class, parent_class)
503
640
  end