taski 0.8.3 → 0.9.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +65 -50
  4. data/docs/GUIDE.md +41 -56
  5. data/examples/README.md +10 -29
  6. data/examples/clean_demo.rb +25 -65
  7. data/examples/large_tree_demo.rb +356 -0
  8. data/examples/message_demo.rb +0 -1
  9. data/examples/progress_demo.rb +13 -24
  10. data/examples/reexecution_demo.rb +8 -44
  11. data/lib/taski/execution/execution_facade.rb +150 -0
  12. data/lib/taski/execution/executor.rb +156 -357
  13. data/lib/taski/execution/registry.rb +15 -19
  14. data/lib/taski/execution/scheduler.rb +161 -140
  15. data/lib/taski/execution/task_observer.rb +41 -0
  16. data/lib/taski/execution/task_output_router.rb +41 -58
  17. data/lib/taski/execution/task_wrapper.rb +123 -219
  18. data/lib/taski/execution/worker_pool.rb +238 -64
  19. data/lib/taski/logging.rb +105 -0
  20. data/lib/taski/progress/layout/base.rb +600 -0
  21. data/lib/taski/progress/layout/filters.rb +126 -0
  22. data/lib/taski/progress/layout/log.rb +27 -0
  23. data/lib/taski/progress/layout/simple.rb +166 -0
  24. data/lib/taski/progress/layout/tags.rb +76 -0
  25. data/lib/taski/progress/layout/theme_drop.rb +84 -0
  26. data/lib/taski/progress/layout/tree.rb +300 -0
  27. data/lib/taski/progress/theme/base.rb +224 -0
  28. data/lib/taski/progress/theme/compact.rb +58 -0
  29. data/lib/taski/progress/theme/default.rb +25 -0
  30. data/lib/taski/progress/theme/detail.rb +48 -0
  31. data/lib/taski/progress/theme/plain.rb +40 -0
  32. data/lib/taski/static_analysis/analyzer.rb +5 -17
  33. data/lib/taski/static_analysis/dependency_graph.rb +19 -1
  34. data/lib/taski/static_analysis/visitor.rb +1 -39
  35. data/lib/taski/task.rb +44 -58
  36. data/lib/taski/test_helper/errors.rb +1 -1
  37. data/lib/taski/test_helper.rb +21 -35
  38. data/lib/taski/version.rb +1 -1
  39. data/lib/taski.rb +60 -61
  40. data/sig/taski.rbs +194 -203
  41. metadata +31 -8
  42. data/examples/section_demo.rb +0 -195
  43. data/lib/taski/execution/base_progress_display.rb +0 -393
  44. data/lib/taski/execution/execution_context.rb +0 -390
  45. data/lib/taski/execution/plain_progress_display.rb +0 -76
  46. data/lib/taski/execution/simple_progress_display.rb +0 -247
  47. data/lib/taski/execution/tree_progress_display.rb +0 -643
  48. data/lib/taski/section.rb +0 -74
@@ -1,643 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "stringio"
4
- require_relative "base_progress_display"
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 < BaseProgressDisplay
11
- SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
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
-
25
- # ANSI color codes (matching Task.tree)
26
- COLORS = {
27
- reset: "\e[0m",
28
- task: "\e[32m", # green
29
- section: "\e[34m", # blue
30
- impl: "\e[33m", # yellow
31
- tree: "\e[90m", # gray
32
- name: "\e[1m", # bold
33
- success: "\e[32m", # green
34
- error: "\e[31m", # red
35
- running: "\e[36m", # cyan
36
- pending: "\e[90m", # gray
37
- dim: "\e[2m" # dim
38
- }.freeze
39
-
40
- # Status icons
41
- ICONS = {
42
- # Run lifecycle states
43
- pending: "⏸", # Pause for waiting
44
- running_prefix: "", # Will use spinner
45
- completed: "✓",
46
- failed: "✗",
47
- skipped: "⊘", # Prohibition sign for unselected impl candidates
48
- # Clean lifecycle states
49
- cleaning_prefix: "", # Will use spinner
50
- clean_completed: "♻",
51
- clean_failed: "✗"
52
- }.freeze
53
-
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.
58
- def self.section_class?(klass)
59
- defined?(Taski::Section) && klass < Taski::Section
60
- end
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 "::".
67
- def self.nested_class?(child_class, parent_class)
68
- child_name = child_class.name.to_s
69
- parent_name = parent_class.name.to_s
70
- child_name.start_with?("#{parent_name}::")
71
- end
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
-
115
- # Render a static tree structure for a task class (used by Task.tree)
116
- # @param root_task_class [Class] The root task class
117
- # @return [String] The rendered tree string
118
- def self.render_static_tree(root_task_class)
119
- tree = build_tree_node(root_task_class)
120
- formatter = StaticTreeFormatter.new
121
- formatter.format(tree)
122
- end
123
-
124
- # Formatter for static tree display (no progress tracking, uses task numbers)
125
- class StaticTreeFormatter
126
- def format(tree)
127
- @task_index_map = {}
128
- format_node(tree, "", false)
129
- end
130
-
131
- private
132
-
133
- def format_node(node, prefix, is_impl)
134
- task_class = node[:task_class]
135
- type_label = colored_type_label(task_class)
136
- impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
137
- task_number = get_task_number(task_class)
138
- name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
139
-
140
- if node[:is_circular]
141
- circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
142
- return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
143
- end
144
-
145
- result = "#{impl_prefix}#{task_number} #{name} #{type_label}\n"
146
-
147
- # Register task number if not already registered
148
- @task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
149
-
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)
153
- end
154
-
155
- result
156
- end
157
-
158
- def format_child_branch(child, prefix, is_last)
159
- connector = is_last ? "└── " : "├── "
160
- extension = is_last ? " " : "│ "
161
- child_tree = format_node(child, "#{prefix}#{extension}", child[:is_impl_candidate])
162
-
163
- result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
164
- lines = child_tree.lines
165
- result += lines.first
166
- lines.drop(1).each { |line| result += line }
167
- result
168
- end
169
-
170
- def get_task_number(task_class)
171
- number = @task_index_map[task_class] || (@task_index_map.size + 1)
172
- "#{COLORS[:tree]}[#{number}]#{COLORS[:reset]}"
173
- end
174
-
175
- def colored_type_label(klass)
176
- if TreeProgressDisplay.section_class?(klass)
177
- "#{COLORS[:section]}(Section)#{COLORS[:reset]}"
178
- else
179
- "#{COLORS[:task]}(Task)#{COLORS[:reset]}"
180
- end
181
- end
182
- end
183
-
184
- def initialize(output: $stdout)
185
- super
186
- @spinner_index = 0
187
- @renderer_thread = nil
188
- @running = false
189
- @tree_structure = nil
190
- @section_impl_map = {} # Section -> selected impl class
191
- @last_line_count = 0 # Track number of lines drawn for cursor movement
192
- end
193
-
194
- protected
195
-
196
- # Template method: Called when root task is set
197
- def on_root_task_set
198
- build_tree_structure
199
- end
200
-
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
204
- end
205
-
206
- # Template method: Determine if display should activate
207
- def should_activate?
208
- tty?
209
- end
210
-
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
217
- @renderer_thread = Thread.new do
218
- loop do
219
- break unless @running
220
- render_live
221
- sleep RENDER_INTERVAL
222
- end
223
- end
224
- end
225
-
226
- # Template method: Called when display stops
227
- def on_stop
228
- @running = false
229
- @renderer_thread&.join
230
- @output.print "\e[?25h" # Show cursor
231
- @output.print "\e[?1049l" # Switch back to main screen buffer
232
- render_final
233
- end
234
-
235
- private
236
-
237
- # Build tree structure from root task for display
238
- def build_tree_structure
239
- return unless @root_task_class
240
-
241
- @tree_structure = self.class.build_tree_node(@root_task_class)
242
- register_tasks_from_tree(@tree_structure)
243
- end
244
-
245
- def render_live
246
- # Poll for new output from task pipes
247
- @output_capture&.poll
248
-
249
- lines = nil
250
-
251
- @monitor.synchronize do
252
- @spinner_index += 1
253
- lines = build_tree_display
254
- end
255
-
256
- return if lines.nil? || lines.empty?
257
-
258
- # Build complete frame in buffer for single write (flicker-free)
259
- buffer = build_frame_buffer(lines)
260
-
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
280
- lines.each do |line|
281
- buffer << line
282
- buffer << "\e[K" # Clear from cursor to end of line (removes old content)
283
- buffer << "\n"
284
- end
285
-
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
291
- end
292
-
293
- buffer
294
- end
295
-
296
- def render_final
297
- @monitor.synchronize do
298
- return unless @root_task_class
299
-
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]}"
319
-
320
- # Calculate total duration
321
- duration_str = ""
322
- if progress.run_duration
323
- duration_str = " (#{progress.run_duration}ms)"
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}"
332
- end
333
-
334
- # Build display lines from tree structure
335
- def build_tree_display
336
- return [] unless @tree_structure
337
-
338
- lines = []
339
- build_root_tree_lines(@tree_structure, "", lines)
340
- lines
341
- end
342
-
343
- # Build tree lines starting from root node
344
- # @param node [Hash] Tree node (root)
345
- # @param prefix [String] Line prefix for tree drawing
346
- # @param lines [Array<String>] Accumulated output lines
347
- def build_root_tree_lines(node, prefix, lines)
348
- task_class = node[:task_class]
349
- progress = @tasks[task_class]
350
-
351
- # Root node is never an impl candidate and is always selected
352
- line = format_tree_line(task_class, progress, false, true)
353
- lines << "#{prefix}#{line}"
354
-
355
- render_children(node, prefix, lines, task_class, true)
356
- end
357
-
358
- # Render all children of a node recursively
359
- # @param node [Hash] Tree node
360
- # @param prefix [String] Line prefix for tree drawing
361
- # @param lines [Array<String>] Accumulated output lines
362
- # @param parent_task_class [Class] Parent task class (for impl selection lookup)
363
- # @param ancestor_selected [Boolean] Whether all ancestor impl candidates were selected
364
- def render_children(node, prefix, lines, parent_task_class, ancestor_selected)
365
- children = node[:children]
366
- children.each_with_index do |child, index|
367
- is_last = (index == children.size - 1)
368
- connector = is_last ? "└── " : "├── "
369
- extension = is_last ? " " : "│ "
370
-
371
- child_progress = @tasks[child[:task_class]]
372
-
373
- # Determine child's selection status
374
- child_is_selected = true
375
- if child[:is_impl_candidate]
376
- selected_impl = @section_impl_map[parent_task_class]
377
- child_is_selected = (selected_impl == child[:task_class])
378
- end
379
- # Propagate ancestor selection state
380
- child_effective_selected = ancestor_selected && child_is_selected
381
-
382
- child_line = format_tree_line(
383
- child[:task_class],
384
- child_progress,
385
- child[:is_impl_candidate],
386
- child_effective_selected
387
- )
388
- lines << "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}#{child_line}"
389
-
390
- if child[:children].any?
391
- render_children(child, "#{prefix}#{COLORS[:tree]}#{extension}#{COLORS[:reset]}", lines, child[:task_class], child_effective_selected)
392
- end
393
- end
394
- end
395
-
396
- def format_tree_line(task_class, progress, is_impl, is_selected)
397
- return format_unknown_task(task_class, is_selected) unless progress
398
-
399
- type_label = type_label_for(task_class, is_selected)
400
- impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
401
-
402
- # Handle unselected nodes (either impl candidates or children of unselected impl)
403
- # Show dimmed regardless of task state since they belong to unselected branch
404
- unless is_selected
405
- name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
406
- suffix = is_impl ? " #{COLORS[:dim]}(not selected)#{COLORS[:reset]}" : ""
407
- return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
408
- end
409
-
410
- status_icons = combined_status_icons(progress)
411
- name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
412
- details = combined_task_details(progress)
413
- output_suffix = task_output_suffix(task_class, progress.state)
414
-
415
- "#{status_icons} #{impl_prefix}#{name} #{type_label}#{details}#{output_suffix}"
416
- end
417
-
418
- def format_unknown_task(task_class, is_selected = true)
419
- if is_selected
420
- name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
421
- type_label = type_label_for(task_class, true)
422
- "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]} #{name} #{type_label}"
423
- else
424
- name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
425
- type_label = type_label_for(task_class, false)
426
- "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{name} #{type_label}"
427
- end
428
- end
429
-
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)
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)
450
- case state
451
- when :completed
452
- "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
453
- when :failed
454
- "#{COLORS[:error]}#{ICONS[:failed]}#{COLORS[:reset]}"
455
- when :running
456
- "#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
457
- else
458
- "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]}"
459
- end
460
- end
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.
483
- def spinner_char
484
- SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
485
- end
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).
492
- def type_label_for(task_class, is_selected = true)
493
- if section_class?(task_class)
494
- is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
495
- else
496
- is_selected ? "#{COLORS[:task]}(Task)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Task)#{COLORS[:reset]}"
497
- end
498
- end
499
-
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
521
- when :completed
522
- return "" unless progress.run_duration
523
- " #{COLORS[:success]}(#{progress.run_duration}ms)#{COLORS[:reset]}"
524
- when :failed
525
- " #{COLORS[:error]}(failed)#{COLORS[:reset]}"
526
- when :running
527
- return "" unless progress.run_start_time
528
- elapsed = ((Time.now - progress.run_start_time) * 1000).round(0)
529
- " #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
530
- else
531
- ""
532
- end
533
- end
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.
628
- def section_class?(klass)
629
- self.class.section_class?(klass)
630
- end
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.
638
- def nested_class?(child_class, parent_class)
639
- self.class.nested_class?(child_class, parent_class)
640
- end
641
- end
642
- end
643
- end