ratatui_ruby-tea 0.3.1 → 0.4.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +42 -2
  3. data/CHANGELOG.md +76 -0
  4. data/README.md +8 -5
  5. data/doc/concepts/async_work.md +164 -0
  6. data/doc/concepts/commands.md +528 -0
  7. data/doc/concepts/message_processing.md +51 -0
  8. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  9. data/doc/contributors/WIP/implementation_plan.md +405 -0
  10. data/doc/contributors/WIP/init_callable_proposal.md +341 -0
  11. data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -0
  12. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  13. data/doc/contributors/WIP/task.md +36 -0
  14. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  15. data/doc/contributors/design/commands_and_outlets.md +11 -1
  16. data/doc/contributors/priorities.md +22 -24
  17. data/examples/app_fractal_dashboard/app.rb +3 -7
  18. data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
  19. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
  20. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
  21. data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
  22. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
  23. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  24. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
  25. data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
  26. data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
  27. data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
  28. data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
  29. data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
  30. data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
  31. data/examples/verify_readme_usage/README.md +7 -4
  32. data/examples/verify_readme_usage/app.rb +7 -4
  33. data/lib/ratatui_ruby/tea/command/all.rb +71 -0
  34. data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
  35. data/lib/ratatui_ruby/tea/command/custom.rb +1 -1
  36. data/lib/ratatui_ruby/tea/command/http.rb +194 -0
  37. data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
  38. data/lib/ratatui_ruby/tea/command/outlet.rb +59 -27
  39. data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
  40. data/lib/ratatui_ruby/tea/command.rb +245 -64
  41. data/lib/ratatui_ruby/tea/message/all.rb +47 -0
  42. data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
  43. data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
  44. data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
  45. data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
  46. data/lib/ratatui_ruby/tea/message.rb +40 -0
  47. data/lib/ratatui_ruby/tea/router.rb +11 -11
  48. data/lib/ratatui_ruby/tea/runtime.rb +320 -185
  49. data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
  50. data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
  51. data/lib/ratatui_ruby/tea/version.rb +1 -1
  52. data/lib/ratatui_ruby/tea.rb +44 -10
  53. data/rbs_collection.lock.yaml +1 -17
  54. data/sig/concurrent.rbs +72 -0
  55. data/sig/ratatui_ruby/tea/command.rbs +141 -37
  56. data/sig/ratatui_ruby/tea/message.rbs +123 -0
  57. data/sig/ratatui_ruby/tea/router.rbs +1 -1
  58. data/sig/ratatui_ruby/tea/runtime.rbs +39 -6
  59. data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
  60. data/sig/ratatui_ruby/tea.rbs +24 -4
  61. metadata +63 -11
  62. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
  63. data/lib/ratatui_ruby/tea/command/cancellation_token.rb +0 -135
@@ -6,6 +6,7 @@
6
6
  #++
7
7
 
8
8
  require "ratatui_ruby"
9
+ require "concurrent-edge"
9
10
 
10
11
  module RatatuiRuby
11
12
  module Tea
@@ -38,224 +39,358 @@ module RatatuiRuby
38
39
  #
39
40
  # Runs until the update function returns a <tt>Command.exit</tt> command.
40
41
  #
41
- # [model] Initial application state (immutable).
42
- # [view] Callable receiving <tt>(model, tui)</tt>, returns a widget.
43
- # [update] Callable receiving <tt>(message, model)</tt>, returns <tt>[new_model, command]</tt> or just <tt>new_model</tt>.
44
- # [init] Optional callable to run at startup. Returns a message for update.
45
- def self.run(model:, view:, update:, init: nil)
46
- validate_ractor_shareable!(model, "model")
47
-
48
- # Execute init command synchronously if provided
49
- if init
50
- init_message = init.call
51
- result = update.call(init_message, model)
52
- model, _command = normalize_update_result(result, model)
53
- validate_ractor_shareable!(model, "model")
54
- end
42
+ # == Root Fragment with Init
43
+ #
44
+ # Pass a fragment module with <tt>Init</tt>, <tt>Update</tt>, and <tt>View</tt> constants.
45
+ # This allows your application to do work at startup, and your Update will be called with the result.
46
+ # It also allows you to create a more complex model with access to <tt>ARGV</tt> and <tt>ENV</tt>.
47
+ #
48
+ # module MyApp
49
+ # # Init is any callable, and returns an immutable Model and/or Command, according to your application's needs.
50
+ # # The Model is your application's initial state, and the Command is any command to run at startup.
51
+ # Init = -> () {
52
+ # # To do work at startup:
53
+ # return [Data.define(:count).new(count: 0), Command.http("https://api.example.com/data")]
54
+ # # To start idle:
55
+ # return Data.define(:count).new(count: 0)
56
+ # }
57
+ #
58
+ # # Update has access to a single Message, and your fragment's latest immutable Model.
59
+ # # It returns a new Model and/or a Command, according to your application's needs.
60
+ # Update = ->(message, model) {
61
+ # [model, Command.exit]
62
+ # }
63
+ #
64
+ # # View has access to your fragment's latest immutable Model, and a RatatuiRuby::TUI.
65
+ # # It returns a tree of RatatuiRuby::Widget and/or Custom Widgets.
66
+ # View = ->(model, tui) {
67
+ # tui.paragraph(text: model.count.to_s)
68
+ # }
69
+ # end
70
+ #
71
+ # Tea.run(MyApp)
72
+ #
73
+ # == Root Fragment with auto-Init
74
+ #
75
+ # Pass a fragment module with a <tt>Model</tt> class and <tt>Update</tt> and <tt>View</tt> constants.
76
+ # Your application will be idle until a RatatuiRuby::Event message is sent to your Update.
77
+ #
78
+ # module MyApp
79
+ # # Model is anything that responds to <tt>new</tt>.
80
+ # Model = Data.define(:count).new(count: 0)
81
+ #
82
+ # # Update has access to a single Message, and your fragment's latest immutable Model.
83
+ # # It returns a new Model and/or a Command, according to your application's needs.
84
+ # Update = ->(message, model) {
85
+ # [model, Command.exit]
86
+ # }
87
+ #
88
+ # # View has access to your fragment's latest immutable Model, and a RatatuiRuby::TUI.
89
+ # # It returns a tree of RatatuiRuby::Widget and/or Custom Widgets.
90
+ # View = ->(model, tui) {
91
+ # tui.paragraph(text: model.count.to_s)
92
+ # }
93
+ # end
94
+ #
95
+ # Tea.run(MyApp)
96
+ #
97
+ # == Explicit Parameters API
98
+ #
99
+ # A root fragment is not required. You can pass individual parameters:
100
+ #
101
+ # Tea.run(
102
+ # model: MyApp::Model.new(count: 0),
103
+ # view: MyApp::View,
104
+ # update: MyApp::Update,
105
+ # command: Command.http("https://api.example.com/data")
106
+ # )
107
+ #
108
+ # == Parameters
109
+ #
110
+ # [root_fragment] Module with Model, Init, Update, View constants. *Mutually exclusive with model/view/update.*
111
+ # [fps] Target frames per second for the application. Higher values feel more responsive, but may spike CPU usage.
112
+ # [model] Initial application state (immutable). *Required if fragment not provided.*
113
+ # [view] Callable receiving <tt>(model, tui)</tt>, returns a widget. *Required if fragment not provided.*
114
+ # [update] Callable receiving <tt>(message, model)</tt>, returns <tt>[new_model, command]</tt> or just <tt>new_model</tt>. *Required if fragment not provided.*
115
+ # [command] Optional callable to run at startup. Returns a message for update.
116
+ #
117
+ # == Raises
118
+ #
119
+ # [RatatuiRuby::Error::Invariant] If both fragment and any of (model, view, update, command) are provided.
120
+ def self.run(root_fragment = nil, fps: 60, model: nil, view: nil, update: nil, command: nil)
121
+ @fragment = fragment_from_kwargs(root_fragment, model:, view:, update:, command:)
122
+ @view = @fragment::View
123
+ @update = @fragment::Update
124
+ @model, @command = init_callable.call
125
+ @timeout = 1 / fps
55
126
 
56
- queue = Queue.new
57
- pending_threads = [] #: Array[Thread]
58
- active_commands = {} #: Hash[Command::_Command, active_entry]
59
-
60
- catch(:quit) do
61
- RatatuiRuby.run do |tui|
62
- loop do
63
- tui.draw do |frame|
64
- widget = view.call(model, tui)
65
- validate_view_result!(widget)
66
- frame.render_widget(widget, frame.area)
67
- end
127
+ # commands do significant work, so they run off the main thread
128
+ validate_ractor_shareable!(@command, "command")
129
+ # models get passed to and from commands on other threads
130
+ validate_ractor_shareable!(@model, "model")
131
+ # views and updates run on the main thread, so they don't need to be shareable
68
132
 
69
- # 1. Handle user input (blocks up to 16ms)
70
- message = tui.poll_event
133
+ start_runtime
134
+ end
71
135
 
72
- # If provided, handle the event
73
- unless message.is_a?(RatatuiRuby::Event::None)
74
- result = update.call(message, model)
75
- model, command = normalize_update_result(result, model)
76
- validate_ractor_shareable!(model, "model")
77
- throw :quit if command.is_a?(Command::Exit)
136
+ # Normalizes Init callable return value to <tt>[model, command]</tt> tuple.
137
+ #
138
+ # Init callables return initial state and optional startup command. They can use
139
+ # DWIM (Do What I Mean) syntax: return just a model, just a command, or a full tuple.
140
+ #
141
+ # This method handles all formats. Use it when composing child fragment Inits.
142
+ #
143
+ # [result] The Init return value (model, command, or <tt>[model, command]</tt> tuple).
144
+ #
145
+ # === Examples
146
+ #
147
+ #--
148
+ # SPDX-SnippetBegin
149
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
150
+ # SPDX-License-Identifier: MIT-0
151
+ #++
152
+ # # Just model
153
+ # model, cmd = Tea.normalize_init(Model.new(...))
154
+ # # => [Model.new(...), nil]
155
+ #
156
+ # # Just command
157
+ # model, cmd = Tea.normalize_init(Command.http(...))
158
+ # # => [nil, Command.http(...)]
159
+ #
160
+ # # Tuple (already normalized)
161
+ # model, cmd = Tea.normalize_init([Model.new(...), Command.http(...)])
162
+ # # => [Model.new(...), Command.http(...)]
163
+ #--
164
+ # SPDX-SnippetEnd
165
+ #++
166
+ def self.normalize_init(result)
167
+ normalize_update_return(result, nil)
168
+ end
78
169
 
79
- thread = dispatch(command, queue, active_commands) if command
80
- pending_threads << thread if thread
81
- end
170
+ # Sentinel value avoids accidentally quitting from application exceptions.
171
+ QUIT = Object.new.freeze
82
172
 
83
- # 2. Check for synthetic events (Sync)
84
- # This comes AFTER poll_event so Sync waits for commands dispatched
85
- # by the preceding event (e.g., inject_key("a"); inject_sync)
86
- if RatatuiRuby::SyntheticEvents.pending?
87
- synthetic = RatatuiRuby::SyntheticEvents.pop
88
- if synthetic&.sync?
89
- # Wait for all pending threads to complete
90
- pending_threads.each(&:join)
91
- pending_threads.clear
92
-
93
- # Yield to ensure any final queue writes are visible
94
- Thread.pass
95
-
96
- # Process all pending queue items
97
- until queue.empty?
98
- begin
99
- background_message = queue.pop(true)
100
- result = update.call(background_message, model)
101
- model, command = normalize_update_result(result, model)
102
- validate_ractor_shareable!(model, "model")
103
- throw :quit if command.is_a?(Command::Exit)
104
-
105
- thread = dispatch(command, queue, active_commands) if command
106
- pending_threads << thread if thread
107
- rescue ThreadError
108
- break
109
- end
110
- end
111
- end
112
- end
173
+ class << self
174
+ private def start_runtime
175
+ @message_queue = Concurrent::Promises::Channel.new
176
+ @pending_futures = [] #: Array[Concurrent::Promises::Future[void]]
177
+ @lifecycle = Command::Lifecycle.new
113
178
 
114
- # 3. Check for background outcomes (non-blocking)
115
- until queue.empty?
116
- begin
117
- background_message = queue.pop(true)
118
- result = update.call(background_message, model)
119
- model, command = normalize_update_result(result, model)
120
- validate_ractor_shareable!(model, "model")
121
- throw :quit if command.is_a?(Command::Exit)
122
-
123
- thread = dispatch(command, queue, active_commands) if command
124
- pending_threads << thread if thread
125
- rescue ThreadError
126
- break
127
- end
179
+ catch(QUIT) do
180
+ dispatch_command
181
+ RatatuiRuby.run do |tui|
182
+ @tui = tui
183
+ loop do
184
+ draw_view
185
+ handle_ratatui_event
186
+ handle_sync
187
+ send_pending_messages
128
188
  end
129
189
  end
130
190
  end
191
+
192
+ # Shutdown: signal all, wait grace periods (cooperative cancellation)
193
+ @lifecycle.shutdown
194
+
195
+ # Process any final messages from completed commands
196
+ send_pending_messages(dispatch: false)
197
+
198
+ @model
199
+ end
200
+
201
+ private def draw_view
202
+ @tui.draw do |frame|
203
+ widget = @view.call(@model, @tui)
204
+ validate_view_return!(widget)
205
+ frame.render_widget(widget, frame.area)
206
+ end
131
207
  end
132
208
 
133
- # Shutdown: signal all, wait grace periods, then kill
134
- active_commands.each do |handle, entry|
135
- entry[:token].cancel!
136
- grace = handle.tea_cancellation_grace_period
137
- if grace.finite?
138
- deadline = Time.now + grace
139
- sleep 0.02 while entry[:thread].alive? && Time.now < deadline
140
- entry[:thread].kill if entry[:thread].alive?
209
+ # Enforces invariants
210
+ private def fragment_from_kwargs(root_fragment, model: nil, view: nil, update: nil, command: nil)
211
+ if root_fragment
212
+ fragment_invariant!("model") if model
213
+ fragment_invariant!("view") if view
214
+ fragment_invariant!("update") if update
215
+ fragment_invariant!("command") if command
216
+ root_fragment
141
217
  else
142
- entry[:thread].join
218
+ fragment = Module.new
219
+ fragment.const_set(:Model, model)
220
+ fragment.const_set(:View, view)
221
+ fragment.const_set(:Update, update)
222
+ fragment.const_set(:Init, -> { [model, command] })
223
+ fragment
143
224
  end
144
225
  end
145
226
 
146
- # Process any final messages from completed commands
147
- until queue.empty?
148
- begin
149
- background_message = queue.pop(true)
150
- result = update.call(background_message, model)
151
- model, = normalize_update_result(result, model)
152
- rescue ThreadError
153
- break
227
+ # Helps app developers understand invariants
228
+ private def fragment_invariant!(param)
229
+ raise RatatuiRuby::Error::Invariant, "Cannot provide both fragment: and #{param}: parameters. Use fragment-first API (fragment:) OR explicit parameters (model:, view:, update:, command:), not both."
230
+ end
231
+
232
+ private def init_callable
233
+ if @fragment.const_defined?(:Init)
234
+ if @fragment::Init.respond_to?(:call)
235
+ if Proc === @fragment::Init or Method === @fragment::Init
236
+ @fragment::Init
237
+ else
238
+ @fragment::Init.method(:call)
239
+ end
240
+ else
241
+ raise RatatuiRuby::Error::Invariant, "Fragment::Init must respond to :call"
242
+ end
243
+ else
244
+ if @fragment.const_defined?(:Model)
245
+ if @fragment::Model.respond_to?(:new)
246
+ -> { @fragment::Model.new }
247
+ else
248
+ raise RatatuiRuby::Error::Invariant, "Fragment::Model must respond to :new; or pass Fragment::Init instead"
249
+ end
250
+ else
251
+ raise RatatuiRuby::Error::Invariant, "Fragment must define a Model class or an Init callable"
252
+ end
154
253
  end
155
254
  end
156
255
 
157
- model
158
- end
256
+ private
159
257
 
160
- # Validates the view returned a widget.
161
- #
162
- # Views return widget trees. Returning +nil+ is a bug—you forgot to
163
- # return something. For an intentionally empty screen, use TUI#clear.
164
- private_class_method def self.validate_view_result!(widget)
165
- return unless widget.nil?
258
+ # Validates the view returned a widget.
259
+ #
260
+ # Views return widget trees. Returning +nil+ is a bug—you forgot to
261
+ # return something. For an intentionally empty screen, use TUI#clear.
262
+ private def validate_view_return!(widget)
263
+ return unless widget.nil?
166
264
 
167
- raise RatatuiRuby::Error::Invariant,
168
- "View returned nil. Return a widget, or use TUI#clear for an empty screen."
169
- end
265
+ raise RatatuiRuby::Error::Invariant,
266
+ "View returned nil. Return a widget, or use TUI#clear for an empty screen."
267
+ end
170
268
 
171
- # Extracts [model, command] from update result.
172
- #
173
- # Uses is_a? checks for type narrowing. The result parameter is untyped
174
- # because the method performs runtime type detection.
175
- #
176
- # @param result [Array, Command::execution, Object] The update result
177
- # @param previous_model [Model] Fallback model if result is a command
178
- # @return [Array(Object, Command::execution?)] The [model, command] tuple
179
- private_class_method def self.normalize_update_result(result, previous_model)
180
- # Case 0: Nil result - preserve previous model
181
- return [previous_model, nil] if result.nil?
182
-
183
- # Case 1: Already a [model, command] tuple
184
- if result.is_a?(Array) && (result.size == 2)
185
- model = result[0]
186
- command = result[1]
187
- # Verify the second element is a valid command (nil, built-in, or custom)
188
- if command.nil? ||
189
- command.class.name&.start_with?("RatatuiRuby::Tea::Command::") ||
190
- (command.respond_to?(:tea_command?) && command.tea_command?)
191
-
192
- return [model, command]
269
+ # Extracts [model, command] from Update return value.
270
+ private def normalize_update_return(result, previous_model)
271
+ # Case 0: Nil result - preserve previous model
272
+ return [previous_model, nil] if result.nil?
273
+
274
+ # Case 1: Already a [model, command] tuple
275
+ if result.is_a?(Array) && (result.size == 2)
276
+ model, command = result
277
+ # Verify the second element is a valid command
278
+ if command.nil? ||
279
+ (command.respond_to?(:tea_command?) && command.tea_command?)
280
+
281
+ return [model, command]
282
+ end
283
+
284
+ # Debug-mode heuristic: warn about suspicious command-like objects
285
+ if RatatuiRuby::Debug.enabled? &&
286
+ command.respond_to?(:call) &&
287
+ !command.respond_to?(:tea_command?) &&
288
+ !Ractor.shareable?(result)
289
+
290
+ warn "WARNING: Update returned [model, #{command.class}] but #{command.class} " \
291
+ "responds to #call without #tea_command?. Did you forget to include Command::Custom? " \
292
+ "The tuple will be treated as the model, not as [model, command]. " \
293
+ "To suppress this warning if the array is your model, use Ractor.make_shareable on it. " \
294
+ "(#{caller.first})"
295
+ end
296
+
297
+ end
298
+
299
+ # Case 2: Result is a Command - use previous model
300
+ if result.respond_to?(:tea_command?) && result.tea_command?
301
+ command = result #: RatatuiRuby::Tea::Command::execution
302
+ return [previous_model, command]
193
303
  end
304
+
305
+ # Case 3: Result is the new model
306
+ [result, nil]
194
307
  end
195
308
 
196
- # Case 2: Result is a Command - use previous model
197
- if result.class.name&.start_with?("RatatuiRuby::Tea::Command::")
198
- return [previous_model, result]
309
+ # Validates an object is Ractor-shareable (deeply frozen).
310
+ #
311
+ # Models and messages must be shareable for future Ractor support.
312
+ # Mutable objects cause race conditions. Freeze your data.
313
+ #
314
+ # Only enforced in debug mode (and tests). Production skips this check
315
+ # for performance; mutable objects will still cause bugs, but silently.
316
+ private def validate_ractor_shareable!(object, name)
317
+ return unless RatatuiRuby::Debug.enabled?
318
+ return if Ractor.shareable?(object)
319
+
320
+ raise RatatuiRuby::Error::Invariant,
321
+ "#{name.capitalize} is not Ractor-shareable. Use Ractor.make_shareable or Object#freeze."
199
322
  end
200
- if result.respond_to?(:tea_command?) && result.tea_command?
201
- return [previous_model, result]
323
+
324
+ private def handle_ratatui_event
325
+ message = @tui.poll_event(timeout: @timeout)
326
+ return if message.none?
327
+
328
+ @model, @command = normalize_update_return(@update.call(message, @model), @model)
329
+ validate_ractor_shareable!(@model, "model")
330
+ throw QUIT if Command::Exit === @command
331
+ dispatch_command
202
332
  end
203
333
 
204
- # Case 3: Result is the new model
205
- [result, nil]
206
- end
334
+ # This must come *after* handle_ratatui_event so Sync waits for commands
335
+ # dispatched by the preceding event. For example, in a test:
336
+ #
337
+ # inject_key("a")
338
+ # inject_sync
339
+ #
340
+ # We need <kbd>a</kbd> to call the Update and queue its message before
341
+ # processing the Sync.
342
+ private def handle_sync
343
+ if RatatuiRuby::SyntheticEvents.pending?
344
+ synthetic = RatatuiRuby::SyntheticEvents.pop
345
+ if synthetic&.sync?
346
+ # Wait for all pending futures to complete
347
+ @pending_futures.each(&:wait)
348
+ @pending_futures.clear
207
349
 
208
- # Validates an object is Ractor-shareable (deeply frozen).
209
- #
210
- # Models and messages must be shareable for future Ractor support.
211
- # Mutable objects cause race conditions. Freeze your data.
212
- #
213
- # Only enforced in debug mode (and tests). Production skips this check
214
- # for performance; mutable objects will still cause bugs, but silently.
215
- private_class_method def self.validate_ractor_shareable!(object, name)
216
- return unless RatatuiRuby::Debug.enabled?
217
- return if Ractor.shareable?(object)
218
-
219
- raise RatatuiRuby::Error::Invariant,
220
- "#{name.capitalize} is not Ractor-shareable. Use Ractor.make_shareable or Object#freeze."
221
- end
350
+ # Yield to ensure any final queue writes are visible
351
+ Thread.pass
222
352
 
223
- # Dispatches a command asynchronously. :nodoc:
224
- #
225
- # Spawns a background thread and pushes results to the message queue.
226
- # See Command.system for message formats.
227
- private_class_method def self.dispatch(command, queue, active_commands = {})
228
- case command
229
- when Command::Cancel
230
- entry = active_commands[command.handle]
231
- if entry && entry[:thread].alive?
232
- entry[:token].cancel!
233
- grace = command.handle.tea_cancellation_grace_period
234
- if grace.finite?
235
- deadline = Time.now + grace
236
- sleep 0.02 while entry[:thread].alive? && Time.now < deadline
237
- entry[:thread].kill if entry[:thread].alive?
238
- else
239
- entry[:thread].join
353
+ # Process all pending message queue items
354
+ send_pending_messages
240
355
  end
241
356
  end
242
- active_commands.delete(command.handle)
243
- nil
244
- else
245
- # Custom command (responds to tea_command?)
246
- if command.respond_to?(:tea_command?) && command.tea_command?
247
- token = Command::CancellationToken.new
248
- outlet = Command::Outlet.new(queue)
249
-
250
- thread = Thread.new do
251
- command.call(outlet, token)
252
- rescue => e
253
- queue << Command::Error.new(command:, exception: e)
254
- end
357
+ end
358
+
359
+ QUEUE_EMPTY = Object.new.freeze
360
+ private_constant :QUEUE_EMPTY
361
+
362
+ private def send_pending_messages(dispatch: true)
363
+ loop do
364
+ background_message = @message_queue.try_pop(QUEUE_EMPTY)
365
+ break if background_message == QUEUE_EMPTY
366
+
367
+ result = @update.call(background_message, @model)
368
+ @model, @command = normalize_update_return(result, @model)
369
+ return unless dispatch
255
370
 
256
- active_commands[command] = { thread:, token: }
257
- thread
371
+ validate_ractor_shareable!(@model, "model")
372
+ throw QUIT if Command::Exit === @command
373
+
374
+ dispatch_command
375
+ end
376
+ end
377
+
378
+ # Spawns a future and pushes results to the message queue.
379
+ # See Command.system for message formats.
380
+ private def dispatch_command
381
+ future = if @command.nil?
382
+ nil
383
+ elsif Command::Cancel === @command
384
+ @lifecycle.cancel(@command.handle)
385
+ nil
386
+ elsif @command.respond_to?(:tea_command?) && @command.tea_command?
387
+ entry = @lifecycle.run_async(@command, @message_queue)
388
+ entry.future
389
+ else
390
+ raise RatatuiRuby::Error::Invariant,
391
+ "#{@command.inspect} is not a valid Tea command."
258
392
  end
393
+ @pending_futures << future if future
259
394
  end
260
395
  end
261
396
  end
@@ -36,8 +36,8 @@ module RatatuiRuby
36
36
 
37
37
  # Creates a shell execution command.
38
38
  # Short alias for +Command.system+.
39
- def self.sh(command, tag)
40
- Command.system(command, tag)
39
+ def self.sh(command, envelope)
40
+ Command.system(command, envelope)
41
41
  end
42
42
 
43
43
  # Creates a mapped command.
@@ -0,0 +1,58 @@
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 "ratatui_ruby/test_helper"
9
+
10
+ module RatatuiRuby
11
+ module Tea
12
+ # Test helpers for Tea command validation.
13
+ #
14
+ # This module extends RatatuiRuby::TestHelper with Tea-specific assertions
15
+ # for verifying custom commands implement the proper protocol.
16
+ module TestHelper
17
+ # Validates a command implements the Tea command protocol.
18
+ #
19
+ # Custom commands run in background threads. They dispatch work and send messages.
20
+ # Forgetting to include \<tt>Command::Custom\</tt> breaks dispatch. The runtime
21
+ # treats \<tt>[model, bad_command]\</tt> as a model, not a tuple. Tests fail with
22
+ # confusing Ractor shareability errors.
23
+ #
24
+ # This method checks the protocol. Call it in tests to catch mistakes early.
25
+ #
26
+ # [command] The command object to validate.
27
+ #
28
+ # === Example
29
+ #
30
+ # def test_websocket_command_protocol
31
+ # cmd = WebSocketCommand.new("wss://example.com")
32
+ # validate_tea_command!(cmd)
33
+ # end
34
+ def validate_tea_command!(command)
35
+ unless command.respond_to?(:tea_command?)
36
+ raise RatatuiRuby::Error::Invariant,
37
+ "#{command.class} does not respond to #tea_command?. " \
38
+ "Include Command::Custom or implement the tea_command? predicate."
39
+ end
40
+
41
+ unless command.respond_to?(:call)
42
+ raise RatatuiRuby::Error::Invariant,
43
+ "#{command.class} does not respond to #call. " \
44
+ "Implement call(out, token) to execute the command."
45
+ end
46
+
47
+ unless command.respond_to?(:tea_cancellation_grace_period)
48
+ raise RatatuiRuby::Error::Invariant,
49
+ "#{command.class} does not respond to #tea_cancellation_grace_period. " \
50
+ "Include Command::Custom or implement this method."
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # Attach Tea test helpers to RatatuiRuby::TestHelper
58
+ RatatuiRuby::TestHelper.include(RatatuiRuby::Tea::TestHelper)
@@ -9,6 +9,6 @@ module RatatuiRuby # :nodoc: Documented in the ratatui_ruby gem.
9
9
  module Tea
10
10
  # The version of this gem.
11
11
  # See https://semver.org/spec/v2.0.0.html
12
- VERSION = "0.3.1"
12
+ VERSION = "0.4.0"
13
13
  end
14
14
  end