taski 0.4.2 → 0.7.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +51 -33
  4. data/Steepfile +1 -0
  5. data/docs/GUIDE.md +340 -0
  6. data/examples/README.md +68 -20
  7. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  8. data/examples/clean_demo.rb +204 -0
  9. data/examples/data_pipeline_demo.rb +3 -3
  10. data/examples/group_demo.rb +113 -0
  11. data/examples/nested_section_demo.rb +161 -0
  12. data/examples/parallel_progress_demo.rb +1 -1
  13. data/examples/reexecution_demo.rb +93 -80
  14. data/examples/system_call_demo.rb +56 -0
  15. data/examples/tree_progress_demo.rb +164 -0
  16. data/lib/taski/{context.rb → args.rb} +3 -3
  17. data/lib/taski/execution/execution_context.rb +379 -0
  18. data/lib/taski/execution/executor.rb +538 -0
  19. data/lib/taski/execution/registry.rb +26 -2
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/task_output_pipe.rb +42 -0
  22. data/lib/taski/execution/task_output_router.rb +216 -0
  23. data/lib/taski/execution/task_wrapper.rb +295 -146
  24. data/lib/taski/execution/tree_progress_display.rb +793 -0
  25. data/lib/taski/execution/worker_pool.rb +104 -0
  26. data/lib/taski/section.rb +23 -0
  27. data/lib/taski/static_analysis/analyzer.rb +4 -2
  28. data/lib/taski/static_analysis/visitor.rb +86 -5
  29. data/lib/taski/task.rb +223 -120
  30. data/lib/taski/version.rb +1 -1
  31. data/lib/taski.rb +147 -28
  32. data/sig/taski.rbs +310 -67
  33. metadata +17 -8
  34. data/docs/advanced-features.md +0 -625
  35. data/docs/api-guide.md +0 -509
  36. data/docs/error-handling.md +0 -684
  37. data/lib/taski/execution/coordinator.rb +0 -63
  38. data/lib/taski/execution/parallel_progress_display.rb +0 -201
@@ -0,0 +1,793 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "stringio"
5
+
6
+ module Taski
7
+ module Execution
8
+ # Tree-based progress display that shows task execution in a tree structure
9
+ # similar to Task.tree, with real-time status updates and stdout capture.
10
+ class TreeProgressDisplay
11
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
12
+
13
+ # ANSI color codes (matching Task.tree)
14
+ COLORS = {
15
+ reset: "\e[0m",
16
+ task: "\e[32m", # green
17
+ section: "\e[34m", # blue
18
+ impl: "\e[33m", # yellow
19
+ tree: "\e[90m", # gray
20
+ name: "\e[1m", # bold
21
+ success: "\e[32m", # green
22
+ error: "\e[31m", # red
23
+ running: "\e[36m", # cyan
24
+ pending: "\e[90m", # gray
25
+ dim: "\e[2m" # dim
26
+ }.freeze
27
+
28
+ # Status icons
29
+ ICONS = {
30
+ # Run lifecycle states
31
+ pending: "⏸", # Pause for waiting
32
+ running_prefix: "", # Will use spinner
33
+ completed: "✓",
34
+ failed: "✗",
35
+ skipped: "⊘", # Prohibition sign for unselected impl candidates
36
+ # Clean lifecycle states
37
+ cleaning_prefix: "", # Will use spinner
38
+ clean_completed: "♻",
39
+ clean_failed: "✗"
40
+ }.freeze
41
+
42
+ ##
43
+ # Checks if a class is a Taski::Section subclass.
44
+ # @param klass [Class] The class to check.
45
+ # @return [Boolean] true if the class is a Section.
46
+ def self.section_class?(klass)
47
+ defined?(Taski::Section) && klass < Taski::Section
48
+ end
49
+
50
+ ##
51
+ # Checks if a class is nested within another class by name prefix.
52
+ # @param child_class [Class] The potential nested class.
53
+ # @param parent_class [Class] The potential parent class.
54
+ # @return [Boolean] true if child_class name starts with parent_class name and "::".
55
+ def self.nested_class?(child_class, parent_class)
56
+ child_name = child_class.name.to_s
57
+ parent_name = parent_class.name.to_s
58
+ child_name.start_with?("#{parent_name}::")
59
+ end
60
+
61
+ # Build a tree structure from a root task class.
62
+ # This is the shared tree building logic used by both static and progress display.
63
+ #
64
+ # @param task_class [Class] The task class to build tree for
65
+ # @param ancestors [Set] Set of ancestor task classes for circular detection
66
+ # @return [Hash, nil] Tree node hash or nil if circular
67
+ #
68
+ # Tree node structure:
69
+ # {
70
+ # task_class: Class, # The task class
71
+ # is_section: Boolean, # Whether this is a Section
72
+ # is_circular: Boolean, # Whether this is a circular reference
73
+ # is_impl_candidate: Boolean, # Whether this is an impl candidate
74
+ # children: Array<Hash> # Child nodes
75
+ # }
76
+ def self.build_tree_node(task_class, ancestors = Set.new)
77
+ is_circular = ancestors.include?(task_class)
78
+
79
+ node = {
80
+ task_class: task_class,
81
+ is_section: section_class?(task_class),
82
+ is_circular: is_circular,
83
+ is_impl_candidate: false,
84
+ children: []
85
+ }
86
+
87
+ # Don't traverse children for circular references
88
+ return node if is_circular
89
+
90
+ new_ancestors = ancestors + [task_class]
91
+ dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
92
+ is_section = section_class?(task_class)
93
+
94
+ dependencies.each do |dep|
95
+ child_node = build_tree_node(dep, new_ancestors)
96
+ child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
97
+ node[:children] << child_node
98
+ end
99
+
100
+ node
101
+ end
102
+
103
+ # Render a static tree structure for a task class (used by Task.tree)
104
+ # @param root_task_class [Class] The root task class
105
+ # @return [String] The rendered tree string
106
+ def self.render_static_tree(root_task_class)
107
+ tree = build_tree_node(root_task_class)
108
+ formatter = StaticTreeFormatter.new
109
+ formatter.format(tree)
110
+ end
111
+
112
+ # Formatter for static tree display (no progress tracking, uses task numbers)
113
+ class StaticTreeFormatter
114
+ def format(tree)
115
+ @task_index_map = {}
116
+ format_node(tree, "", false)
117
+ end
118
+
119
+ private
120
+
121
+ def format_node(node, prefix, is_impl)
122
+ task_class = node[:task_class]
123
+ type_label = colored_type_label(task_class)
124
+ impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
125
+ task_number = get_task_number(task_class)
126
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
127
+
128
+ if node[:is_circular]
129
+ circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
130
+ return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
131
+ end
132
+
133
+ result = "#{impl_prefix}#{task_number} #{name} #{type_label}\n"
134
+
135
+ # Register task number if not already registered
136
+ @task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
137
+
138
+ node[:children].each_with_index do |child, index|
139
+ is_last = (index == node[:children].size - 1)
140
+ result += format_child_branch(child, prefix, is_last)
141
+ end
142
+
143
+ result
144
+ end
145
+
146
+ def format_child_branch(child, prefix, is_last)
147
+ connector = is_last ? "└── " : "├── "
148
+ extension = is_last ? " " : "│ "
149
+ child_tree = format_node(child, "#{prefix}#{extension}", child[:is_impl_candidate])
150
+
151
+ result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
152
+ lines = child_tree.lines
153
+ result += lines.first
154
+ lines.drop(1).each { |line| result += line }
155
+ result
156
+ end
157
+
158
+ def get_task_number(task_class)
159
+ number = @task_index_map[task_class] || (@task_index_map.size + 1)
160
+ "#{COLORS[:tree]}[#{number}]#{COLORS[:reset]}"
161
+ end
162
+
163
+ def colored_type_label(klass)
164
+ if TreeProgressDisplay.section_class?(klass)
165
+ "#{COLORS[:section]}(Section)#{COLORS[:reset]}"
166
+ else
167
+ "#{COLORS[:task]}(Task)#{COLORS[:reset]}"
168
+ end
169
+ end
170
+ end
171
+
172
+ # Tracks the progress of a group within a task
173
+ class GroupProgress
174
+ attr_accessor :name, :state, :start_time, :end_time, :duration, :error, :last_message
175
+
176
+ def initialize(name)
177
+ @name = name
178
+ @state = :pending
179
+ @start_time = nil
180
+ @end_time = nil
181
+ @duration = nil
182
+ @error = nil
183
+ @last_message = nil
184
+ end
185
+ end
186
+
187
+ class TaskProgress
188
+ # Run lifecycle tracking
189
+ attr_accessor :run_state, :run_start_time, :run_end_time, :run_error, :run_duration
190
+ # Clean lifecycle tracking
191
+ attr_accessor :clean_state, :clean_start_time, :clean_end_time, :clean_error, :clean_duration
192
+ # Display properties
193
+ attr_accessor :is_impl_candidate
194
+ # Group tracking
195
+ attr_accessor :groups, :current_group_index
196
+
197
+ def initialize
198
+ # Run lifecycle
199
+ @run_state = :pending
200
+ @run_start_time = nil
201
+ @run_end_time = nil
202
+ @run_error = nil
203
+ @run_duration = nil
204
+ # Clean lifecycle
205
+ @clean_state = nil # nil means clean hasn't started
206
+ @clean_start_time = nil
207
+ @clean_end_time = nil
208
+ @clean_error = nil
209
+ @clean_duration = nil
210
+ # Display
211
+ @is_impl_candidate = false
212
+ # Groups
213
+ @groups = []
214
+ @current_group_index = nil
215
+ end
216
+
217
+ # For backward compatibility - returns the most relevant state for display
218
+ def state
219
+ @clean_state || @run_state
220
+ end
221
+
222
+ # Legacy accessors for backward compatibility
223
+ def start_time
224
+ @clean_start_time || @run_start_time
225
+ end
226
+
227
+ def end_time
228
+ @clean_end_time || @run_end_time
229
+ end
230
+
231
+ def error
232
+ @clean_error || @run_error
233
+ end
234
+
235
+ def duration
236
+ @clean_duration || @run_duration
237
+ end
238
+ end
239
+
240
+ def initialize(output: $stdout)
241
+ @output = output
242
+ @tasks = {}
243
+ @monitor = Monitor.new
244
+ @spinner_index = 0
245
+ @renderer_thread = nil
246
+ @running = false
247
+ @nest_level = 0 # Track nested executor calls
248
+ @root_task_class = nil
249
+ @tree_structure = nil
250
+ @section_impl_map = {} # Section -> selected impl class
251
+ @output_capture = nil # ThreadOutputCapture for getting task output
252
+ end
253
+
254
+ # Set the output capture for getting task output
255
+ # @param capture [ThreadOutputCapture] The output capture instance
256
+ def set_output_capture(capture)
257
+ @monitor.synchronize do
258
+ @output_capture = capture
259
+ end
260
+ end
261
+
262
+ # Set the root task to build tree structure
263
+ # Only sets root task if not already set (prevents nested executor overwrite)
264
+ # @param root_task_class [Class] The root task class
265
+ def set_root_task(root_task_class)
266
+ @monitor.synchronize do
267
+ return if @root_task_class # Don't overwrite existing root task
268
+ @root_task_class = root_task_class
269
+ build_tree_structure
270
+ end
271
+ end
272
+
273
+ # Register which impl was selected for a section
274
+ # @param section_class [Class] The section class
275
+ # @param impl_class [Class] The selected implementation class
276
+ def register_section_impl(section_class, impl_class)
277
+ @monitor.synchronize do
278
+ @section_impl_map[section_class] = impl_class
279
+ end
280
+ end
281
+
282
+ # @param task_class [Class] The task class to register
283
+ def register_task(task_class)
284
+ @monitor.synchronize do
285
+ return if @tasks.key?(task_class)
286
+ @tasks[task_class] = TaskProgress.new
287
+ end
288
+ end
289
+
290
+ # @param task_class [Class] The task class to check
291
+ # @return [Boolean] true if the task is registered
292
+ def task_registered?(task_class)
293
+ @monitor.synchronize do
294
+ @tasks.key?(task_class)
295
+ end
296
+ end
297
+
298
+ # @param task_class [Class] The task class to update
299
+ # @param state [Symbol] The new state (:pending, :running, :completed, :failed, :cleaning, :clean_completed, :clean_failed)
300
+ # @param duration [Float] Duration in milliseconds (for completed tasks)
301
+ # @param error [Exception] Error object (for failed tasks)
302
+ def update_task(task_class, state:, duration: nil, error: nil)
303
+ @monitor.synchronize do
304
+ progress = @tasks[task_class]
305
+ return unless progress
306
+
307
+ case state
308
+ # Run lifecycle states
309
+ when :pending
310
+ progress.run_state = :pending
311
+ when :running
312
+ progress.run_state = :running
313
+ progress.run_start_time = Time.now
314
+ when :completed
315
+ progress.run_state = :completed
316
+ progress.run_end_time = Time.now
317
+ progress.run_duration = duration if duration
318
+ when :failed
319
+ progress.run_state = :failed
320
+ progress.run_end_time = Time.now
321
+ progress.run_error = error if error
322
+ # Clean lifecycle states
323
+ when :cleaning
324
+ progress.clean_state = :cleaning
325
+ progress.clean_start_time = Time.now
326
+ when :clean_completed
327
+ progress.clean_state = :clean_completed
328
+ progress.clean_end_time = Time.now
329
+ progress.clean_duration = duration if duration
330
+ when :clean_failed
331
+ progress.clean_state = :clean_failed
332
+ progress.clean_end_time = Time.now
333
+ progress.clean_error = error if error
334
+ end
335
+ end
336
+ end
337
+
338
+ # @param task_class [Class] The task class
339
+ # @return [Symbol] The task state
340
+ def task_state(task_class)
341
+ @monitor.synchronize do
342
+ @tasks[task_class]&.state
343
+ end
344
+ end
345
+
346
+ # Update group state for a task.
347
+ # Called by ExecutionContext when group lifecycle events occur.
348
+ #
349
+ # @param task_class [Class] The task class containing the group
350
+ # @param group_name [String] The name of the group
351
+ # @param state [Symbol] The new state (:running, :completed, :failed)
352
+ # @param duration [Float, nil] Duration in milliseconds (for completed groups)
353
+ # @param error [Exception, nil] Error object (for failed groups)
354
+ def update_group(task_class, group_name, state:, duration: nil, error: nil)
355
+ @monitor.synchronize do
356
+ progress = @tasks[task_class]
357
+ return unless progress
358
+
359
+ case state
360
+ when :running
361
+ # Create new group and set as current
362
+ group = GroupProgress.new(group_name)
363
+ group.state = :running
364
+ group.start_time = Time.now
365
+ progress.groups << group
366
+ progress.current_group_index = progress.groups.size - 1
367
+ when :completed
368
+ # Find the group by name and mark completed
369
+ group = progress.groups.find { |g| g.name == group_name && g.state == :running }
370
+ if group
371
+ group.state = :completed
372
+ group.end_time = Time.now
373
+ group.duration = duration
374
+ end
375
+ progress.current_group_index = nil
376
+ when :failed
377
+ # Find the group by name and mark failed
378
+ group = progress.groups.find { |g| g.name == group_name && g.state == :running }
379
+ if group
380
+ group.state = :failed
381
+ group.end_time = Time.now
382
+ group.duration = duration
383
+ group.error = error
384
+ end
385
+ progress.current_group_index = nil
386
+ end
387
+ end
388
+ end
389
+
390
+ def start
391
+ should_start = false
392
+ @monitor.synchronize do
393
+ @nest_level += 1
394
+ return if @nest_level > 1 # Already running from outer executor
395
+ return if @running
396
+ return unless @output.tty?
397
+
398
+ @running = true
399
+ should_start = true
400
+ end
401
+
402
+ return unless should_start
403
+
404
+ @output.print "\e[?25l" # Hide cursor
405
+ @output.print "\e7" # Save cursor position (before any tree output)
406
+ @renderer_thread = Thread.new do
407
+ loop do
408
+ break unless @running
409
+ render_live
410
+ sleep 0.1
411
+ end
412
+ end
413
+ end
414
+
415
+ def stop
416
+ should_stop = false
417
+ @monitor.synchronize do
418
+ @nest_level -= 1 if @nest_level > 0
419
+ return unless @nest_level == 0
420
+ return unless @running
421
+
422
+ @running = false
423
+ should_stop = true
424
+ end
425
+
426
+ return unless should_stop
427
+
428
+ @renderer_thread&.join
429
+ @output.print "\e[?25h" # Show cursor
430
+ render_final
431
+ end
432
+
433
+ private
434
+
435
+ # Build tree structure from root task for display
436
+ def build_tree_structure
437
+ return unless @root_task_class
438
+
439
+ @tree_structure = self.class.build_tree_node(@root_task_class)
440
+ register_tasks_from_tree(@tree_structure)
441
+ end
442
+
443
+ # Register all tasks from tree structure
444
+ def register_tasks_from_tree(node)
445
+ return unless node
446
+
447
+ task_class = node[:task_class]
448
+ register_task(task_class)
449
+
450
+ # Mark as impl candidate if applicable
451
+ if node[:is_impl_candidate]
452
+ @tasks[task_class].is_impl_candidate = true
453
+ end
454
+
455
+ node[:children].each { |child| register_tasks_from_tree(child) }
456
+ end
457
+
458
+ def render_live
459
+ # Poll for new output from task pipes
460
+ @output_capture&.poll
461
+
462
+ lines = nil
463
+
464
+ @monitor.synchronize do
465
+ @spinner_index += 1
466
+ lines = build_tree_display
467
+ end
468
+
469
+ return if lines.nil? || lines.empty?
470
+
471
+ # Restore cursor to saved position (from start) and clear
472
+ @output.print "\e8" # Restore cursor position
473
+ @output.print "\e[J" # Clear from cursor to end of screen
474
+
475
+ # Redraw all lines
476
+ lines.each do |line|
477
+ @output.print "#{line}\n"
478
+ end
479
+
480
+ @output.flush
481
+ end
482
+
483
+ def render_final
484
+ @monitor.synchronize do
485
+ lines = build_tree_display
486
+ return if lines.empty?
487
+
488
+ # Restore cursor to saved position (from start) and clear
489
+ @output.print "\e8" # Restore cursor position
490
+ @output.print "\e[J" # Clear from cursor to end of screen
491
+
492
+ # Print final state
493
+ lines.each { |line| @output.puts line }
494
+ end
495
+ end
496
+
497
+ # Build display lines from tree structure
498
+ def build_tree_display
499
+ return [] unless @tree_structure
500
+
501
+ lines = []
502
+ build_root_tree_lines(@tree_structure, "", lines)
503
+ lines
504
+ end
505
+
506
+ # Build tree lines starting from root node
507
+ # @param node [Hash] Tree node (root)
508
+ # @param prefix [String] Line prefix for tree drawing
509
+ # @param lines [Array<String>] Accumulated output lines
510
+ def build_root_tree_lines(node, prefix, lines)
511
+ task_class = node[:task_class]
512
+ progress = @tasks[task_class]
513
+
514
+ # Root node is never an impl candidate and is always selected
515
+ line = format_tree_line(task_class, progress, false, true)
516
+ lines << "#{prefix}#{line}"
517
+
518
+ render_children(node, prefix, lines, task_class, true)
519
+ end
520
+
521
+ # Render all children of a node recursively
522
+ # @param node [Hash] Tree node
523
+ # @param prefix [String] Line prefix for tree drawing
524
+ # @param lines [Array<String>] Accumulated output lines
525
+ # @param parent_task_class [Class] Parent task class (for impl selection lookup)
526
+ # @param ancestor_selected [Boolean] Whether all ancestor impl candidates were selected
527
+ def render_children(node, prefix, lines, parent_task_class, ancestor_selected)
528
+ children = node[:children]
529
+ children.each_with_index do |child, index|
530
+ is_last = (index == children.size - 1)
531
+ connector = is_last ? "└── " : "├── "
532
+ extension = is_last ? " " : "│ "
533
+
534
+ child_progress = @tasks[child[:task_class]]
535
+
536
+ # Determine child's selection status
537
+ child_is_selected = true
538
+ if child[:is_impl_candidate]
539
+ selected_impl = @section_impl_map[parent_task_class]
540
+ child_is_selected = (selected_impl == child[:task_class])
541
+ end
542
+ # Propagate ancestor selection state
543
+ child_effective_selected = ancestor_selected && child_is_selected
544
+
545
+ child_line = format_tree_line(
546
+ child[:task_class],
547
+ child_progress,
548
+ child[:is_impl_candidate],
549
+ child_effective_selected
550
+ )
551
+ lines << "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}#{child_line}"
552
+
553
+ if child[:children].any?
554
+ render_children(child, "#{prefix}#{COLORS[:tree]}#{extension}#{COLORS[:reset]}", lines, child[:task_class], child_effective_selected)
555
+ end
556
+ end
557
+ end
558
+
559
+ def format_tree_line(task_class, progress, is_impl, is_selected)
560
+ return format_unknown_task(task_class, is_selected) unless progress
561
+
562
+ type_label = type_label_for(task_class, is_selected)
563
+ impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
564
+
565
+ # Handle unselected nodes (either impl candidates or children of unselected impl)
566
+ # Show dimmed regardless of task state since they belong to unselected branch
567
+ unless is_selected
568
+ name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
569
+ suffix = is_impl ? " #{COLORS[:dim]}(not selected)#{COLORS[:reset]}" : ""
570
+ return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
571
+ end
572
+
573
+ status_icons = combined_status_icons(progress)
574
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
575
+ details = combined_task_details(progress)
576
+ output_suffix = task_output_suffix(task_class, progress.state)
577
+
578
+ "#{status_icons} #{impl_prefix}#{name} #{type_label}#{details}#{output_suffix}"
579
+ end
580
+
581
+ def format_unknown_task(task_class, is_selected = true)
582
+ if is_selected
583
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
584
+ type_label = type_label_for(task_class, true)
585
+ "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]} #{name} #{type_label}"
586
+ else
587
+ name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
588
+ type_label = type_label_for(task_class, false)
589
+ "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{name} #{type_label}"
590
+ end
591
+ end
592
+
593
+ ##
594
+ # Returns combined status icons for both run and clean phases.
595
+ # Shows run icon first, then clean icon if clean phase has started.
596
+ # @param [TaskProgress] progress - The task progress object with run_state and clean_state.
597
+ # @return [String] The combined ANSI-colored icons.
598
+ def combined_status_icons(progress)
599
+ run_icon = run_status_icon(progress.run_state)
600
+
601
+ # If clean phase hasn't started, only show run icon
602
+ return run_icon unless progress.clean_state
603
+
604
+ clean_icon = clean_status_icon(progress.clean_state)
605
+ "#{run_icon} #{clean_icon}"
606
+ end
607
+
608
+ ##
609
+ # Returns the status icon for run phase.
610
+ # @param [Symbol] state - The run state (:pending, :running, :completed, :failed).
611
+ # @return [String] The ANSI-colored icon.
612
+ def run_status_icon(state)
613
+ case state
614
+ when :completed
615
+ "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
616
+ when :failed
617
+ "#{COLORS[:error]}#{ICONS[:failed]}#{COLORS[:reset]}"
618
+ when :running
619
+ "#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
620
+ else
621
+ "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]}"
622
+ end
623
+ end
624
+
625
+ ##
626
+ # Returns the status icon for clean phase.
627
+ # @param [Symbol] state - The clean state (:cleaning, :clean_completed, :clean_failed).
628
+ # @return [String] The ANSI-colored icon.
629
+ def clean_status_icon(state)
630
+ case state
631
+ when :cleaning
632
+ "#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
633
+ when :clean_completed
634
+ "#{COLORS[:success]}#{ICONS[:clean_completed]}#{COLORS[:reset]}"
635
+ when :clean_failed
636
+ "#{COLORS[:error]}#{ICONS[:clean_failed]}#{COLORS[:reset]}"
637
+ else
638
+ ""
639
+ end
640
+ end
641
+
642
+ ##
643
+ # Returns the current spinner character for animation.
644
+ # Cycles through SPINNER_FRAMES based on the current spinner index.
645
+ # @return [String] The current spinner frame character.
646
+ def spinner_char
647
+ SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
648
+ end
649
+
650
+ ##
651
+ # Returns a colored type label for the task class.
652
+ # @param task_class [Class] The task class to get the label for.
653
+ # @param is_selected [Boolean] Whether the task is selected (affects color).
654
+ # @return [String] The colored type label (Section or Task).
655
+ def type_label_for(task_class, is_selected = true)
656
+ if section_class?(task_class)
657
+ is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
658
+ else
659
+ is_selected ? "#{COLORS[:task]}(Task)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Task)#{COLORS[:reset]}"
660
+ end
661
+ end
662
+
663
+ ##
664
+ # Returns combined details for both run and clean phases.
665
+ # @param [TaskProgress] progress - Progress object with run_state, clean_state, etc.
666
+ # @return [String] Combined details for both phases.
667
+ def combined_task_details(progress)
668
+ run_detail = run_phase_details(progress)
669
+ clean_detail = clean_phase_details(progress)
670
+
671
+ if clean_detail.empty?
672
+ run_detail
673
+ else
674
+ "#{run_detail}#{clean_detail}"
675
+ end
676
+ end
677
+
678
+ ##
679
+ # Returns details for the run phase only.
680
+ # @param [TaskProgress] progress - Progress object.
681
+ # @return [String] Run phase details.
682
+ def run_phase_details(progress)
683
+ case progress.run_state
684
+ when :completed
685
+ return "" unless progress.run_duration
686
+ " #{COLORS[:success]}(#{progress.run_duration}ms)#{COLORS[:reset]}"
687
+ when :failed
688
+ " #{COLORS[:error]}(failed)#{COLORS[:reset]}"
689
+ when :running
690
+ return "" unless progress.run_start_time
691
+ elapsed = ((Time.now - progress.run_start_time) * 1000).round(0)
692
+ " #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
693
+ else
694
+ ""
695
+ end
696
+ end
697
+
698
+ ##
699
+ # Returns details for the clean phase only.
700
+ # @param [TaskProgress] progress - Progress object.
701
+ # @return [String] Clean phase details.
702
+ def clean_phase_details(progress)
703
+ case progress.clean_state
704
+ when :cleaning
705
+ return "" unless progress.clean_start_time
706
+ elapsed = ((Time.now - progress.clean_start_time) * 1000).round(0)
707
+ " #{COLORS[:running]}(cleaning #{elapsed}ms)#{COLORS[:reset]}"
708
+ when :clean_completed
709
+ return "" unless progress.clean_duration
710
+ " #{COLORS[:success]}(cleaned #{progress.clean_duration}ms)#{COLORS[:reset]}"
711
+ when :clean_failed
712
+ " #{COLORS[:error]}(clean failed)#{COLORS[:reset]}"
713
+ else
714
+ ""
715
+ end
716
+ end
717
+
718
+ # Get task output suffix to display next to task
719
+ ##
720
+ # Produces a trailing output suffix for a task when it is actively producing output.
721
+ #
722
+ # Fetches the last captured stdout/stderr line for the given task and returns a
723
+ # formatted, dimmed suffix containing that line only when the task `state` is
724
+ # `:running` or `:cleaning` and an output capture is available. The returned
725
+ # string is truncated to fit the terminal width (with a minimum visible length)
726
+ # and includes surrounding dim/reset color codes.
727
+ # @param [Class] task_class - The task class whose output to query.
728
+ # @param [Symbol] state - The task lifecycle state (only `:running` and `:cleaning` produce output).
729
+ # @return [String] A formatted, possibly truncated output suffix prefixed with a dim pipe, or an empty string when no output should be shown.
730
+ def task_output_suffix(task_class, state)
731
+ return "" unless state == :running || state == :cleaning
732
+ return "" unless @output_capture
733
+
734
+ last_line = @output_capture.last_line_for(task_class)
735
+ return "" unless last_line && !last_line.empty?
736
+
737
+ # Get current group name if any
738
+ progress = @tasks[task_class]
739
+ group_prefix = ""
740
+ if progress&.current_group_index
741
+ current_group = progress.groups[progress.current_group_index]
742
+ group_prefix = "#{current_group.name}: " if current_group
743
+ end
744
+
745
+ # Truncate if too long (leave space for tree structure)
746
+ terminal_cols = terminal_width
747
+ max_output_length = terminal_cols - 50
748
+ max_output_length = 20 if max_output_length < 20
749
+
750
+ full_output = "#{group_prefix}#{last_line}"
751
+ truncated = if full_output.length > max_output_length
752
+ full_output[0, max_output_length - 3] + "..."
753
+ else
754
+ full_output
755
+ end
756
+
757
+ " #{COLORS[:dim]}| #{truncated}#{COLORS[:reset]}"
758
+ end
759
+
760
+ ##
761
+ # Returns the terminal width in columns.
762
+ # Defaults to 80 if the output IO doesn't support winsize.
763
+ # @return [Integer] The terminal width in columns.
764
+ def terminal_width
765
+ if @output.respond_to?(:winsize)
766
+ _, cols = @output.winsize
767
+ cols || 80
768
+ else
769
+ 80
770
+ end
771
+ end
772
+
773
+ ##
774
+ # Checks if a class is a Taski::Section subclass.
775
+ # Delegates to the class method.
776
+ # @param klass [Class] The class to check.
777
+ # @return [Boolean] true if the class is a Section.
778
+ def section_class?(klass)
779
+ self.class.section_class?(klass)
780
+ end
781
+
782
+ ##
783
+ # Checks if a class is nested within another class.
784
+ # Delegates to the class method.
785
+ # @param child_class [Class] The potential nested class.
786
+ # @param parent_class [Class] The potential parent class.
787
+ # @return [Boolean] true if child_class is nested within parent_class.
788
+ def nested_class?(child_class, parent_class)
789
+ self.class.nested_class?(child_class, parent_class)
790
+ end
791
+ end
792
+ end
793
+ end