rooibos 0.5.0 → 0.6.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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +9 -5
  3. data/.builds/ruby-3.3.yml +9 -5
  4. data/.builds/ruby-3.4.yml +9 -5
  5. data/.builds/ruby-4.0.0.yml +9 -5
  6. data/AGENTS.md +1 -1
  7. data/CHANGELOG.md +46 -0
  8. data/README.md +2 -2
  9. data/README.rdoc +374 -0
  10. data/REUSE.toml +5 -0
  11. data/Rakefile +1 -1
  12. data/doc/best_practices/forms_and_validation.md +20 -0
  13. data/doc/best_practices/http_workflows.md +20 -0
  14. data/doc/best_practices/index.md +26 -0
  15. data/doc/best_practices/lists_and_tables.md +20 -0
  16. data/doc/best_practices/modal_dialogs.md +20 -0
  17. data/doc/best_practices/no_stateful_widgets.md +184 -0
  18. data/doc/best_practices/orchestration.md +20 -0
  19. data/doc/best_practices/streaming_data.md +20 -0
  20. data/doc/contributors/design/commands_and_outlets.md +1 -1
  21. data/doc/contributors/documentation_plan.md +616 -0
  22. data/doc/contributors/documentation_stub_audit.md +112 -0
  23. data/doc/contributors/documentation_style.md +275 -0
  24. data/doc/contributors/e2e_pty.md +168 -0
  25. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
  26. data/doc/contributors/specs/file_browser.md +789 -0
  27. data/doc/contributors/specs/file_browser_stories.md +774 -0
  28. data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
  29. data/doc/contributors/todo/scrollbar.md +118 -0
  30. data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
  31. data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
  32. data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
  33. data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
  34. data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
  35. data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
  36. data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
  37. data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
  38. data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
  39. data/doc/contributors/tutorial_old/12_going_further.md +20 -0
  40. data/doc/contributors/tutorial_old/index.md +20 -0
  41. data/doc/essentials/commands.md +20 -0
  42. data/doc/essentials/index.md +31 -0
  43. data/doc/essentials/messages.md +21 -0
  44. data/doc/essentials/models.md +21 -0
  45. data/doc/essentials/shortcuts.md +19 -0
  46. data/doc/essentials/the_elm_architecture.md +24 -0
  47. data/doc/essentials/the_runtime.md +21 -0
  48. data/doc/essentials/update_functions.md +20 -0
  49. data/doc/essentials/views.md +22 -0
  50. data/doc/getting_started/for_go_developers.md +16 -0
  51. data/doc/getting_started/for_python_developers.md +16 -0
  52. data/doc/getting_started/for_react_developers.md +17 -0
  53. data/doc/getting_started/index.md +52 -0
  54. data/doc/getting_started/install.md +20 -0
  55. data/doc/getting_started/quickstart.md +9 -45
  56. data/doc/getting_started/ruby_primer.md +19 -0
  57. data/doc/getting_started/why_rooibos.md +20 -0
  58. data/doc/index.md +79 -11
  59. data/doc/scaling_up/async_patterns.md +20 -0
  60. data/doc/scaling_up/command_composition.md +20 -0
  61. data/doc/scaling_up/custom_commands.md +21 -0
  62. data/doc/scaling_up/fractal_architecture.md +20 -0
  63. data/doc/scaling_up/index.md +30 -0
  64. data/doc/scaling_up/message_routing.md +20 -0
  65. data/doc/scaling_up/ractor_safety.md +20 -0
  66. data/doc/scaling_up/testing.md +21 -0
  67. data/doc/troubleshooting/common_errors.md +20 -0
  68. data/doc/troubleshooting/debugging.md +21 -0
  69. data/doc/troubleshooting/index.md +23 -0
  70. data/doc/troubleshooting/performance.md +20 -0
  71. data/doc/tutorial/01_project_setup.md +44 -0
  72. data/doc/tutorial/02_hello_world.md +45 -0
  73. data/doc/tutorial/03_static_file_list.md +44 -0
  74. data/doc/tutorial/04_arrow_navigation.md +47 -0
  75. data/doc/tutorial/05_real_files.md +45 -0
  76. data/doc/tutorial/06_safe_refactoring.md +21 -0
  77. data/doc/tutorial/07_red_first_tdd.md +26 -0
  78. data/doc/tutorial/08_file_metadata.md +42 -0
  79. data/doc/tutorial/09_text_preview.md +44 -0
  80. data/doc/tutorial/10_directory_tree.md +42 -0
  81. data/doc/tutorial/11_pane_focus.md +40 -0
  82. data/doc/tutorial/12_sorting.md +41 -0
  83. data/doc/tutorial/13_filtering.md +43 -0
  84. data/doc/tutorial/14_toggle_hidden.md +41 -0
  85. data/doc/tutorial/15_text_input_widget.md +43 -0
  86. data/doc/tutorial/16_rename_files.md +42 -0
  87. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  88. data/doc/tutorial/18_progress_indicators.md +43 -0
  89. data/doc/tutorial/19_atomic_operations.md +42 -0
  90. data/doc/tutorial/20_external_editor.md +42 -0
  91. data/doc/tutorial/21_modal_overlays.md +41 -0
  92. data/doc/tutorial/22_error_handling.md +43 -0
  93. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  94. data/doc/tutorial/24_mouse_events.md +43 -0
  95. data/doc/tutorial/25_resize_events.md +43 -0
  96. data/doc/tutorial/26_loading_states.md +42 -0
  97. data/doc/tutorial/27_performance.md +43 -0
  98. data/doc/tutorial/28_color_schemes.md +47 -0
  99. data/doc/tutorial/29_configuration.md +124 -0
  100. data/doc/tutorial/30_going_further.md +17 -0
  101. data/doc/tutorial/index.md +17 -0
  102. data/examples/app_file_browser/app.rb +40 -0
  103. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  104. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  105. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  106. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  107. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  108. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  112. data/examples/verify_website_first_app/app.rb +85 -0
  113. data/examples/verify_website_hello_mvu/app.rb +31 -0
  114. data/examples/widget_command_system/app.rb +15 -13
  115. data/exe/rooibos +10 -0
  116. data/generate_tutorial_stubs.rb +126 -0
  117. data/lib/rooibos/cli/commands/new.rb +373 -0
  118. data/lib/rooibos/cli/commands/run.rb +98 -0
  119. data/lib/rooibos/cli.rb +78 -0
  120. data/lib/rooibos/command/all.rb +25 -20
  121. data/lib/rooibos/command/batch.rb +26 -25
  122. data/lib/rooibos/command/custom.rb +84 -1
  123. data/lib/rooibos/command/http.rb +59 -55
  124. data/lib/rooibos/command/lifecycle.rb +5 -5
  125. data/lib/rooibos/command/open.rb +86 -0
  126. data/lib/rooibos/command/outlet.rb +105 -3
  127. data/lib/rooibos/command/wait.rb +5 -5
  128. data/lib/rooibos/command.rb +57 -74
  129. data/lib/rooibos/message/batch.rb +39 -0
  130. data/lib/rooibos/message/canceled.rb +51 -0
  131. data/lib/rooibos/message/error.rb +48 -0
  132. data/lib/rooibos/message/open.rb +30 -0
  133. data/lib/rooibos/message.rb +84 -4
  134. data/lib/rooibos/router.rb +11 -14
  135. data/lib/rooibos/runtime.rb +40 -43
  136. data/lib/rooibos/shortcuts.rb +47 -0
  137. data/lib/rooibos/test_helper.rb +71 -6
  138. data/lib/rooibos/version.rb +1 -1
  139. data/lib/rooibos/welcome.rb +237 -0
  140. data/lib/rooibos.rb +4 -3
  141. data/mise.toml +1 -1
  142. data/rbs_collection.lock.yaml +2 -2
  143. data/sig/concurrent.rbs +3 -0
  144. data/sig/gem.rbs +20 -0
  145. data/sig/rooibos/cli.rbs +42 -0
  146. data/sig/rooibos/command.rbs +48 -0
  147. data/sig/rooibos/message.rbs +60 -0
  148. data/sig/rooibos/shortcuts.rbs +14 -0
  149. data/sig/rooibos/test_helper.rbs +6 -2
  150. data/sig/rooibos/welcome.rbs +75 -0
  151. data/tasks/install.rake +29 -0
  152. data/tasks/resources/build.yml.erb +2 -0
  153. metadata +272 -38
  154. data/doc/concepts/application_architecture.md +0 -197
  155. data/doc/concepts/application_testing.md +0 -49
  156. data/doc/concepts/async_work.md +0 -164
  157. data/doc/concepts/commands.md +0 -530
  158. data/doc/concepts/message_processing.md +0 -51
  159. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  160. data/doc/contributors/WIP/implementation_plan.md +0 -409
  161. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  162. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  163. data/doc/contributors/WIP/task.md +0 -36
  164. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  165. data/doc/contributors/kit-no-outlet.md +0 -238
  166. data/doc/contributors/priorities.md +0 -38
  167. data/doc/images/.gitkeep +0 -0
  168. data/exe/.gitkeep +0 -0
  169. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -71,7 +71,7 @@ module Rooibos
71
71
  # it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
72
72
  # If your command does not exit within this window, it is force-killed.
73
73
  #
74
- # *This is NOT a lifetime limit.* Your command runs indefinitely until cancelled.
74
+ # *This is NOT a lifetime limit.* Your command runs indefinitely until canceled.
75
75
  # A WebSocket open for 15 minutes is fine. This timeout only applies to the
76
76
  # cleanup phase after cancellation is requested.
77
77
  #
@@ -99,6 +99,89 @@ module Rooibos
99
99
  def rooibos_cancellation_grace_period
100
100
  0.1
101
101
  end
102
+
103
+ # Infrastructure methods to exclude from introspection.
104
+ # Computed once from bare prototypes.
105
+ INFRASTRUCTURE_METHODS = begin
106
+ bare_data = Data.define(:_)
107
+ bare_struct = Struct.new(:_)
108
+
109
+ methods = Object.public_instance_methods +
110
+ bare_data.public_instance_methods +
111
+ bare_struct.public_instance_methods
112
+
113
+ # PP methods can error when called on objects without pretty_print override
114
+ methods += %i[
115
+ pretty_print
116
+ pretty_print_cycle
117
+ pretty_print_instance_variables
118
+ pretty_print_inspect
119
+ ]
120
+
121
+ methods.uniq.freeze
122
+ end
123
+ private_constant :INFRASTRUCTURE_METHODS
124
+
125
+ # Deconstructs for hash-based pattern matching.
126
+ #
127
+ # Introspects public query methods (CQS: zero-arity, no side effects) and
128
+ # returns a hash suitable for +case+/+in+ matching. Excludes infrastructure
129
+ # methods from Object, Data, and Struct.
130
+ #
131
+ # Always includes +:type+ as a snake_case symbol of the class name.
132
+ # Anonymous classes default to +:custom+.
133
+ #
134
+ # Data.define members are automatically included since they generate
135
+ # public accessor methods.
136
+ #
137
+ # This is a naive but practical default. Override for:
138
+ # - Hot paths (introspects methods on every call)
139
+ # - Ghost methods via +method_missing+/+respond_to_missing?+
140
+ # - Methods with optional arguments (only zero-arity detected)
141
+ #
142
+ # @param keys [Array<Symbol>, nil] Limit output to specific keys for performance.
143
+ # Pass +nil+ to include all keys.
144
+ # @return [Hash{Symbol => Object}] Deconstructed hash with +:type+ discriminator.
145
+ #
146
+ # @example Pattern matching with Data.define command
147
+ # case msg
148
+ # in { type: :http_response, envelope: :users, status: 200 }
149
+ # # handle success
150
+ # end
151
+ def deconstruct_keys(keys)
152
+ class_name = self.class.name&.split("::")&.last
153
+ type_name = if class_name
154
+ class_name
155
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
156
+ .downcase
157
+ .to_sym
158
+ else
159
+ :custom
160
+ end
161
+
162
+ result = { type: type_name }
163
+
164
+ # Include Data.define/Struct members
165
+ if self.class.respond_to?(:members)
166
+ klass = self.class #: Class & _HasMembers
167
+ klass.members.each do |member|
168
+ next if keys && !keys.include?(member)
169
+ result[member] = public_send(member)
170
+ end
171
+ end
172
+
173
+ # Include public zero-arity query methods (excluding infrastructure)
174
+ # Use Kernel#public_method to avoid collision with Data.define :method member
175
+ get_method = Kernel.instance_method(:public_method)
176
+ (public_methods - INFRASTRUCTURE_METHODS).each do |method_name|
177
+ next if method_name.to_s.end_with?("=", "!")
178
+ next unless get_method.bind_call(self, method_name).arity.zero?
179
+ next if keys && !keys.include?(method_name)
180
+ result[method_name] = public_send(method_name)
181
+ end
182
+
183
+ result
184
+ end
102
185
  end
103
186
  end
104
187
  end
@@ -18,76 +18,80 @@ module Rooibos
18
18
  Http = Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser) do
19
19
  include Custom
20
20
 
21
- def self.new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
22
- get: nil, post: nil, put: nil, patch: nil, delete: nil
23
- )
24
- # Auto-splat single hash argument
25
- return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
26
-
27
- # Auto-spread single array argument
28
- return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
29
-
30
- # DWIM: parse positional args and keyword method shortcuts
31
- method_keywords = { get:, post:, put:, patch:, delete: }.compact
32
- method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
21
+ class << self
22
+ undef_method :new
23
+
24
+ def new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
25
+ get: nil, post: nil, put: nil, patch: nil, delete: nil
26
+ )
27
+ # Auto-splat single hash argument
28
+ return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
29
+
30
+ # Auto-spread single array argument
31
+ return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
32
+
33
+ # DWIM: parse positional args and keyword method shortcuts
34
+ method_keywords = { get:, post:, put:, patch:, delete: }.compact
35
+ method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
36
+
37
+ # Ractor validation
38
+ if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
39
+ raise Rooibos::Error::Invariant,
40
+ "URL is not Ractor-shareable: #{url.inspect}\n" \
41
+ "Use a frozen string or Ractor.make_shareable."
42
+ end
33
43
 
34
- # Ractor validation
35
- if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
36
- raise Rooibos::Error::Invariant,
37
- "URL is not Ractor-shareable: #{url.inspect}\n" \
38
- "Use a frozen string or Ractor.make_shareable."
39
- end
44
+ if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
45
+ raise Rooibos::Error::Invariant,
46
+ "Headers are not Ractor-shareable: #{headers.inspect}\n" \
47
+ "Use Ractor.make_shareable or freeze the hash and its contents."
48
+ end
40
49
 
41
- if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
42
- raise Rooibos::Error::Invariant,
43
- "Headers are not Ractor-shareable: #{headers.inspect}\n" \
44
- "Use Ractor.make_shareable or freeze the hash and its contents."
45
- end
50
+ if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
51
+ raise Rooibos::Error::Invariant,
52
+ "Body is not Ractor-shareable: #{body.inspect}\n" \
53
+ "Use a frozen string or Ractor.make_shareable."
54
+ end
46
55
 
47
- if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
48
- raise Rooibos::Error::Invariant,
49
- "Body is not Ractor-shareable: #{body.inspect}\n" \
50
- "Use a frozen string or Ractor.make_shareable."
51
- end
56
+ if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
57
+ raise Rooibos::Error::Invariant,
58
+ "Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
59
+ "Use a frozen string, symbol, or Ractor.make_shareable."
60
+ end
52
61
 
53
- if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
54
- raise Rooibos::Error::Invariant,
55
- "Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
56
- "Use a frozen string, symbol, or Ractor.make_shareable."
57
- end
62
+ if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
63
+ raise Rooibos::Error::Invariant,
64
+ "Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
65
+ "Use a number or Ractor.make_shareable."
66
+ end
58
67
 
59
- if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
60
- raise Rooibos::Error::Invariant,
61
- "Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
62
- "Use a number or Ractor.make_shareable."
63
- end
68
+ # Parser validation
69
+ if parser && !parser.respond_to?(:call)
70
+ raise ArgumentError, "parser: must respond to :call"
71
+ end
64
72
 
65
- # Parser validation
66
- if parser && !parser.respond_to?(:call)
67
- raise ArgumentError, "parser: must respond to :call"
68
- end
73
+ if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
74
+ raise Rooibos::Error::Invariant,
75
+ "Parser is not Ractor-shareable: #{parser.inspect}\n" \
76
+ "Use a frozen Method object or Ractor.make_shareable."
77
+ end
69
78
 
70
- if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
71
- raise Rooibos::Error::Invariant,
72
- "Parser is not Ractor-shareable: #{parser.inspect}\n" \
73
- "Use a frozen Method object or Ractor.make_shareable."
74
- end
79
+ # Method validation
80
+ unless %i[get post put patch delete].include?(method)
81
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
82
+ end
75
83
 
76
- # Method validation
77
- unless %i[get post put patch delete].include?(method)
78
- raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
84
+ instance = allocate
85
+ instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
86
+ instance
79
87
  end
80
-
81
- instance = allocate
82
- instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
83
- instance
84
88
  end
85
89
 
86
90
  # Net::HTTP is blocking; no cooperative cancellation possible.
87
91
  # Grace period = 0 means runtime can force-kill immediately.
88
92
  def rooibos_cancellation_grace_period = 0
89
93
 
90
- def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
94
+ def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords) # :nodoc:
91
95
  # Handle keyword method shortcuts: get: 'url'
92
96
  if method_keywords.any?
93
97
  method_key, url = method_keywords.first
@@ -19,8 +19,7 @@ module Rooibos
19
19
  #
20
20
  # The framework creates one instance at startup. All outlets share it.
21
21
  class Lifecycle
22
- # :nodoc: Internal representation of a tracked async command.
23
- Entry = Data.define(:future, :origin)
22
+ Entry = Data.define(:future, :origin) # :nodoc: Internal representation of a tracked async command.
24
23
 
25
24
  # Creates a lifecycle manager.
26
25
  #
@@ -40,7 +39,7 @@ module Rooibos
40
39
  # [token] Parent's cancellation token.
41
40
  # [timeout] Max wait seconds for the result.
42
41
  #
43
- # Returns the child's message, or <tt>nil</tt> if cancelled or timed out.
42
+ # Returns the child's message, or <tt>nil</tt> if canceled or timed out.
44
43
  # Raises if the child raised.
45
44
  def run_sync(command, token, timeout:)
46
45
  return nil if token.canceled?
@@ -76,7 +75,7 @@ module Rooibos
76
75
  #
77
76
  # Spawns a future that executes the command. Tracks the command in the
78
77
  # active map for cancellation support. Errors are pushed to the channel
79
- # as <tt>Command::Error</tt> messages.
78
+ # as <tt>Message::Error</tt> messages.
80
79
  #
81
80
  # [command] Callable with <tt>call(out, token)</tt>.
82
81
  # [channel] Channel to push results and errors to.
@@ -89,7 +88,7 @@ module Rooibos
89
88
  future = Concurrent::Promises.future do
90
89
  command.call(outlet, cancellation)
91
90
  rescue => e
92
- channel.push Command::Error.new(command:, exception: e)
91
+ channel.push Message::Error.new(command:, exception: e)
93
92
  end
94
93
 
95
94
  entry = Entry.new(future:, origin:)
@@ -114,6 +113,7 @@ module Rooibos
114
113
  entry.future.wait(grace.finite? ? grace : nil)
115
114
 
116
115
  @active.delete(command)
116
+ entry # Return so caller can remove from pending_futures
117
117
  end
118
118
 
119
119
  # Cancels all active commands and waits for them to complete.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "shellwords"
9
+
10
+ module Rooibos
11
+ module Command
12
+ # Opens a file or URL with the system's default application.
13
+ #
14
+ # Terminal applications often need to hand off to external programs.
15
+ # Opening a PDF, launching a URL, or viewing an image requires
16
+ # platform-specific commands.
17
+ #
18
+ # This command detects the platform and runs the appropriate opener:
19
+ # +open+ on macOS, +xdg-open+ on Linux, +start+ on Windows.
20
+ #
21
+ # On success (exit 0), sends +Message::Open+.
22
+ # On failure (non-zero), sends +Message::Error+.
23
+ #
24
+ # === Example
25
+ #
26
+ # def update(msg, model)
27
+ # case msg
28
+ # in :view_clicked
29
+ # [model, Command.open(model.selected_file)]
30
+ # in { type: :open, envelope: path }
31
+ # model.with(status: "Opened #{path}")
32
+ # in { type: :error, envelope: path }
33
+ # model.with(error: "Could not open #{path}")
34
+ # end
35
+ # end
36
+ #
37
+ class Open < Data.define(:path, :envelope)
38
+ include Custom
39
+
40
+ # System commands are generally fast; no grace period needed.
41
+ def rooibos_cancellation_grace_period = 0
42
+
43
+ # Executes the open command and sends the result message.
44
+ def call(out, token)
45
+ return if token.canceled?
46
+
47
+ require "open3"
48
+ cmd = self.class.__send__(:system_command, path)
49
+ _stdout, stderr, status = Open3.capture3(cmd)
50
+
51
+ message = if status.exitstatus == 0
52
+ Message::Open.new(envelope:)
53
+ else
54
+ error_msg = stderr.empty? ? "Failed to open: #{path}" : stderr.strip
55
+ Message::Error.new(
56
+ command: envelope,
57
+ exception: RuntimeError.new(error_msg.freeze).freeze
58
+ )
59
+ end
60
+
61
+ out.put(Ractor.make_shareable(message))
62
+ rescue => e
63
+ out.put(Ractor.make_shareable(Message::Error.new(
64
+ command: envelope,
65
+ exception: RuntimeError.new(e.message.freeze).freeze
66
+ )))
67
+ end
68
+
69
+ # Builds the platform-specific open command.
70
+ def self.system_command(path, platform = RUBY_PLATFORM) # :nodoc:
71
+ escaped = path.shellescape
72
+ case platform
73
+ when /darwin/
74
+ "open #{escaped}"
75
+ when /linux/
76
+ "xdg-open #{escaped}"
77
+ when /mingw|mswin|cygwin/
78
+ "start #{path}"
79
+ else
80
+ "xdg-open #{escaped}"
81
+ end
82
+ end
83
+ private_class_method :system_command
84
+ end
85
+ end
86
+ end
@@ -85,10 +85,15 @@ module Rooibos
85
85
  def initialize(message_queue, lifecycle:)
86
86
  @message_queue = message_queue
87
87
  @live = lifecycle
88
+ @pending_async = [] #: Array[AsyncHandle]
88
89
  end
89
90
 
90
- # :nodoc: Internal infrastructure for nested command lifecycle sharing.
91
- attr_reader :live
91
+ # Internal handle for async streaming commands.
92
+ AsyncHandle = Data.define(:future) # :nodoc:
93
+ private_constant :AsyncHandle
94
+
95
+ # Internal infrastructure for nested command lifecycle sharing.
96
+ attr_reader :live # :nodoc:
92
97
 
93
98
  # Sends a message to the runtime.
94
99
  #
@@ -131,7 +136,7 @@ module Rooibos
131
136
  # [token] The parent's cancellation token, passed through to the child.
132
137
  # [timeout] Max seconds to wait for the child's result (default: 30.0).
133
138
  #
134
- # Returns the message from the child, or +nil+ if cancelled/timed out.
139
+ # Returns the message from the child, or +nil+ if canceled/timed out.
135
140
  # Raises if the child command raised an exception.
136
141
  #
137
142
  # === Example
@@ -152,6 +157,103 @@ module Rooibos
152
157
  def source(command, token, timeout: 30.0)
153
158
  @live.run_sync(command, token, timeout:)
154
159
  end
160
+
161
+ # Spawns an async streaming command.
162
+ #
163
+ # Multiple data sources often need to stream in parallel. Dashboards,
164
+ # real-time feeds, and multi-provider aggregations all face this pattern.
165
+ # Waiting for one source before starting the next creates latency.
166
+ #
167
+ # This method spawns a child command that runs asynchronously. Messages
168
+ # from the child stream directly to your update function as they arrive.
169
+ # The child gets a full Outlet, so it can nest +source+ or +standing+ calls.
170
+ #
171
+ # Use +wait+ to block until the child completes, or fire-and-forget for
172
+ # long-running streams.
173
+ #
174
+ # [command] A callable with <tt>call(out, token)</tt>.
175
+ # [token] The parent's cancellation token.
176
+ #
177
+ # Returns a handle for use with +wait+.
178
+ #
179
+ # === Example
180
+ #
181
+ # A dashboard that opens two SSE streams for live updates. Each stream
182
+ # emits chunks as they arrive — no waiting for the other.
183
+ #
184
+ #--
185
+ # SPDX-SnippetBegin
186
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
187
+ # SPDX-License-Identifier: MIT-0
188
+ #++
189
+ # def call(out, token)
190
+ # # Authenticate first (sync)
191
+ # auth = out.source(Authenticate.new, token)
192
+ # return if auth.nil?
193
+ #
194
+ # # Open two SSE streams in parallel — chunks arrive live
195
+ # # Streams remain outstanding until token is canceled
196
+ # out.standing(StreamNotifications.new(auth), token)
197
+ # out.standing(StreamPrices.new(auth), token)
198
+ # end
199
+ #--
200
+ # SPDX-SnippetEnd
201
+ #++
202
+ def standing(command, token)
203
+ child_outlet = Outlet.new(@message_queue, lifecycle: @live)
204
+ future = Concurrent::Promises.future do
205
+ command.call(child_outlet, token)
206
+ rescue => e
207
+ @message_queue.push Message::Error.new(command:, exception: e)
208
+ end
209
+ handle = AsyncHandle.new(future:)
210
+ @pending_async << handle
211
+ handle
212
+ end
213
+
214
+ # Blocks until async commands complete.
215
+ #
216
+ # After spawning children with +standing+, the parent command normally
217
+ # returns immediately. Use +wait+ to block until children finish, then
218
+ # emit a completion signal.
219
+ #
220
+ # This is how custom commands achieve the same end-of-streams dispatch
221
+ # that +Command.batch+ gets automatically with +Message::Batch+.
222
+ #
223
+ # [handles] Zero or more handles from +standing+. If empty, waits for all.
224
+ #
225
+ # === Example
226
+ #
227
+ # A custom command that streams from two sources and signals when done.
228
+ #
229
+ #--
230
+ # SPDX-SnippetBegin
231
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
232
+ # SPDX-License-Identifier: MIT-0
233
+ #++
234
+ # def call(out, token)
235
+ # h1 = out.standing(StreamPrices.new, token)
236
+ # h2 = out.standing(StreamNews.new, token)
237
+ # out.wait(h1, h2)
238
+ # out.put(:streams_closed) # Your custom completion signal
239
+ # end
240
+ #--
241
+ # SPDX-SnippetEnd
242
+ #++
243
+ def wait(*handles, token: nil)
244
+ handles = @pending_async || [] if handles.empty?
245
+ return if handles.empty?
246
+
247
+ futures = handles.map(&:future)
248
+ all_done = Concurrent::Promises.zip_futures(*futures)
249
+
250
+ if token
251
+ # Race completion against cancellation
252
+ Concurrent::Promises.any_event(all_done, token.origin).wait
253
+ else
254
+ all_done.wait
255
+ end
256
+ end
155
257
  end
156
258
  end
157
259
  end
@@ -15,8 +15,8 @@ module Rooibos
15
15
  # Cancellation is tricky.
16
16
  #
17
17
  # This command waits, then sends a message. It responds to
18
- # cancellation cooperatively. When cancelled, it sends
19
- # <tt>Command.cancel(self)</tt> so you know the timer stopped.
18
+ # cancellation cooperatively. When canceled, it sends
19
+ # <tt>Message::Canceled</tt> so you know the timer stopped.
20
20
  #
21
21
  # Use it for delayed actions, debounced inputs, or animation loops.
22
22
  #
@@ -28,7 +28,7 @@ module Rooibos
28
28
  # [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
29
29
  # in :dismiss
30
30
  # [model.with(notification: nil), nil]
31
- # in Command::Cancel
31
+ # in Message::Canceled
32
32
  # [model.with(notification: nil), nil] # User navigated away
33
33
  # end
34
34
  # end
@@ -57,7 +57,7 @@ module Rooibos
57
57
  # Executes the timer.
58
58
  #
59
59
  # Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
60
- # If cancelled, sends <tt>Command.cancel(self)</tt> instead.
60
+ # If canceled, sends <tt>Message::Canceled</tt> instead.
61
61
  #
62
62
  # [out] Outlet for sending messages.
63
63
  # [token] Cancellation token from the runtime.
@@ -68,7 +68,7 @@ module Rooibos
68
68
  combined.origin.wait
69
69
 
70
70
  if token.canceled?
71
- out.put(Command.cancel(self))
71
+ out.put(Message::Canceled.new(command: self))
72
72
  else
73
73
  elapsed = Time.now - start_time
74
74
  response = Message::Timer.new(envelope:, elapsed:)