taski 0.9.1 → 0.9.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c7ea0740de2f0d1fcf24a72fe5b32e75128fa4d793718b3ac12a2c041c37628
4
- data.tar.gz: 8fb45c4d390a98709d934f4b59f14a613d689ed7538df630cb4328bc405b2803
3
+ metadata.gz: 6cf783b65df1fdbbb8c8f19d78467243ea1d054c1b47097cae24b11d22d30c56
4
+ data.tar.gz: f5a88d4f88a6babb6e121b93cd22066691563a539c722cd8a3e336258ebfe495
5
5
  SHA512:
6
- metadata.gz: 7cba0b749c9f5b5e76f990731017d18c550407215f851877129d2c6b1c9872fccfb3d0e088b01db19a92064f92592971a085ca9eecca3bcd8742e44b7374a9e5
7
- data.tar.gz: 211b4d9ed455e19688bbb29acc7beb5043150703e144bb38f973b68f6bbf5fb3aa942c7fd2d77faa4332df243cb5f0d50e4a06813c7786b7c954f5a8eb674900
6
+ metadata.gz: ff7ccc6b57e2ed321a84406e2e8e913aac4ec5d9cf2e6f3d00f28cc9b22bbb6609147d500a3fca1dde7fe37bb921cd8ba00380c9b52962f71ea5e2331bef8e16
7
+ data.tar.gz: 5978f76dab28ef8cc3f57338a89996e9ba45fce7c897cf7739e7ada9076e5fc0006bb5663a1723c5492140fec8e14f5811100a8325e96dc56ebdf35684aeca0b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.2] - 2026-02-16
11
+
12
+ ### Added
13
+ - Progress::Config API for declarative layout/theme configuration ([#180](https://github.com/ahogappa/taski/pull/180))
14
+ - Split Layout::Tree into Tree::Live (TTY) and Tree::Event (non-TTY) with `Tree.for` factory ([#181](https://github.com/ahogappa/taski/pull/181))
15
+
16
+ ### Fixed
17
+ - Add base64 as runtime dependency ([#182](https://github.com/ahogappa/taski/pull/182))
18
+ - Use done_count instead of completed_count in completion display ([#179](https://github.com/ahogappa/taski/pull/179))
19
+ - Pass skipped_count in Simple layout's render_final ([#179](https://github.com/ahogappa/taski/pull/179))
20
+ - Show most recently started tasks first in simple progress display ([#178](https://github.com/ahogappa/taski/pull/178))
21
+
10
22
  ## [0.9.1] - 2026-02-16
11
23
 
12
24
  ### Changed
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Configuration for progress display.
6
+ # Holds class references for Layout and Theme, and builds display instances lazily.
7
+ #
8
+ # @example
9
+ # Taski.progress.layout = Taski::Progress::Layout::Tree
10
+ # Taski.progress.theme = Taski::Progress::Theme::Detail
11
+ class Config
12
+ attr_reader :layout, :theme, :output
13
+
14
+ # @param on_invalidate [Proc, nil] Called when config changes (to clear external caches)
15
+ def initialize(&on_invalidate)
16
+ @layout = nil
17
+ @theme = nil
18
+ @output = nil
19
+ @cached_display = nil
20
+ @on_invalidate = on_invalidate
21
+ end
22
+
23
+ def layout=(klass)
24
+ validate_layout!(klass) if klass
25
+ @layout = klass
26
+ invalidate!
27
+ end
28
+
29
+ def theme=(klass)
30
+ validate_theme!(klass) if klass
31
+ @theme = klass
32
+ invalidate!
33
+ end
34
+
35
+ def output=(io)
36
+ @output = io
37
+ invalidate!
38
+ end
39
+
40
+ # Build a Layout instance from the current config.
41
+ # Returns a cached instance if config hasn't changed.
42
+ def build
43
+ @cached_display ||= build_display
44
+ end
45
+
46
+ # Reset all settings to defaults.
47
+ def reset
48
+ @layout = nil
49
+ @theme = nil
50
+ @output = nil
51
+ invalidate!
52
+ end
53
+
54
+ private
55
+
56
+ def invalidate!
57
+ @cached_display = nil
58
+ @on_invalidate&.call
59
+ end
60
+
61
+ def build_display
62
+ layout_ref = @layout || Layout::Simple
63
+ args = {}
64
+ args[:theme] = @theme.new if @theme
65
+ args[:output] = @output if @output
66
+
67
+ if layout_ref.respond_to?(:for)
68
+ layout_ref.for(**args)
69
+ else
70
+ layout_ref.new(**args)
71
+ end
72
+ end
73
+
74
+ def validate_layout!(klass)
75
+ # Accept a Class that inherits from Base, or a Module with .for factory
76
+ valid = (klass.is_a?(Class) && klass <= Layout::Base) ||
77
+ (klass.is_a?(Module) && klass.respond_to?(:for))
78
+ unless valid
79
+ raise ArgumentError, "layout must be a Layout::Base subclass or a module with .for, got #{klass.inspect}"
80
+ end
81
+ end
82
+
83
+ def validate_theme!(klass)
84
+ unless klass.is_a?(Class) && klass <= Theme::Base
85
+ raise ArgumentError, "theme must be a subclass of Taski::Progress::Theme::Base, got #{klass.inspect}"
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -267,7 +267,7 @@ module Taski
267
267
  if failed_count > 0
268
268
  render_execution_failed(failed_count: failed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
269
269
  else
270
- render_execution_completed(completed_count: completed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
270
+ render_execution_completed(done_count: done_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
271
271
  end
272
272
  end
273
273
 
@@ -372,8 +372,8 @@ module Taski
372
372
  end
373
373
 
374
374
  # Render execution complete event
375
- def render_execution_completed(completed_count:, total_count:, total_duration:, skipped_count: 0)
376
- execution = ExecutionDrop.new(state: :completed, completed_count:, total_count:, total_duration:, skipped_count:)
375
+ def render_execution_completed(done_count:, total_count:, total_duration:, skipped_count: 0)
376
+ execution = ExecutionDrop.new(state: :completed, done_count:, total_count:, total_duration:, skipped_count:)
377
377
  render_execution_template(:execution_complete, execution:)
378
378
  end
379
379
 
@@ -515,6 +515,28 @@ module Taski
515
515
  task_class.name || task_class.to_s
516
516
  end
517
517
 
518
+ # Start a periodic render loop in a background thread.
519
+ # Starts spinner timer and calls the given block at @theme.render_interval.
520
+ # @yield Block to execute each render cycle (called under @monitor.synchronize)
521
+ def render_loop(&block)
522
+ @render_thread_running = true
523
+ start_spinner_timer
524
+ @render_thread = Thread.new do
525
+ while @render_thread_running
526
+ @monitor.synchronize(&block)
527
+ sleep @theme.render_interval
528
+ end
529
+ end
530
+ end
531
+
532
+ # Stop the periodic render loop and spinner timer.
533
+ def stop_render_loop
534
+ @render_thread_running = false
535
+ @render_thread&.join
536
+ @render_thread = nil
537
+ stop_spinner_timer
538
+ end
539
+
518
540
  # Check if output is a TTY
519
541
  def tty?
520
542
  @output.tty?
@@ -36,9 +36,6 @@ module Taski
36
36
  def initialize(output: $stdout, theme: nil)
37
37
  theme ||= Theme::Compact.new
38
38
  super
39
- @renderer_thread = nil
40
- @running = false
41
- @running_mutex = Mutex.new
42
39
  end
43
40
 
44
41
  protected
@@ -70,38 +67,26 @@ module Taski
70
67
  end
71
68
 
72
69
  def handle_start
73
- @running_mutex.synchronize { @running = true }
74
- start_spinner_timer
75
70
  @output.print "\e[?25l" # Hide cursor
76
- @renderer_thread = Thread.new do
77
- loop do
78
- break unless @running_mutex.synchronize { @running }
79
- render_live
80
- sleep @theme.render_interval
81
- end
82
- end
71
+ render_loop { render_status_line }
83
72
  end
84
73
 
85
74
  def handle_stop
86
- @running_mutex.synchronize { @running = false }
87
- @renderer_thread&.join
88
- stop_spinner_timer
75
+ stop_render_loop
89
76
  @output.print "\e[?25h" # Show cursor
90
77
  render_final
91
78
  end
92
79
 
93
80
  private
94
81
 
95
- def render_live
96
- @monitor.synchronize do
97
- line = build_status_line
98
- # Truncate line to terminal width to prevent line wrap
99
- max_width = terminal_width - 1 # Leave space for cursor
100
- line = line[0, max_width] if line.length > max_width
101
- # Clear line and write new content
102
- @output.print "\r\e[K#{line}"
103
- @output.flush
104
- end
82
+ def render_status_line
83
+ line = build_status_line
84
+ # Truncate line to terminal width to prevent line wrap
85
+ max_width = terminal_width - 1 # Leave space for cursor
86
+ line = line[0, max_width] if line.length > max_width
87
+ # Clear line and write new content
88
+ @output.print "\r\e[K#{line}"
89
+ @output.flush
105
90
  end
106
91
 
107
92
  def terminal_width
@@ -113,9 +98,9 @@ module Taski
113
98
  def render_final
114
99
  @monitor.synchronize do
115
100
  line = if failed_count > 0
116
- render_execution_failed(failed_count: failed_count, total_count: total_count, total_duration: total_duration)
101
+ render_execution_failed(failed_count: failed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
117
102
  else
118
- render_execution_completed(completed_count: completed_count, total_count: total_count, total_duration: total_duration)
103
+ render_execution_completed(done_count: done_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
119
104
  end
120
105
 
121
106
  @output.print "\r\e[K#{line}\n"
@@ -126,7 +111,7 @@ module Taski
126
111
  def build_status_line
127
112
  task_names = collect_current_task_names
128
113
 
129
- primary_task = running_tasks.keys.first || cleaning_tasks.keys.first
114
+ primary_task = running_tasks.keys.last || cleaning_tasks.keys.last
130
115
  task_stdout = build_task_stdout(primary_task)
131
116
 
132
117
  render_execution_running(
@@ -139,12 +124,13 @@ module Taski
139
124
 
140
125
  def collect_current_task_names
141
126
  # Prioritize: cleaning > running > pending
127
+ # Reverse so most recently started tasks appear first
142
128
  current_tasks = if cleaning_tasks.any?
143
- cleaning_tasks.keys
129
+ cleaning_tasks.keys.reverse
144
130
  elsif running_tasks.any?
145
- running_tasks.keys
131
+ running_tasks.keys.reverse
146
132
  elsif pending_tasks.any?
147
- pending_tasks.keys
133
+ pending_tasks.keys.reverse
148
134
  else
149
135
  []
150
136
  end
@@ -76,7 +76,7 @@ module Taski
76
76
  # failed_count, total_count, total_duration, root_task_name, task_names
77
77
  #
78
78
  # @example Using in Liquid template
79
- # [{{ execution.completed_count }}/{{ execution.total_count }}]
79
+ # [{{ execution.done_count }}/{{ execution.total_count }}]
80
80
  # {{ execution.total_duration | format_duration }}
81
81
  class ExecutionDrop < DataDrop; end
82
82
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../../theme/detail"
5
+ require_relative "structure"
6
+
7
+ module Taski
8
+ module Progress
9
+ module Layout
10
+ module Tree
11
+ # Non-TTY event-driven tree layout.
12
+ # Outputs lines immediately with tree prefixes as events arrive.
13
+ # Used for logs, CI, piped output, and static tree display.
14
+ class Event < Base
15
+ include Structure
16
+
17
+ def initialize(output: $stderr, theme: nil)
18
+ theme ||= Theme::Detail.new
19
+ super
20
+ init_tree_structure
21
+ end
22
+
23
+ protected
24
+
25
+ def handle_ready
26
+ build_ready_tree
27
+ end
28
+
29
+ def handle_task_update(task_class, current_state, phase)
30
+ progress = @tasks[task_class]
31
+ duration = compute_duration(progress, phase)
32
+ text = render_for_task_event(task_class, current_state, duration, nil, phase)
33
+ output_with_prefix(task_class, text) if text
34
+ end
35
+
36
+ def handle_group_started(task_class, group_name, phase)
37
+ text = render_group_started(task_class, group_name: group_name)
38
+ output_with_prefix(task_class, text) if text
39
+ end
40
+
41
+ def handle_group_completed(task_class, group_name, phase, duration)
42
+ text = render_group_succeeded(task_class, group_name: group_name, task_duration: duration)
43
+ output_with_prefix(task_class, text) if text
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../../theme/detail"
5
+ require_relative "structure"
6
+
7
+ module Taski
8
+ module Progress
9
+ module Layout
10
+ module Tree
11
+ # TTY periodic-update tree layout.
12
+ # Refreshes the entire tree display at regular intervals with spinner animation.
13
+ # Used for interactive terminal output.
14
+ class Live < Base
15
+ include Structure
16
+
17
+ def initialize(output: $stderr, theme: nil)
18
+ theme ||= Theme::Detail.new
19
+ super
20
+ init_tree_structure
21
+ @last_line_count = 0
22
+ end
23
+
24
+ protected
25
+
26
+ def handle_ready
27
+ build_ready_tree
28
+ end
29
+
30
+ # TTY mode: skip per-event output, tree is updated by render_loop
31
+ def handle_task_update(_task_class, _current_state, _phase)
32
+ end
33
+
34
+ def handle_group_started(_task_class, _group_name, _phase)
35
+ end
36
+
37
+ def handle_group_completed(_task_class, _group_name, _phase, _duration)
38
+ end
39
+
40
+ def should_activate?
41
+ tty?
42
+ end
43
+
44
+ def handle_start
45
+ @output.print "\e[?25l" # Hide cursor
46
+ render_loop { render_tree_live }
47
+ end
48
+
49
+ def handle_stop
50
+ stop_render_loop
51
+ @output.print "\e[?25h" # Show cursor
52
+ render_final
53
+ end
54
+
55
+ private
56
+
57
+ def render_tree_live
58
+ lines = build_tree_lines
59
+ clear_previous_output
60
+ lines.each { |line| @output.puts line }
61
+ @output.flush
62
+ @last_line_count = lines.size
63
+ end
64
+
65
+ def render_final
66
+ @monitor.synchronize do
67
+ lines = build_tree_lines
68
+ clear_previous_output
69
+
70
+ lines.each { |line| @output.puts line }
71
+ @output.puts render_execution_summary
72
+ @output.flush
73
+ end
74
+ end
75
+
76
+ def clear_previous_output
77
+ return if @last_line_count == 0
78
+ # Move cursor up and clear lines
79
+ @output.print "\e[#{@last_line_count}A\e[J"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ module Layout
6
+ module Tree
7
+ # Shared tree structure logic for Tree::Live and Tree::Event.
8
+ # Provides tree building, node registration, prefix generation,
9
+ # and tree rendering methods.
10
+ module Structure
11
+ # Tree connector characters
12
+ BRANCH = "├── "
13
+ LAST_BRANCH = "└── "
14
+ VERTICAL = "│ "
15
+ SPACE = " "
16
+
17
+ # Returns the tree structure as a string.
18
+ # Uses the current theme to render task content for each node.
19
+ def render_tree
20
+ build_tree_lines.join("\n") + "\n"
21
+ end
22
+
23
+ protected
24
+
25
+ def init_tree_structure
26
+ @tree_nodes = {}
27
+ @node_depths = {}
28
+ @node_is_last = {}
29
+ end
30
+
31
+ def build_ready_tree
32
+ graph = context&.dependency_graph
33
+ root = context&.root_task_class
34
+ return unless graph && root
35
+
36
+ tree = build_tree_from_graph(root, graph)
37
+ register_tree_nodes(tree, depth: 0, is_last: true, ancestors_last: [])
38
+ end
39
+
40
+ # Output text with tree prefix for the given task
41
+ def output_with_prefix(task_class, text)
42
+ prefix = build_tree_prefix(task_class)
43
+ output_line("#{prefix}#{text}")
44
+ end
45
+
46
+ private
47
+
48
+ def build_tree_from_graph(task_class, graph, ancestors = Set.new)
49
+ is_circular = ancestors.include?(task_class)
50
+ node = {task_class: task_class, is_circular: is_circular, children: []}
51
+ return node if is_circular
52
+
53
+ new_ancestors = ancestors + [task_class]
54
+ deps = graph.dependencies_for(task_class)
55
+ deps.each do |dep|
56
+ child_node = build_tree_from_graph(dep, graph, new_ancestors)
57
+ node[:children] << child_node
58
+ end
59
+ node
60
+ end
61
+
62
+ def register_tree_nodes(node, depth:, is_last:, ancestors_last:)
63
+ return unless node
64
+
65
+ task_class = node[:task_class]
66
+ @tasks[task_class] ||= new_task_progress
67
+ @tree_nodes[task_class] = node
68
+ @node_depths[task_class] = depth
69
+ @node_is_last[task_class] = {is_last: is_last, ancestors_last: ancestors_last.dup}
70
+
71
+ children = node[:children]
72
+ children.each_with_index do |child, index|
73
+ child_is_last = (index == children.size - 1)
74
+ new_ancestors_last = ancestors_last + [is_last]
75
+ register_tree_nodes(child, depth: depth + 1, is_last: child_is_last, ancestors_last: new_ancestors_last)
76
+ end
77
+ end
78
+
79
+ def build_tree_lines
80
+ return [] unless @root_task_class
81
+
82
+ lines = []
83
+ root_node = @tree_nodes[@root_task_class]
84
+ build_node_lines(root_node, lines)
85
+ lines
86
+ end
87
+
88
+ def build_node_lines(node, lines)
89
+ return unless node
90
+
91
+ task_class = node[:task_class]
92
+ prefix = build_tree_prefix(task_class)
93
+ content = build_task_content(task_class)
94
+ lines << "#{prefix}#{content}"
95
+
96
+ node[:children].each do |child|
97
+ build_node_lines(child, lines)
98
+ end
99
+ end
100
+
101
+ def build_task_content(task_class)
102
+ progress = @tasks[task_class]
103
+
104
+ case progress&.dig(:run_state)
105
+ when :running
106
+ render_task_started(task_class)
107
+ when :completed
108
+ render_task_succeeded(task_class, task_duration: compute_duration(progress, :run))
109
+ when :failed
110
+ render_task_failed(task_class, error: nil)
111
+ when :skipped
112
+ render_task_skipped(task_class)
113
+ else
114
+ task = TaskDrop.new(name: task_class_name(task_class), state: :pending)
115
+ render_task_template(:task_pending, task:, execution: execution_drop)
116
+ end
117
+ end
118
+
119
+ def build_tree_prefix(task_class)
120
+ depth = @node_depths[task_class]
121
+ return "" if depth.nil? || depth == 0
122
+
123
+ last_info = @node_is_last[task_class]
124
+ return "" unless last_info
125
+
126
+ ancestors_last = last_info[:ancestors_last]
127
+ is_last = last_info[:is_last]
128
+
129
+ prefix = ""
130
+ # Skip the first ancestor (root) since root has no visual prefix
131
+ ancestors_last[1..].each do |ancestor_is_last|
132
+ prefix += ancestor_is_last ? SPACE : VERTICAL
133
+ end
134
+
135
+ prefix += is_last ? LAST_BRANCH : BRANCH
136
+ prefix
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -1,299 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
- require_relative "../theme/detail"
3
+ require_relative "tree/structure"
4
+ require_relative "tree/live"
5
+ require_relative "tree/event"
5
6
 
6
7
  module Taski
7
8
  module Progress
8
9
  module Layout
9
- # Tree layout for hierarchical task display.
10
- # Renders tasks in a tree structure with visual connectors (├──, └──, │).
10
+ # Tree layout module for hierarchical task display.
11
+ # Renders tasks in a tree structure with visual connectors.
11
12
  #
12
- # Operates in two modes:
13
- # - TTY mode: Periodic full-tree refresh with spinner animation
14
- # - Non-TTY mode: Incremental output with tree prefixes (for logs/tests)
13
+ # Contains two implementations:
14
+ # - Tree::Live — TTY periodic-update with spinner animation
15
+ # - Tree::Event — Non-TTY event-driven incremental output
15
16
  #
16
- # Output format (live updating):
17
- # BuildApplication
18
- # ├── ⠹ SetupDatabase
19
- # │ ├── ✓ CreateSchema (50ms)
20
- # │ └── ⠹ SeedData
21
- # ├── ○ ExtractLayers
22
- # │ ├── ✓ DownloadLayer1 (100ms)
23
- # │ └── ○ DownloadLayer2
24
- # └── ✓ RunSystemCommand (200ms)
17
+ # Use Tree.for to automatically select the appropriate implementation.
25
18
  #
26
- # The tree structure (prefixes) is added by this Layout.
27
- # The task content (icons, names, duration) comes from the Theme.
19
+ # @example Auto-select based on output TTY
20
+ # layout = Taski::Progress::Layout::Tree.for
28
21
  #
29
- # This demonstrates the Theme/Layout separation:
30
- # - Theme defines "what one line looks like" (icons, colors, formatting)
31
- # - Layout defines "how lines are arranged" (tree structure, prefixes)
32
- #
33
- # @example Using with Theme::Default
34
- # layout = Taski::Progress::Layout::Tree.new(theme: Taski::Progress::Theme::Default.new)
35
- class Tree < Base
36
- # Tree connector characters
37
- BRANCH = "├── "
38
- LAST_BRANCH = "└── "
39
- VERTICAL = "│ "
40
- SPACE = " "
41
-
42
- def initialize(output: $stderr, theme: nil)
43
- theme ||= Theme::Detail.new
44
- super
45
- @tree_nodes = {}
46
- @node_depths = {}
47
- @node_is_last = {}
48
- @renderer_thread = nil
49
- @running = false
50
- @running_mutex = Mutex.new
51
- @last_line_count = 0
52
- @non_tty_started = false
53
- end
54
-
55
- # Returns the tree structure as a string.
56
- # Uses the current theme to render task content for each node.
57
- def render_tree
58
- build_tree_lines.join("\n") + "\n"
59
- end
60
-
61
- # Override on_start to handle non-TTY mode
62
- def on_start
63
- @monitor.synchronize do
64
- @nest_level += 1
65
- return if @nest_level > 1
66
-
67
- @start_time = Time.now
68
-
69
- if should_activate?
70
- @active = true
71
- else
72
- # Non-TTY mode: output execution start message
73
- @non_tty_started = true
74
- output_line(render_execution_started(@root_task_class)) if @root_task_class
75
- end
76
- end
77
-
78
- handle_start if @active
79
- end
80
-
81
- # Override on_stop to handle non-TTY mode
82
- def on_stop
83
- was_active = false
84
- non_tty_was_started = false
85
- @monitor.synchronize do
86
- @nest_level -= 1 if @nest_level > 0
87
- return unless @nest_level == 0
88
- was_active = @active
89
- non_tty_was_started = @non_tty_started
90
- @active = false
91
- @non_tty_started = false
92
- end
93
-
94
- if was_active
95
- handle_stop
96
- elsif non_tty_was_started
97
- # Non-TTY mode: output execution summary
98
- output_execution_summary
99
- end
100
- flush_queued_messages
101
- end
102
-
103
- protected
104
-
105
- def handle_ready
106
- graph = context&.dependency_graph
107
- root = context&.root_task_class
108
- return unless graph && root
109
-
110
- tree = build_tree_from_graph(root, graph)
111
- register_tree_nodes(tree, depth: 0, is_last: true, ancestors_last: [])
112
- end
113
-
114
- # In TTY mode, tree is updated by render_live periodically.
115
- # In non-TTY mode, output lines immediately with tree prefix.
116
- def handle_task_update(task_class, current_state, phase)
117
- return if @active # TTY mode: skip per-event output
118
-
119
- # Non-TTY mode: output with tree prefix
120
- progress = @tasks[task_class]
121
- duration = compute_duration(progress, phase)
122
- text = render_for_task_event(task_class, current_state, duration, nil, phase)
123
- output_with_prefix(task_class, text) if text
124
- end
125
-
126
- def handle_group_started(task_class, group_name, phase)
127
- return if @active # TTY mode: skip per-event output
128
-
129
- # Non-TTY mode: output with tree prefix
130
- text = render_group_started(task_class, group_name: group_name)
131
- output_with_prefix(task_class, text) if text
132
- end
133
-
134
- def handle_group_completed(task_class, group_name, phase, duration)
135
- return if @active # TTY mode: skip per-event output
136
-
137
- # Non-TTY mode: output with tree prefix
138
- text = render_group_succeeded(task_class, group_name: group_name, task_duration: duration)
139
- output_with_prefix(task_class, text) if text
140
- end
141
-
142
- def should_activate?
143
- tty?
144
- end
145
-
146
- def handle_start
147
- @running_mutex.synchronize { @running = true }
148
- start_spinner_timer
149
- @output.print "\e[?25l" # Hide cursor
150
- @renderer_thread = Thread.new do
151
- loop do
152
- break unless @running_mutex.synchronize { @running }
153
- render_live
154
- sleep @theme.render_interval
155
- end
156
- end
157
- end
158
-
159
- def handle_stop
160
- @running_mutex.synchronize { @running = false }
161
- @renderer_thread&.join
162
- stop_spinner_timer
163
- @output.print "\e[?25h" # Show cursor
164
- render_final
165
- end
166
-
167
- private
168
-
169
- # Output text with tree prefix for the given task
170
- def output_with_prefix(task_class, text)
171
- prefix = build_tree_prefix(task_class)
172
- output_line("#{prefix}#{text}")
173
- end
174
-
175
- # Output execution summary for non-TTY mode
176
- def output_execution_summary
177
- output_line(render_execution_summary)
178
- end
179
-
180
- def build_tree_from_graph(task_class, graph, ancestors = Set.new)
181
- is_circular = ancestors.include?(task_class)
182
- node = {task_class: task_class, is_circular: is_circular, children: []}
183
- return node if is_circular
184
-
185
- new_ancestors = ancestors + [task_class]
186
- deps = graph.dependencies_for(task_class)
187
- deps.each do |dep|
188
- child_node = build_tree_from_graph(dep, graph, new_ancestors)
189
- node[:children] << child_node
190
- end
191
- node
192
- end
193
-
194
- def register_tree_nodes(node, depth:, is_last:, ancestors_last:)
195
- return unless node
196
-
197
- task_class = node[:task_class]
198
- @tasks[task_class] ||= new_task_progress
199
- @tree_nodes[task_class] = node
200
- @node_depths[task_class] = depth
201
- @node_is_last[task_class] = {is_last: is_last, ancestors_last: ancestors_last.dup}
202
-
203
- children = node[:children]
204
- children.each_with_index do |child, index|
205
- child_is_last = (index == children.size - 1)
206
- new_ancestors_last = ancestors_last + [is_last]
207
- register_tree_nodes(child, depth: depth + 1, is_last: child_is_last, ancestors_last: new_ancestors_last)
208
- end
209
- end
210
-
211
- def render_live
212
- @monitor.synchronize do
213
- lines = build_tree_lines
214
- clear_previous_output
215
- lines.each { |line| @output.puts line }
216
- @output.flush
217
- @last_line_count = lines.size
218
- end
219
- end
220
-
221
- def render_final
222
- @monitor.synchronize do
223
- lines = build_tree_lines
224
- clear_previous_output
225
-
226
- lines.each { |line| @output.puts line }
227
- @output.puts render_execution_summary
228
- @output.flush
229
- end
230
- end
231
-
232
- def clear_previous_output
233
- return if @last_line_count == 0
234
- # Move cursor up and clear lines
235
- @output.print "\e[#{@last_line_count}A\e[J"
236
- end
237
-
238
- def build_tree_lines
239
- return [] unless @root_task_class
240
-
241
- lines = []
242
- root_node = @tree_nodes[@root_task_class]
243
- build_node_lines(root_node, lines)
244
- lines
245
- end
246
-
247
- def build_node_lines(node, lines)
248
- return unless node
249
-
250
- task_class = node[:task_class]
251
- prefix = build_tree_prefix(task_class)
252
- content = build_task_content(task_class)
253
- lines << "#{prefix}#{content}"
254
-
255
- node[:children].each do |child|
256
- build_node_lines(child, lines)
257
- end
258
- end
259
-
260
- def build_task_content(task_class)
261
- progress = @tasks[task_class]
262
-
263
- case progress&.dig(:run_state)
264
- when :running
265
- render_task_started(task_class)
266
- when :completed
267
- render_task_succeeded(task_class, task_duration: compute_duration(progress, :run))
268
- when :failed
269
- render_task_failed(task_class, error: nil)
270
- when :skipped
271
- render_task_skipped(task_class)
22
+ # @example Explicit selection
23
+ # layout = Taski::Progress::Layout::Tree::Live.new # TTY
24
+ # layout = Taski::Progress::Layout::Tree::Event.new # non-TTY
25
+ module Tree
26
+ # Factory method to create the appropriate tree layout.
27
+ # Returns Tree::Live for TTY outputs, Tree::Event otherwise.
28
+ #
29
+ # @param output [IO] Output stream (default: $stderr)
30
+ # @param theme [Theme::Base, nil] Theme instance
31
+ # @return [Tree::Live, Tree::Event]
32
+ def self.for(output: $stderr, theme: nil)
33
+ if output.respond_to?(:tty?) && output.tty?
34
+ Live.new(output: output, theme: theme)
272
35
  else
273
- task = TaskDrop.new(name: task_class_name(task_class), state: :pending)
274
- render_task_template(:task_pending, task:, execution: execution_drop)
36
+ Event.new(output: output, theme: theme)
275
37
  end
276
38
  end
277
-
278
- def build_tree_prefix(task_class)
279
- depth = @node_depths[task_class]
280
- return "" if depth.nil? || depth == 0
281
-
282
- last_info = @node_is_last[task_class]
283
- return "" unless last_info
284
-
285
- ancestors_last = last_info[:ancestors_last]
286
- is_last = last_info[:is_last]
287
-
288
- prefix = ""
289
- # Skip the first ancestor (root) since root has no visual prefix
290
- ancestors_last[1..].each do |ancestor_is_last|
291
- prefix += ancestor_is_last ? SPACE : VERTICAL
292
- end
293
-
294
- prefix += is_last ? LAST_BRANCH : BRANCH
295
- prefix
296
- end
297
39
  end
298
40
  end
299
41
  end
@@ -84,7 +84,7 @@ module Taski
84
84
  end
85
85
 
86
86
  def execution_complete
87
- "[TASKI] Completed: {{ execution.completed_count }}/{{ execution.total_count }} tasks ({{ execution.total_duration | format_duration }})"
87
+ "[TASKI] Completed: {{ execution.done_count }}/{{ execution.total_count }} tasks ({{ execution.total_duration | format_duration }})"
88
88
  end
89
89
 
90
90
  def execution_fail
@@ -45,7 +45,7 @@ module Taski
45
45
 
46
46
  # Execution complete with icon
47
47
  def execution_complete
48
- "{% icon %} [TASKI] Completed: {{ execution.completed_count }}/{{ execution.total_count }} tasks ({{ execution.total_duration | format_duration }})"
48
+ "{% icon %} [TASKI] Completed: {{ execution.done_count }}/{{ execution.total_count }} tasks ({{ execution.total_duration | format_duration }})"
49
49
  end
50
50
 
51
51
  # Execution fail with icon
data/lib/taski/task.rb CHANGED
@@ -115,7 +115,7 @@ module Taski
115
115
  def tree
116
116
  output = StringIO.new
117
117
  theme = Progress::Theme::Plain.new
118
- layout = Progress::Layout::Tree.new(output: output, theme: theme)
118
+ layout = Progress::Layout::Tree.for(output: output, theme: theme)
119
119
  context = Execution::ExecutionFacade.new(root_task_class: self)
120
120
  layout.context = context
121
121
  layout.on_ready
data/lib/taski/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taski
4
- VERSION = "0.9.1"
4
+ VERSION = "0.9.2"
5
5
  end
data/lib/taski.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "taski/execution/executor"
16
16
  require_relative "taski/progress/layout/log"
17
17
  require_relative "taski/progress/layout/simple"
18
18
  require_relative "taski/progress/layout/tree"
19
+ require_relative "taski/progress/config"
19
20
  require_relative "taski/args"
20
21
  require_relative "taski/env"
21
22
  require_relative "taski/logging"
@@ -267,14 +268,28 @@ module Taski
267
268
  reset_args! if created_args
268
269
  end
269
270
 
270
- NOT_CONFIGURED = Object.new.freeze
271
271
  PROGRESS_MONITOR = Monitor.new
272
- @progress_display = NOT_CONFIGURED
272
+ PROGRESS_NOT_SET = Object.new.freeze
273
+ @progress_display = PROGRESS_NOT_SET
274
+ @progress_config = Progress::Config.new {
275
+ PROGRESS_MONITOR.synchronize do
276
+ unless @progress_display.equal?(PROGRESS_NOT_SET)
277
+ @progress_display.stop if @progress_display.respond_to?(:stop)
278
+ end
279
+ @progress_display = PROGRESS_NOT_SET
280
+ end
281
+ }
282
+
283
+ # Get the progress configuration singleton.
284
+ # @return [Progress::Config]
285
+ def self.progress
286
+ PROGRESS_MONITOR.synchronize { @progress_config }
287
+ end
273
288
 
274
289
  def self.progress_display
275
290
  PROGRESS_MONITOR.synchronize do
276
- if @progress_display.equal?(NOT_CONFIGURED)
277
- @progress_display = Progress::Layout::Simple.new
291
+ if @progress_display.equal?(PROGRESS_NOT_SET)
292
+ @progress_display = @progress_config.build
278
293
  end
279
294
  @progress_display
280
295
  end
@@ -282,7 +297,7 @@ module Taski
282
297
 
283
298
  def self.progress_display=(display)
284
299
  PROGRESS_MONITOR.synchronize do
285
- unless @progress_display.equal?(NOT_CONFIGURED)
300
+ unless @progress_display.equal?(PROGRESS_NOT_SET)
286
301
  @progress_display.stop if @progress_display.respond_to?(:stop)
287
302
  end
288
303
  @progress_display = display
@@ -291,10 +306,11 @@ module Taski
291
306
 
292
307
  def self.reset_progress_display!
293
308
  PROGRESS_MONITOR.synchronize do
294
- unless @progress_display.equal?(NOT_CONFIGURED)
309
+ unless @progress_display.equal?(PROGRESS_NOT_SET)
295
310
  @progress_display.stop if @progress_display.respond_to?(:stop)
296
311
  end
297
- @progress_display = NOT_CONFIGURED
312
+ @progress_display = PROGRESS_NOT_SET
313
+ @progress_config.reset
298
314
  end
299
315
  end
300
316
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: taski
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ahogappa
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: liquid
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +107,7 @@ files:
93
107
  - lib/taski/execution/task_wrapper.rb
94
108
  - lib/taski/execution/worker_pool.rb
95
109
  - lib/taski/logging.rb
110
+ - lib/taski/progress/config.rb
96
111
  - lib/taski/progress/layout/base.rb
97
112
  - lib/taski/progress/layout/filters.rb
98
113
  - lib/taski/progress/layout/log.rb
@@ -100,6 +115,9 @@ files:
100
115
  - lib/taski/progress/layout/tags.rb
101
116
  - lib/taski/progress/layout/theme_drop.rb
102
117
  - lib/taski/progress/layout/tree.rb
118
+ - lib/taski/progress/layout/tree/event.rb
119
+ - lib/taski/progress/layout/tree/live.rb
120
+ - lib/taski/progress/layout/tree/structure.rb
103
121
  - lib/taski/progress/theme/base.rb
104
122
  - lib/taski/progress/theme/compact.rb
105
123
  - lib/taski/progress/theme/default.rb