taski 0.5.0 → 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.
@@ -27,49 +27,105 @@ module Taski
27
27
 
28
28
  # Status icons
29
29
  ICONS = {
30
+ # Run lifecycle states
30
31
  pending: "⏸", # Pause for waiting
31
32
  running_prefix: "", # Will use spinner
32
33
  completed: "✓",
33
34
  failed: "✗",
34
- skipped: "⊘" # Prohibition sign for unselected impl candidates
35
+ skipped: "⊘", # Prohibition sign for unselected impl candidates
36
+ # Clean lifecycle states
37
+ cleaning_prefix: "", # Will use spinner
38
+ clean_completed: "♻",
39
+ clean_failed: "✗"
35
40
  }.freeze
36
41
 
37
- # Shared helper methods
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.
38
46
  def self.section_class?(klass)
39
47
  defined?(Taski::Section) && klass < Taski::Section
40
48
  end
41
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 "::".
42
55
  def self.nested_class?(child_class, parent_class)
43
56
  child_name = child_class.name.to_s
44
57
  parent_name = parent_class.name.to_s
45
58
  child_name.start_with?("#{parent_name}::")
46
59
  end
47
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
+
48
103
  # Render a static tree structure for a task class (used by Task.tree)
49
104
  # @param root_task_class [Class] The root task class
50
105
  # @return [String] The rendered tree string
51
106
  def self.render_static_tree(root_task_class)
52
- renderer = StaticTreeRenderer.new
53
- renderer.render(root_task_class)
107
+ tree = build_tree_node(root_task_class)
108
+ formatter = StaticTreeFormatter.new
109
+ formatter.format(tree)
54
110
  end
55
111
 
56
- # Internal renderer for static tree display (no progress tracking)
57
- class StaticTreeRenderer
58
- def render(root_task_class)
112
+ # Formatter for static tree display (no progress tracking, uses task numbers)
113
+ class StaticTreeFormatter
114
+ def format(tree)
59
115
  @task_index_map = {}
60
- build_tree(root_task_class, "", false, Set.new)
116
+ format_node(tree, "", false)
61
117
  end
62
118
 
63
119
  private
64
120
 
65
- def build_tree(task_class, prefix, is_impl, ancestors)
121
+ def format_node(node, prefix, is_impl)
122
+ task_class = node[:task_class]
66
123
  type_label = colored_type_label(task_class)
67
124
  impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
68
125
  task_number = get_task_number(task_class)
69
126
  name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
70
127
 
71
- # Detect circular reference
72
- if ancestors.include?(task_class)
128
+ if node[:is_circular]
73
129
  circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
74
130
  return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
75
131
  end
@@ -79,26 +135,21 @@ module Taski
79
135
  # Register task number if not already registered
80
136
  @task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
81
137
 
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)
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)
90
141
  end
91
142
 
92
143
  result
93
144
  end
94
145
 
95
- def render_dependency_branch(dep, prefix, is_last, is_impl, ancestors)
146
+ def format_child_branch(child, prefix, is_last)
96
147
  connector = is_last ? "└── " : "├── "
97
148
  extension = is_last ? " " : "│ "
98
- dep_tree = build_tree(dep, "#{prefix}#{extension}", is_impl, ancestors)
149
+ child_tree = format_node(child, "#{prefix}#{extension}", child[:is_impl_candidate])
99
150
 
100
151
  result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
101
- lines = dep_tree.lines
152
+ lines = child_tree.lines
102
153
  result += lines.first
103
154
  lines.drop(1).each { |line| result += line }
104
155
  result
@@ -118,17 +169,71 @@ module Taski
118
169
  end
119
170
  end
120
171
 
121
- class TaskProgress
122
- attr_accessor :state, :start_time, :end_time, :error, :duration
123
- attr_accessor :is_impl_candidate
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
124
175
 
125
- def initialize
176
+ def initialize(name)
177
+ @name = name
126
178
  @state = :pending
127
179
  @start_time = nil
128
180
  @end_time = nil
129
- @error = nil
130
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
131
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
132
237
  end
133
238
  end
134
239
 
@@ -143,7 +248,15 @@ module Taski
143
248
  @root_task_class = nil
144
249
  @tree_structure = nil
145
250
  @section_impl_map = {} # Section -> selected impl class
146
- @last_line_count = 0
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
147
260
  end
148
261
 
149
262
  # Set the root task to build tree structure
@@ -183,7 +296,7 @@ module Taski
183
296
  end
184
297
 
185
298
  # @param task_class [Class] The task class to update
186
- # @param state [Symbol] The new state (:pending, :running, :completed, :failed)
299
+ # @param state [Symbol] The new state (:pending, :running, :completed, :failed, :cleaning, :clean_completed, :clean_failed)
187
300
  # @param duration [Float] Duration in milliseconds (for completed tasks)
188
301
  # @param error [Exception] Error object (for failed tasks)
189
302
  def update_task(task_class, state:, duration: nil, error: nil)
@@ -191,15 +304,33 @@ module Taski
191
304
  progress = @tasks[task_class]
192
305
  return unless progress
193
306
 
194
- progress.state = state
195
- progress.duration = duration if duration
196
- progress.error = error if error
197
-
198
307
  case state
308
+ # Run lifecycle states
309
+ when :pending
310
+ progress.run_state = :pending
199
311
  when :running
200
- progress.start_time = Time.now
201
- when :completed, :failed
202
- progress.end_time = Time.now
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
203
334
  end
204
335
  end
205
336
  end
@@ -212,6 +343,50 @@ module Taski
212
343
  end
213
344
  end
214
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
+
215
390
  def start
216
391
  should_start = false
217
392
  @monitor.synchronize do
@@ -226,8 +401,8 @@ module Taski
226
401
 
227
402
  return unless should_start
228
403
 
229
- # Hide cursor (outside monitor to avoid holding lock during I/O)
230
- @output.print "\e[?25l"
404
+ @output.print "\e[?25l" # Hide cursor
405
+ @output.print "\e7" # Save cursor position (before any tree output)
231
406
  @renderer_thread = Thread.new do
232
407
  loop do
233
408
  break unless @running
@@ -251,8 +426,7 @@ module Taski
251
426
  return unless should_stop
252
427
 
253
428
  @renderer_thread&.join
254
- # Show cursor
255
- @output.print "\e[?25h"
429
+ @output.print "\e[?25h" # Show cursor
256
430
  render_final
257
431
  end
258
432
 
@@ -262,38 +436,10 @@ module Taski
262
436
  def build_tree_structure
263
437
  return unless @root_task_class
264
438
 
265
- @tree_structure = build_tree_node(@root_task_class, Set.new)
439
+ @tree_structure = self.class.build_tree_node(@root_task_class)
266
440
  register_tasks_from_tree(@tree_structure)
267
441
  end
268
442
 
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
443
  # Register all tasks from tree structure
298
444
  def register_tasks_from_tree(node)
299
445
  return unless node
@@ -310,29 +456,25 @@ module Taski
310
456
  end
311
457
 
312
458
  def render_live
459
+ # Poll for new output from task pipes
460
+ @output_capture&.poll
461
+
313
462
  lines = nil
314
- line_count = nil
315
463
 
316
464
  @monitor.synchronize do
317
465
  @spinner_index += 1
318
466
  lines = build_tree_display
319
- line_count = @last_line_count
320
467
  end
321
468
 
322
469
  return if lines.nil? || lines.empty?
323
470
 
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
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
328
474
 
329
475
  # Redraw all lines
330
476
  lines.each do |line|
331
- @output.print "\e[K#{line}\n"
332
- end
333
-
334
- @monitor.synchronize do
335
- @last_line_count = lines.length
477
+ @output.print "#{line}\n"
336
478
  end
337
479
 
338
480
  @output.flush
@@ -343,12 +485,9 @@ module Taski
343
485
  lines = build_tree_display
344
486
  return if lines.empty?
345
487
 
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
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
352
491
 
353
492
  # Print final state
354
493
  lines.each { |line| @output.puts line }
@@ -431,11 +570,12 @@ module Taski
431
570
  return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
432
571
  end
433
572
 
434
- status_icon = task_status_icon(progress.state, is_selected)
573
+ status_icons = combined_status_icons(progress)
435
574
  name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
436
- details = task_details(progress)
575
+ details = combined_task_details(progress)
576
+ output_suffix = task_output_suffix(task_class, progress.state)
437
577
 
438
- "#{status_icon} #{impl_prefix}#{name} #{type_label}#{details}"
578
+ "#{status_icons} #{impl_prefix}#{name} #{type_label}#{details}#{output_suffix}"
439
579
  end
440
580
 
441
581
  def format_unknown_task(task_class, is_selected = true)
@@ -450,12 +590,26 @@ module Taski
450
590
  end
451
591
  end
452
592
 
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
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
458
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)
459
613
  case state
460
614
  when :completed
461
615
  "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
@@ -468,10 +622,36 @@ module Taski
468
622
  end
469
623
  end
470
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.
471
646
  def spinner_char
472
647
  SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
473
648
  end
474
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).
475
655
  def type_label_for(task_class, is_selected = true)
476
656
  if section_class?(task_class)
477
657
  is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
@@ -480,24 +660,131 @@ module Taski
480
660
  end
481
661
  end
482
662
 
483
- def task_details(progress)
484
- case progress.state
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
485
684
  when :completed
486
- " #{COLORS[:success]}(#{progress.duration}ms)#{COLORS[:reset]}"
685
+ return "" unless progress.run_duration
686
+ " #{COLORS[:success]}(#{progress.run_duration}ms)#{COLORS[:reset]}"
487
687
  when :failed
488
688
  " #{COLORS[:error]}(failed)#{COLORS[:reset]}"
489
689
  when :running
490
- elapsed = ((Time.now - progress.start_time) * 1000).round(0)
690
+ return "" unless progress.run_start_time
691
+ elapsed = ((Time.now - progress.run_start_time) * 1000).round(0)
491
692
  " #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
492
693
  else
493
694
  ""
494
695
  end
495
696
  end
496
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.
497
778
  def section_class?(klass)
498
779
  self.class.section_class?(klass)
499
780
  end
500
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.
501
788
  def nested_class?(child_class, parent_class)
502
789
  self.class.nested_class?(child_class, parent_class)
503
790
  end