rooibos 0.5.0 → 0.6.1

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 (171) 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 +57 -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_rails_developers.md +17 -0
  53. data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
  54. data/doc/getting_started/for_react_developers.md +17 -0
  55. data/doc/getting_started/index.md +52 -0
  56. data/doc/getting_started/install.md +20 -0
  57. data/doc/getting_started/quickstart.md +9 -45
  58. data/doc/getting_started/ruby_primer.md +19 -0
  59. data/doc/getting_started/why_rooibos.md +20 -0
  60. data/doc/index.md +79 -11
  61. data/doc/scaling_up/async_patterns.md +20 -0
  62. data/doc/scaling_up/command_composition.md +20 -0
  63. data/doc/scaling_up/custom_commands.md +21 -0
  64. data/doc/scaling_up/fractal_architecture.md +20 -0
  65. data/doc/scaling_up/index.md +30 -0
  66. data/doc/scaling_up/message_routing.md +20 -0
  67. data/doc/scaling_up/ractor_safety.md +20 -0
  68. data/doc/scaling_up/testing.md +21 -0
  69. data/doc/troubleshooting/common_errors.md +20 -0
  70. data/doc/troubleshooting/debugging.md +21 -0
  71. data/doc/troubleshooting/index.md +23 -0
  72. data/doc/troubleshooting/performance.md +20 -0
  73. data/doc/tutorial/01_project_setup.md +44 -0
  74. data/doc/tutorial/02_hello_world.md +45 -0
  75. data/doc/tutorial/03_static_file_list.md +44 -0
  76. data/doc/tutorial/04_arrow_navigation.md +47 -0
  77. data/doc/tutorial/05_real_files.md +45 -0
  78. data/doc/tutorial/06_safe_refactoring.md +21 -0
  79. data/doc/tutorial/07_red_first_tdd.md +26 -0
  80. data/doc/tutorial/08_file_metadata.md +42 -0
  81. data/doc/tutorial/09_text_preview.md +44 -0
  82. data/doc/tutorial/10_directory_tree.md +42 -0
  83. data/doc/tutorial/11_pane_focus.md +40 -0
  84. data/doc/tutorial/12_sorting.md +41 -0
  85. data/doc/tutorial/13_filtering.md +43 -0
  86. data/doc/tutorial/14_toggle_hidden.md +41 -0
  87. data/doc/tutorial/15_text_input_widget.md +43 -0
  88. data/doc/tutorial/16_rename_files.md +42 -0
  89. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  90. data/doc/tutorial/18_progress_indicators.md +43 -0
  91. data/doc/tutorial/19_atomic_operations.md +42 -0
  92. data/doc/tutorial/20_external_editor.md +42 -0
  93. data/doc/tutorial/21_modal_overlays.md +41 -0
  94. data/doc/tutorial/22_error_handling.md +43 -0
  95. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  96. data/doc/tutorial/24_mouse_events.md +43 -0
  97. data/doc/tutorial/25_resize_events.md +43 -0
  98. data/doc/tutorial/26_loading_states.md +42 -0
  99. data/doc/tutorial/27_performance.md +43 -0
  100. data/doc/tutorial/28_color_schemes.md +47 -0
  101. data/doc/tutorial/29_configuration.md +124 -0
  102. data/doc/tutorial/30_going_further.md +17 -0
  103. data/doc/tutorial/index.md +17 -0
  104. data/examples/app_file_browser/app.rb +40 -0
  105. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  106. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  107. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  108. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  112. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  113. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  114. data/examples/verify_website_first_app/app.rb +85 -0
  115. data/examples/verify_website_hello_mvu/app.rb +31 -0
  116. data/examples/widget_command_system/app.rb +15 -13
  117. data/exe/rooibos +10 -0
  118. data/generate_tutorial_stubs.rb +126 -0
  119. data/lib/rooibos/cli/commands/new.rb +373 -0
  120. data/lib/rooibos/cli/commands/run.rb +98 -0
  121. data/lib/rooibos/cli.rb +78 -0
  122. data/lib/rooibos/command/all.rb +76 -23
  123. data/lib/rooibos/command/batch.rb +61 -34
  124. data/lib/rooibos/command/custom.rb +84 -1
  125. data/lib/rooibos/command/http.rb +121 -55
  126. data/lib/rooibos/command/lifecycle.rb +5 -5
  127. data/lib/rooibos/command/open.rb +93 -0
  128. data/lib/rooibos/command/outlet.rb +105 -3
  129. data/lib/rooibos/command/wait.rb +9 -6
  130. data/lib/rooibos/command.rb +114 -89
  131. data/lib/rooibos/message/batch.rb +39 -0
  132. data/lib/rooibos/message/canceled.rb +51 -0
  133. data/lib/rooibos/message/error.rb +48 -0
  134. data/lib/rooibos/message/open.rb +30 -0
  135. data/lib/rooibos/message.rb +84 -4
  136. data/lib/rooibos/router.rb +11 -14
  137. data/lib/rooibos/runtime.rb +40 -43
  138. data/lib/rooibos/shortcuts.rb +47 -0
  139. data/lib/rooibos/test_helper.rb +71 -6
  140. data/lib/rooibos/version.rb +1 -1
  141. data/lib/rooibos/welcome.rb +237 -0
  142. data/lib/rooibos.rb +4 -3
  143. data/mise.toml +1 -1
  144. data/rbs_collection.lock.yaml +2 -2
  145. data/sig/concurrent.rbs +4 -0
  146. data/sig/gem.rbs +20 -0
  147. data/sig/rooibos/cli.rbs +42 -0
  148. data/sig/rooibos/command.rbs +59 -7
  149. data/sig/rooibos/message.rbs +66 -2
  150. data/sig/rooibos/shortcuts.rbs +14 -0
  151. data/sig/rooibos/test_helper.rbs +6 -2
  152. data/sig/rooibos/welcome.rbs +75 -0
  153. data/tasks/install.rake +29 -0
  154. data/tasks/resources/build.yml.erb +2 -0
  155. metadata +274 -38
  156. data/doc/concepts/application_architecture.md +0 -197
  157. data/doc/concepts/application_testing.md +0 -49
  158. data/doc/concepts/async_work.md +0 -164
  159. data/doc/concepts/commands.md +0 -530
  160. data/doc/concepts/message_processing.md +0 -51
  161. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  162. data/doc/contributors/WIP/implementation_plan.md +0 -409
  163. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  164. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  165. data/doc/contributors/WIP/task.md +0 -36
  166. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  167. data/doc/contributors/kit-no-outlet.md +0 -238
  168. data/doc/contributors/priorities.md +0 -38
  169. data/doc/images/.gitkeep +0 -0
  170. data/exe/.gitkeep +0 -0
  171. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -0,0 +1,93 @@
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
+ # <tt>open</tt> on macOS, <tt>xdg-open</tt> on Linux, <tt>start</tt> on Windows.
20
+ #
21
+ # On success (exit 0), sends <tt>Message::Open</tt>.
22
+ # On failure (non-zero), sends <tt>Message::Error</tt>.
23
+ #
24
+ # Prefer the <tt>Command.open</tt> factory method for convenience.
25
+ #
26
+ # === Example
27
+ #
28
+ # # Using the factory method (recommended)
29
+ # Command.open(model.selected_file)
30
+ # Command.open("https://rooibos.run")
31
+ #
32
+ # # Using the class directly
33
+ # Open.new(path: model.selected_file, envelope: model.selected_file)
34
+ #
35
+ # # Pattern-match on the response
36
+ # def update(msg, model)
37
+ # case msg
38
+ # in { type: :open, envelope: path }
39
+ # model.with(status: "Opened #{path}")
40
+ # in { type: :error, envelope: path }
41
+ # model.with(error: "Could not open #{path}")
42
+ # end
43
+ # end
44
+ class Open < Data.define(:path, :envelope)
45
+ include Custom
46
+
47
+ # System commands are generally fast; no grace period needed.
48
+ def rooibos_cancellation_grace_period = 0
49
+
50
+ # Executes the open command and sends the result message.
51
+ def call(out, token)
52
+ return if token.canceled?
53
+
54
+ require "open3"
55
+ cmd = self.class.__send__(:system_command, path)
56
+ _stdout, stderr, status = Open3.capture3(cmd)
57
+
58
+ message = if status.exitstatus == 0
59
+ Message::Open.new(envelope:)
60
+ else
61
+ error_msg = stderr.empty? ? "Failed to open: #{path}" : stderr.strip
62
+ Message::Error.new(
63
+ command: envelope,
64
+ exception: RuntimeError.new(error_msg.freeze).freeze
65
+ )
66
+ end
67
+
68
+ out.put(Ractor.make_shareable(message))
69
+ rescue => e
70
+ out.put(Ractor.make_shareable(Message::Error.new(
71
+ command: envelope,
72
+ exception: RuntimeError.new(e.message.freeze).freeze
73
+ )))
74
+ end
75
+
76
+ # Builds the platform-specific open command.
77
+ def self.system_command(path, platform = RUBY_PLATFORM) # :nodoc:
78
+ escaped = path.shellescape
79
+ case platform
80
+ when /darwin/
81
+ "open #{escaped}"
82
+ when /linux/
83
+ "xdg-open #{escaped}"
84
+ when /mingw|mswin|cygwin/
85
+ "start #{path}"
86
+ else
87
+ "xdg-open #{escaped}"
88
+ end
89
+ end
90
+ private_class_method :system_command
91
+ end
92
+ end
93
+ 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,11 +15,14 @@ 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
  #
23
+ # Prefer the <tt>Command.wait</tt> or <tt>Command.tick</tt> factory
24
+ # methods for convenience. Both are aliases for the same behavior.
25
+ #
23
26
  # === Example: Notification dismissal
24
27
  #
25
28
  # def update(msg, model)
@@ -28,7 +31,7 @@ module Rooibos
28
31
  # [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
29
32
  # in :dismiss
30
33
  # [model.with(notification: nil), nil]
31
- # in Command::Cancel
34
+ # in Message::Canceled
32
35
  # [model.with(notification: nil), nil] # User navigated away
33
36
  # end
34
37
  # end
@@ -44,7 +47,7 @@ module Rooibos
44
47
  # [model.with(frame:), Command.tick(0.1, :animate)]
45
48
  # end
46
49
  # end
47
- Wait = Data.define(:seconds, :envelope) do
50
+ class Wait < Data.define(:seconds, :envelope)
48
51
  include Custom
49
52
 
50
53
  # Cooperative cancellation needs no grace period.
@@ -57,7 +60,7 @@ module Rooibos
57
60
  # Executes the timer.
58
61
  #
59
62
  # Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
60
- # If cancelled, sends <tt>Command.cancel(self)</tt> instead.
63
+ # If canceled, sends <tt>Message::Canceled</tt> instead.
61
64
  #
62
65
  # [out] Outlet for sending messages.
63
66
  # [token] Cancellation token from the runtime.
@@ -68,7 +71,7 @@ module Rooibos
68
71
  combined.origin.wait
69
72
 
70
73
  if token.canceled?
71
- out.put(Command.cancel(self))
74
+ out.put(Message::Canceled.new(command: self))
72
75
  else
73
76
  elapsed = Time.now - start_time
74
77
  response = Message::Timer.new(envelope:, elapsed:)
@@ -13,6 +13,7 @@ require_relative "command/wait"
13
13
  require_relative "command/batch"
14
14
  require_relative "command/all"
15
15
  require_relative "command/http"
16
+ require_relative "command/open"
16
17
 
17
18
  module Rooibos
18
19
  # Commands represent side effects.
@@ -35,9 +36,21 @@ module Rooibos
35
36
  # # No side effect
36
37
  # [model, nil]
37
38
  module Command
38
- # Sentinel value for application termination.
39
+ # Terminates the application.
39
40
  #
40
- # The runtime detects this before dispatching. It breaks the loop immediately.
41
+ # Users press a key or click a button to quit. The update function returns
42
+ # a command, and the runtime executes it. Termination is special: the
43
+ # runtime detects this sentinel before dispatching and breaks the loop.
44
+ #
45
+ # Prefer the <tt>Command.exit</tt> factory method for convenience.
46
+ #
47
+ # === Example
48
+ #
49
+ # # Using the factory method (recommended)
50
+ # [model, Command.exit]
51
+ #
52
+ # # Using the class directly
53
+ # [model, Exit.new]
41
54
  class Exit < Data.define
42
55
  include Custom
43
56
 
@@ -67,11 +80,11 @@ module Rooibos
67
80
 
68
81
  # Creates a fresh cancellation that never fires.
69
82
  #
70
- # Some I/O operations cannot be cancelled mid-execution. Ruby's <tt>Net::HTTP</tt>
83
+ # Some I/O operations cannot be canceled mid-execution. Ruby's <tt>Net::HTTP</tt>
71
84
  # blocks until completion or timeout — there is no way to interrupt it.
72
85
  #
73
86
  # A shared singleton would be unsafe. If any code path accidentally resolves
74
- # the origin, all commands using it become cancelled.
87
+ # the origin, all commands using it become canceled.
75
88
  #
76
89
  # Use it for commands that wrap non-cancellable blocking I/O.
77
90
  #
@@ -84,16 +97,28 @@ module Rooibos
84
97
  cancellation
85
98
  end
86
99
 
87
- # Sentinel value for command cancellation.
100
+ # Cancels a running command.
101
+ #
102
+ # Long-running commands (WebSocket listeners, database pollers) run until
103
+ # stopped. Stopping them requires signaling from outside the command. The
104
+ # runtime tracks active commands by their object identity and routes cancel
105
+ # requests.
106
+ #
107
+ # This type carries the handle (command object) to cancel. The runtime
108
+ # pattern-matches on <tt>Command::Cancel</tt> and signals the token.
88
109
  #
89
- # Long-running commands (WebSocket listeners, database pollers) run until stopped.
90
- # Stopping them requires signaling from outside the command. The runtime tracks
91
- # active commands by their object identity and routes cancel requests.
110
+ # Prefer the <tt>Command.cancel</tt> factory method for convenience.
92
111
  #
93
- # This type carries the handle (command object) to cancel. The runtime pattern-matches
94
- # on <tt>Command::Cancel</tt> and signals the token.
112
+ # === Example
113
+ #
114
+ # # Using the factory method (recommended)
115
+ # [model, Command.cancel(model.active_fetch)]
116
+ #
117
+ # # Using the class directly
118
+ # [model, Cancel.new(handle: model.active_fetch)]
95
119
  class Cancel < Data.define(:handle)
96
120
  include Custom
121
+ include Message::Predicates
97
122
 
98
123
  # Stub - Cancel is a sentinel handled by runtime before dispatch.
99
124
  def call(_out, _token)
@@ -121,55 +146,6 @@ module Rooibos
121
146
  Cancel.new(handle:)
122
147
  end
123
148
 
124
- # Error message from a failed command.
125
- #
126
- # Commands run in background threads. Exceptions bubble up silently.
127
- # Your update function never sees them. Backtraces in STDERR corrupt the TUI.
128
- #
129
- # The runtime catches exceptions and wraps them in Error messages.
130
- # Pattern match on Error in your update function. Display the error, log it, or recover.
131
- #
132
- # Use it to surface failures from HTTP requests, file I/O, or external processes.
133
- #
134
- # === Examples
135
- #
136
- # Update = ->(message, model) {
137
- # case message
138
- # in Command::Error[command:, exception:]
139
- # # Show error toast
140
- # [model.with(error: exception.message), nil]
141
- # in Command::Error[command: Command::Http, exception:]
142
- # # Retry HTTP request
143
- # [model, command]
144
- # in Command::Error
145
- # # Log and continue
146
- # warn "Command failed: #{message.exception}"
147
- # [model, nil]
148
- # end
149
- # }
150
- class Error < Data.define(:command, :exception); end
151
-
152
- # Creates an error sentinel.
153
- #
154
- # The runtime produces this automatically when a command raises.
155
- # Use this factory for testing or for commands that want to signal
156
- # error completion without raising.
157
- #
158
- # [command] The command that failed.
159
- # [exception] The exception that was raised.
160
- #
161
- # === Example
162
- #
163
- # def update(message, model)
164
- # case message
165
- # in Command::Error(command:, exception:)
166
- # model.with(error: "#{command.class} failed: #{exception.message}")
167
- # end
168
- # end
169
- def self.error(command, exception)
170
- Error.new(command:, exception:)
171
- end
172
-
173
149
  # Runs a shell command and routes its output back as messages.
174
150
  #
175
151
  # Apps run external tools: linters, compilers, scripts, system utilities.
@@ -180,20 +156,29 @@ module Rooibos
180
156
  #
181
157
  # Use it to run builds, lint files, execute scripts, or invoke any CLI tool.
182
158
  #
159
+ # Prefer the <tt>Command.system</tt> factory method for convenience.
160
+ #
183
161
  # === Batch Mode (default)
184
162
  #
185
163
  # A single message arrives when the command finishes:
186
- # <tt>[tag, {stdout:, stderr:, status:}]</tt>
164
+ # <tt>Message::System::Batch</tt> with <tt>stdout</tt>, <tt>stderr</tt>, <tt>status</tt>.
187
165
  #
188
166
  # === Streaming Mode
189
167
  #
190
- # Messages arrive incrementally:
191
- # - <tt>[tag, :stdout, line]</tt> for each stdout line
192
- # - <tt>[tag, :stderr, line]</tt> for each stderr line
193
- # - <tt>[tag, :complete, {status:}]</tt> when the command finishes
194
- # - <tt>[tag, :error, {message:}]</tt> if the command cannot start
168
+ # <tt>Message::System::Stream</tt> messages arrive incrementally:
169
+ # <tt>stream: :stdout</tt>:: for each stdout chunk
170
+ # <tt>stream: :stderr</tt>:: for each stderr chunk
171
+ # <tt>stream: :complete</tt>:: when the command finishes
172
+ # <tt>stream: :error</tt>:: if the command cannot start
173
+ #
174
+ # === Example
175
+ #
176
+ # # Using the factory method (recommended)
177
+ # Command.system("ls -la", :got_files)
178
+ # Command.system("tail -f log.txt", :log, stream: true)
195
179
  #
196
- # The <tt>status</tt> is the integer exit code (0 = success).
180
+ # # Using the class directly
181
+ # System.new(command: "ls -la", envelope: :got_files, stream: false)
197
182
  class System < Data.define(:command, :envelope, :stream)
198
183
  include Custom
199
184
 
@@ -320,9 +305,9 @@ module Rooibos
320
305
  # # Then handle it later:
321
306
  # def update(message, model)
322
307
  # case message
323
- # in [:got_files, {stdout:, status: 0}]
308
+ # in { type: :system, envelope: :got_files, stdout:, status: 0 }
324
309
  # [model.with(files: stdout.lines), nil]
325
- # in [:got_files, {stderr:, status:}]
310
+ # in { type: :system, envelope: :got_files, stderr:, status: }
326
311
  # [model.with(error: stderr), nil]
327
312
  # end
328
313
  # end
@@ -335,14 +320,14 @@ module Rooibos
335
320
  # # Then handle incremental messages:
336
321
  # def update(message, model)
337
322
  # case message
338
- # in [:log, :stdout, line]
323
+ # in { type: :system, envelope: :log, stream: :stdout, content: line }
339
324
  # [model.with(lines: [*model.lines, line]), nil]
340
- # in [:log, :stderr, line]
325
+ # in { type: :system, envelope: :log, stream: :stderr, content: line }
341
326
  # [model.with(errors: [*model.errors, line]), nil]
342
- # in [:log, :complete, {status:}]
327
+ # in { type: :system, envelope: :log, stream: :complete, status: }
343
328
  # [model.with(loading: false, exit_status: status), nil]
344
- # in [:log, :error, {message:}]
345
- # [model.with(loading: false, error: message), nil]
329
+ # in { type: :system, envelope: :log, stream: :error, content: msg }
330
+ # [model.with(loading: false, error: msg), nil]
346
331
  # end
347
332
  # end
348
333
  def self.system(command, envelope, stream: false)
@@ -361,31 +346,48 @@ module Rooibos
361
346
  # adds its routing prefix. Clean separation. No coupling.
362
347
  #
363
348
  # Use it to compose child fragments that return their own commands.
349
+ #
350
+ # Prefer the <tt>Command.map</tt> factory method for convenience.
351
+ #
352
+ # === Example
353
+ #
354
+ # # Using the factory method (recommended)
355
+ # Command.map(child_command) { |msg| [:sidebar, msg] }
356
+ #
357
+ # # Using the class directly
358
+ # Mapped.new(inner_command: child_command, mapper: ->(msg) { [:sidebar, msg] })
364
359
  class Mapped < Data.define(:inner_command, :mapper)
365
360
  include Custom
366
361
 
362
+ DONE = Object.new.freeze
363
+ private_constant :DONE
364
+
367
365
  # Grace period delegates to inner command.
368
366
  def rooibos_cancellation_grace_period
369
367
  inner_command.respond_to?(:rooibos_cancellation_grace_period) ?
370
368
  inner_command.rooibos_cancellation_grace_period : 0.1
371
369
  end
372
370
 
373
- # Executes the inner command, waits for result, and transforms it.
371
+ # Executes the inner command and transforms each message.
374
372
  def call(out, token)
375
373
  inner_channel = Concurrent::Promises::Channel.new
376
374
  inner_outlet = Outlet.new(inner_channel, lifecycle: out.live)
377
375
 
378
- # Dispatch inner command
379
- if inner_command.respond_to?(:call)
380
- inner_command.call(inner_outlet, token)
381
- else
382
- raise ArgumentError, "Inner command must respond to #call"
376
+ Concurrent::Promises.future do
377
+ if inner_command.respond_to?(:call)
378
+ inner_command.call(inner_outlet, token)
379
+ else
380
+ raise ArgumentError, "Inner command must respond to #call"
381
+ end
382
+ inner_channel.push(DONE)
383
383
  end
384
384
 
385
- # Transform result and send
386
- inner_message = inner_channel.pop
387
- transformed = mapper.call(inner_message)
388
- out.put(*transformed)
385
+ loop do
386
+ msg = inner_channel.pop
387
+ break if msg.equal?(DONE)
388
+ transformed = mapper.call(msg)
389
+ out.put(*transformed) if transformed
390
+ end
389
391
  end
390
392
  end
391
393
 
@@ -405,8 +407,14 @@ module Rooibos
405
407
  #
406
408
  # # Parent wraps to route as [:sidebar, :got_files, {...}]
407
409
  # parent_command = Command.map(child_command) { |child_result| [:sidebar, *child_result] }
408
- def self.map(inner_command, &mapper)
409
- Mapped.new(inner_command:, mapper:)
410
+ def self.map(inner_command, mapper = nil, &block)
411
+ if mapper && block
412
+ raise ArgumentError, "Pass either a mapper callable or a block, not both"
413
+ end
414
+ unless mapper || block
415
+ raise ArgumentError, "Pass a mapper callable or a block"
416
+ end
417
+ Mapped.new(inner_command:, mapper: mapper || block)
410
418
  end
411
419
 
412
420
  # Gives a callable unique identity for cancellation.
@@ -529,15 +537,32 @@ module Rooibos
529
537
  Http.new(*, **)
530
538
  end
531
539
 
532
- # :nodoc:
533
- class Wrapped < Data.define(:callable, :grace_period)
540
+ # Opens a file or URL with the system's default application.
541
+ # Cross-platform: uses +open+ on macOS, +xdg-open+ on Linux, +start+ on Windows.
542
+ #
543
+ # On success (exit 0), sends +Message::Open+.
544
+ # On failure (non-zero), sends +Message::Error+.
545
+ #
546
+ # === Example
547
+ #
548
+ # case message
549
+ # in { type: :open, envelope: path }
550
+ # model.with(status: "Opened #{path}")
551
+ # in { type: :error, envelope: path }
552
+ # model.with(error: "Could not open #{path}")
553
+ # end
554
+ #
555
+ def self.open(path, envelope = path)
556
+ Open.new(path:, envelope:)
557
+ end
558
+
559
+ class Wrapped < Data.define(:callable, :grace_period) # :nodoc:
534
560
  include Custom
535
561
  def rooibos_cancellation_grace_period
536
562
  grace_period || super
537
563
  end
538
564
 
539
- # :nodoc:
540
- def call(out, token)
565
+ def call(out, token) # :nodoc:
541
566
  callable.call(out, token)
542
567
  end
543
568
  end
@@ -0,0 +1,39 @@
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
+ module Rooibos
9
+ module Message
10
+ # Completion sentinel from Command.batch.
11
+ #
12
+ # Batch commands stream child messages live. When all children finish,
13
+ # this message signals completion. Use it to trigger follow-up actions
14
+ # or UI updates after parallel work completes.
15
+ #
16
+ # === Example
17
+ #
18
+ # case msg
19
+ # in Message::Batch
20
+ # out.put(:all_done)
21
+ # in [:progress, pct]
22
+ # model.with(progress: pct)
23
+ # end
24
+ #
25
+ Batch = Data.define(:command) do
26
+ include Predicates
27
+
28
+ # Returns <tt>true</tt> for batch completion messages.
29
+ def batch?
30
+ true
31
+ end
32
+
33
+ # Deconstructs for pattern matching.
34
+ def deconstruct_keys(_keys)
35
+ { type: :batch, command: }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,51 @@
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
+ module Rooibos
9
+ module Message
10
+ # Cancellation notification from a canceled command.
11
+ #
12
+ # Long-running commands respond to cancellation cooperatively. When the
13
+ # runtime signals cancellation, the command finishes current work and sends
14
+ # this message.
15
+ #
16
+ # Pattern match on Canceled in your update function to clean up state,
17
+ # stop animations, or acknowledge the cancellation.
18
+ #
19
+ # Use it to handle timer cancellations, aborted HTTP requests, or
20
+ # stopped background processes.
21
+ #
22
+ # === Example
23
+ #
24
+ # Update = ->(message, model) {
25
+ # case message
26
+ # in { type: :canceled, command: }
27
+ # # Timer was canceled, clear the notification
28
+ # model.with(notification: nil)
29
+ # in Message::Canceled
30
+ # # Generic cancellation handling
31
+ # model
32
+ # end
33
+ # }
34
+ Canceled = Data.define(:command) do
35
+ include Predicates
36
+
37
+ # Returns <tt>true</tt> for cancellation messages.
38
+ def canceled?
39
+ true
40
+ end
41
+ alias_method :cancelled?, :canceled?
42
+
43
+ # Deconstructs for pattern matching.
44
+ #
45
+ # Returns a hash with <tt>type</tt> and <tt>command</tt>.
46
+ def deconstruct_keys(_keys)
47
+ { type: :canceled, command: }
48
+ end
49
+ end
50
+ end
51
+ end