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
@@ -13,9 +13,12 @@ require "ratatui_ruby/tea"
13
13
  class VerifyReadmeUsage
14
14
  # [SYNC:START:mvu]
15
15
  Model = Data.define(:text)
16
- MODEL = Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
17
16
 
18
- VIEW = -> (model, tui) do
17
+ Init = -> do
18
+ Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
19
+ end
20
+
21
+ View = -> (model, tui) do
19
22
  tui.paragraph(
20
23
  text: model.text,
21
24
  alignment: :center,
@@ -27,7 +30,7 @@ class VerifyReadmeUsage
27
30
  )
28
31
  end
29
32
 
30
- UPDATE = -> (msg, model) do
33
+ Update = -> (msg, model) do
31
34
  if msg.q? || msg.ctrl_c?
32
35
  RatatuiRuby::Tea::Command.exit
33
36
  else
@@ -36,7 +39,7 @@ class VerifyReadmeUsage
36
39
  end
37
40
 
38
41
  def run
39
- RatatuiRuby::Tea.run(model: MODEL, view: VIEW, update: UPDATE)
42
+ RatatuiRuby::Tea.run(VerifyReadmeUsage)
40
43
  end
41
44
  # [SYNC:END:mvu]
42
45
  end
@@ -0,0 +1,71 @@
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 RatatuiRuby
9
+ module Tea
10
+ module Command
11
+ # An aggregating parallel command.
12
+ All = Data.define(:envelope, :commands, :nested) do
13
+ include Custom
14
+
15
+ def self.new(tag, *args)
16
+ # DWIM: detect nested vs splatted based on call-site arity
17
+ if args.size == 1 && args.first.is_a?(Array)
18
+ commands = args.first
19
+ nested = true
20
+ else
21
+ commands = args
22
+ nested = false
23
+ end
24
+
25
+ if RatatuiRuby::Debug.enabled?
26
+ commands.each do |cmd|
27
+ unless Ractor.shareable?(cmd)
28
+ raise RatatuiRuby::Error::Invariant,
29
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
30
+ "Use Ractor.make_shareable or a Data.define command."
31
+ end
32
+ end
33
+ end
34
+
35
+ instance = allocate
36
+ instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
37
+ instance
38
+ end
39
+
40
+ def call(out, token)
41
+ # Early return for empty commands - prevents hang from zip_futures([])
42
+ if commands.empty?
43
+ response = Message::All.new(envelope:, results: [].freeze, nested:)
44
+ out.put(Ractor.make_shareable(response))
45
+ return
46
+ end
47
+
48
+ child_lifecycle = Lifecycle.new
49
+
50
+ futures = commands.map do |command|
51
+ Concurrent::Promises.future do
52
+ child_channel = Concurrent::Promises::Channel.new
53
+ child_outlet = Outlet.new(child_channel, lifecycle: child_lifecycle)
54
+ command.call(child_outlet, token)
55
+ child_channel.pop
56
+ end
57
+ end
58
+
59
+ all_done = Concurrent::Promises.zip_futures(*futures)
60
+ Concurrent::Promises.any_event(all_done, token.origin).wait
61
+
62
+ return out.put(Command.cancel(self)) if token.canceled?
63
+
64
+ shareable_results = Ractor.make_shareable(all_done.value!)
65
+ response = Message::All.new(envelope:, results: shareable_results, nested:)
66
+ out.put(Ractor.make_shareable(response))
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,79 @@
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 RatatuiRuby
9
+ module Tea
10
+ module Command
11
+ # A fire-and-forget parallel command.
12
+ #
13
+ # Applications fetch data from multiple sources. Dashboard panels load
14
+ # users, stats, and notifications. Waiting sequentially is slow.
15
+ # Managing threads and error handling manually is error-prone.
16
+ #
17
+ # This command runs children in parallel. Each child sends its own messages
18
+ # independently. The batch completes when all children finish or when
19
+ # cancellation fires. On cancellation, emits <tt>Command.cancel(self)</tt>.
20
+ #
21
+ # Use it for parallel fetches, concurrent refreshes, or any work that
22
+ # does not need coordinated results.
23
+ #
24
+ # === Example
25
+ #
26
+ # def update(msg, model)
27
+ # case msg
28
+ # in :refresh_all
29
+ # batch = Command.batch(
30
+ # Command.http(:get, "/users", :users),
31
+ # Command.http(:get, "/stats", :stats),
32
+ # )
33
+ # [model.with(loading: true), batch]
34
+ # in :users | :stats
35
+ # [model.with(msg => data), nil]
36
+ # end
37
+ # end
38
+ class Batch < Data.define(:commands) do
39
+ include Custom
40
+
41
+ # Initialize
42
+ def self.new(*args)
43
+ # DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
44
+ commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
45
+
46
+ if RatatuiRuby::Debug.enabled?
47
+ commands.each do |cmd|
48
+ unless Ractor.shareable?(cmd)
49
+ raise RatatuiRuby::Error::Invariant,
50
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
51
+ "Use Ractor.make_shareable or a Data.define command."
52
+ end
53
+ end
54
+ end
55
+
56
+ instance = allocate
57
+ instance.__send__(:initialize, commands: commands.freeze)
58
+ instance
59
+ end
60
+
61
+ # Call it
62
+ def call(out, token)
63
+ futures = commands.map do |command|
64
+ Concurrent::Promises.future { command.call(out, token) }
65
+ end
66
+
67
+ all_done = Concurrent::Promises.zip_futures(*futures)
68
+ Concurrent::Promises.any_event(all_done, token.origin).wait
69
+
70
+ # Re-raise any child exception for runtime to wrap in Command::Error
71
+ futures.each { |f| raise f.reason if f.rejected? }
72
+
73
+ out.put(Command.cancel(self)) if token.canceled?
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -39,7 +39,7 @@ module RatatuiRuby
39
39
  # ws.on_message { |msg| out.put(:ws_message, msg) }
40
40
  # ws.connect
41
41
  #
42
- # until token.cancelled?
42
+ # until token.canceled?
43
43
  # ws.ping
44
44
  # sleep 1
45
45
  # end
@@ -0,0 +1,194 @@
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 "net/http"
9
+ require "uri"
10
+
11
+ module RatatuiRuby
12
+ module Tea
13
+ module Command
14
+ # Alias to Message::HttpResponse for backwards compatibility.
15
+ # New code should use RatatuiRuby::Tea::Message::HttpResponse.
16
+ HttpResponse = Message::HttpResponse
17
+
18
+ # An HTTP request command.
19
+ Http = Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser) do
20
+ include Custom
21
+
22
+ def self.new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
23
+ get: nil, post: nil, put: nil, patch: nil, delete: nil
24
+ )
25
+ # Auto-splat single hash argument
26
+ return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
27
+
28
+ # Auto-spread single array argument
29
+ return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
30
+
31
+ # DWIM: parse positional args and keyword method shortcuts
32
+ method_keywords = { get:, post:, put:, patch:, delete: }.compact
33
+ method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
34
+
35
+ # Ractor validation
36
+ if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
37
+ raise RatatuiRuby::Error::Invariant,
38
+ "URL is not Ractor-shareable: #{url.inspect}\n" \
39
+ "Use a frozen string or Ractor.make_shareable."
40
+ end
41
+
42
+ if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
43
+ raise RatatuiRuby::Error::Invariant,
44
+ "Headers are not Ractor-shareable: #{headers.inspect}\n" \
45
+ "Use Ractor.make_shareable or freeze the hash and its contents."
46
+ end
47
+
48
+ if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
49
+ raise RatatuiRuby::Error::Invariant,
50
+ "Body is not Ractor-shareable: #{body.inspect}\n" \
51
+ "Use a frozen string or Ractor.make_shareable."
52
+ end
53
+
54
+ if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
55
+ raise RatatuiRuby::Error::Invariant,
56
+ "Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
57
+ "Use a frozen string, symbol, or Ractor.make_shareable."
58
+ end
59
+
60
+ if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
61
+ raise RatatuiRuby::Error::Invariant,
62
+ "Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
63
+ "Use a number or Ractor.make_shareable."
64
+ end
65
+
66
+ # Parser validation
67
+ if parser && !parser.respond_to?(:call)
68
+ raise ArgumentError, "parser: must respond to :call"
69
+ end
70
+
71
+ if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
72
+ raise RatatuiRuby::Error::Invariant,
73
+ "Parser is not Ractor-shareable: #{parser.inspect}\n" \
74
+ "Use a frozen Method object or Ractor.make_shareable."
75
+ end
76
+
77
+ # Method validation
78
+ unless %i[get post put patch delete].include?(method)
79
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
80
+ end
81
+
82
+ instance = allocate
83
+ instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
84
+ instance
85
+ end
86
+
87
+ # Net::HTTP is blocking; no cooperative cancellation possible.
88
+ # Grace period = 0 means runtime can force-kill immediately.
89
+ def tea_cancellation_grace_period = 0
90
+
91
+ def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
92
+ # Handle keyword method shortcuts: get: 'url'
93
+ if method_keywords.any?
94
+ method_key, url = method_keywords.first
95
+ # Check for conflicts with method: keyword
96
+ if method_kw && method_kw != method_key
97
+ raise ArgumentError, "Conflicting method specified: #{method_key}: and method: #{method_kw}"
98
+ end
99
+ # Check for conflicts with url: keyword
100
+ if url_kw && url_kw != url
101
+ raise ArgumentError, "Conflicting url specified: #{method_key}: provides url but url: also given"
102
+ end
103
+ return [method_key, url, url, body_kw]
104
+ end
105
+
106
+ # If keywords provided, use them directly
107
+ return [method_kw, url_kw, envelope_kw, body_kw] if args.empty?
108
+
109
+ case args
110
+ in [String => url]
111
+ # Http.new('url') → GET, URL as envelope
112
+ [:get, url, url, body_kw]
113
+ in [String => url, Symbol => envelope]
114
+ # Http.new('url', :tag) → GET, custom envelope
115
+ [:get, url, envelope, body_kw]
116
+ in [Symbol => method, String => url]
117
+ # Http.new(:delete, 'url') → method, URL as envelope
118
+ [method, url, url, body_kw]
119
+ in [Symbol => method, String => url, Symbol => envelope]
120
+ # Http.new(:delete, 'url', :tag) → method, custom envelope
121
+ [method, url, envelope, body_kw]
122
+ in [Symbol => method, String => url, String => body]
123
+ # Http.new(:post, 'url', 'body') → method, URL as envelope, body
124
+ [method, url, url, body]
125
+ in [Symbol => method, String => url, String => body, Symbol => envelope]
126
+ # Http.new(:post, 'url', 'body', :tag) → method, custom envelope, body
127
+ [method, url, envelope, body]
128
+ else
129
+ raise ArgumentError, "Invalid arguments for Http.new: #{args.inspect}"
130
+ end
131
+ end
132
+ private_class_method :parse_dwim_args
133
+
134
+ def call(out, token)
135
+ return if token.canceled?
136
+
137
+ uri = URI(url)
138
+ http = Net::HTTP.new(uri.host, uri.port)
139
+ http.use_ssl = uri.scheme == "https"
140
+ if timeout
141
+ http.open_timeout = timeout
142
+ http.read_timeout = timeout
143
+ end
144
+
145
+ klass = case method
146
+ when :get then Net::HTTP::Get
147
+ when :post then Net::HTTP::Post
148
+ when :put then Net::HTTP::Put
149
+ when :patch then Net::HTTP::Patch
150
+ when :delete then Net::HTTP::Delete
151
+ end
152
+ request = klass.new(uri)
153
+ request.body = body if body
154
+ headers&.each { |key, value| request[key] = value }
155
+ response = http.request(request)
156
+
157
+ response_body = response.body.freeze
158
+ response_headers = response.each_header.to_h.freeze
159
+ response_status = response.code.to_i
160
+
161
+ # Invoke parser with positional params if provided
162
+ parsed_body = if parser
163
+ parser.call(response_body, response_headers, response_status)
164
+ else
165
+ response_body
166
+ end
167
+
168
+ # Validate parsed body is Ractor-shareable in debug mode
169
+ if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parsed_body)
170
+ raise RatatuiRuby::Error::Invariant,
171
+ "Parsed body is not Ractor-shareable: #{parsed_body.class}\n" \
172
+ "Parser must return frozen/shareable data. Use .freeze or Ractor.make_shareable."
173
+ end
174
+
175
+ out.put(Ractor.make_shareable(HttpResponse.new(
176
+ envelope:,
177
+ status: response_status,
178
+ body: parsed_body,
179
+ headers: response_headers,
180
+ error: nil
181
+ )))
182
+ rescue => e
183
+ out.put(Ractor.make_shareable(HttpResponse.new(
184
+ envelope:,
185
+ status: nil,
186
+ body: nil,
187
+ headers: nil,
188
+ error: e.message.freeze
189
+ )))
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,136 @@
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 RatatuiRuby
9
+ module Tea
10
+ module Command
11
+ # Coordinates command execution across the runtime.
12
+ #
13
+ # Commands run off the main thread. Both the runtime and nested commands
14
+ # via <tt>Outlet#source</tt> share cancellation tokens. Racing results
15
+ # against cancellation is repetitive. Tracking active commands is tedious.
16
+ #
17
+ # This class centralizes that logic. It races results against cancellation
18
+ # and timeout. Commands that ignore cancellation are orphaned until
19
+ # process exit. Cooperative cancellation is the only way to exit cleanly.
20
+ #
21
+ # The framework creates one instance at startup. All outlets share it.
22
+ class Lifecycle
23
+ # :nodoc: Internal representation of a tracked async command.
24
+ Entry = Data.define(:future, :origin)
25
+
26
+ # Creates a lifecycle manager.
27
+ #
28
+ # The runtime creates one at startup. All outlets share it. Child commands
29
+ # from <tt>Outlet#source</tt> inherit the same lifecycle for consistent
30
+ # thread management.
31
+ def initialize
32
+ @active = Concurrent::Map.new
33
+ end
34
+
35
+ # Runs a command synchronously, returning its result.
36
+ #
37
+ # Spawns a thread, races the result against cancellation and timeout.
38
+ # On cancellation, waits the grace period then kills the thread if needed.
39
+ #
40
+ # [command] Callable with <tt>call(out, token)</tt>.
41
+ # [token] Parent's cancellation token.
42
+ # [timeout] Max wait seconds for the result.
43
+ #
44
+ # Returns the child's message, or <tt>nil</tt> if cancelled or timed out.
45
+ # Raises if the child raised.
46
+ def run_sync(command, token, timeout:)
47
+ return nil if token.canceled?
48
+
49
+ child_channel = Concurrent::Promises::Channel.new
50
+ child_outlet = Outlet.new(child_channel, lifecycle: self)
51
+
52
+ exception = nil
53
+ Concurrent::Promises.future do
54
+ command.call(child_outlet, token)
55
+ rescue => e
56
+ exception = e
57
+ end
58
+
59
+ # Race: pop result vs cancellation vs timeout
60
+ pop_future = Concurrent::Promises.future { child_channel.pop(timeout, :timeout) }
61
+ Concurrent::Promises.any_event(pop_future, token.origin).wait
62
+
63
+ # Cooperative cancellation only — misbehaving commands are orphaned
64
+ return nil if token.canceled?
65
+
66
+ if exception
67
+ raise exception.is_a?(Exception) ? exception : RuntimeError.new(exception.to_s)
68
+ end
69
+
70
+ result = pop_future.value
71
+ return nil if result == :timeout
72
+
73
+ result
74
+ end
75
+
76
+ # Runs a command asynchronously, tracking it for later cancellation.
77
+ #
78
+ # Spawns a future that executes the command. Tracks the command in the
79
+ # active map for cancellation support. Errors are pushed to the channel
80
+ # as <tt>Command::Error</tt> messages.
81
+ #
82
+ # [command] Callable with <tt>call(out, token)</tt>.
83
+ # [channel] Channel to push results and errors to.
84
+ #
85
+ # Returns a hash with <tt>:future</tt> and <tt>:origin</tt> for tracking.
86
+ def run_async(command, channel)
87
+ cancellation, origin = Concurrent::Cancellation.new
88
+ outlet = Outlet.new(channel, lifecycle: self)
89
+
90
+ future = Concurrent::Promises.future do
91
+ command.call(outlet, cancellation)
92
+ rescue => e
93
+ channel.push Command::Error.new(command:, exception: e)
94
+ end
95
+
96
+ entry = Entry.new(future:, origin:)
97
+ @active[command] = entry
98
+ entry
99
+ end
100
+
101
+ # Cancels a running command, waiting for its grace period.
102
+ #
103
+ # Signals cancellation, waits for the command's grace period, then
104
+ # removes it from tracking. Does nothing if the command isn't tracked.
105
+ #
106
+ # [command] The command to cancel (must be the same object passed to run_async).
107
+ def cancel(command)
108
+ entry = @active[command]
109
+ return unless entry&.future&.pending?
110
+
111
+ entry.origin.resolve # Signal cancellation
112
+
113
+ grace = command.respond_to?(:tea_cancellation_grace_period) ?
114
+ command.tea_cancellation_grace_period : 0.1
115
+ entry.future.wait(grace.finite? ? grace : nil)
116
+
117
+ @active.delete(command)
118
+ end
119
+
120
+ # Cancels all active commands and waits for them to complete.
121
+ #
122
+ # Iterates through all tracked commands, signals cancellation, and waits
123
+ # for each command's grace period. Called at runtime shutdown.
124
+ def shutdown
125
+ @active.each do |command, entry|
126
+ entry.origin.resolve # Signal cancellation
127
+
128
+ grace = command.respond_to?(:tea_cancellation_grace_period) ?
129
+ command.tea_cancellation_grace_period : 0.1
130
+ entry.future.wait(grace.finite? ? grace : nil)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -64,7 +64,7 @@ module RatatuiRuby
64
64
  # include Tea::Command::Custom
65
65
  #
66
66
  # def call(out, token)
67
- # until token.cancelled?
67
+ # until token.canceled?
68
68
  # data = fetch_batch
69
69
  # out.put(:batch, Ractor.make_shareable(data))
70
70
  # sleep 5
@@ -76,26 +76,64 @@ module RatatuiRuby
76
76
  # SPDX-SnippetEnd
77
77
  #++
78
78
  class Outlet
79
- # Creates an outlet for the given queue.
79
+ # Creates an outlet for the given message queue.
80
80
  #
81
- # The runtime provides the queue. Custom commands receive the outlet as
82
- # their first argument.
81
+ # The runtime provides the message queue and lifecycle. Custom commands receive
82
+ # the outlet as their first argument.
83
83
  #
84
- # [queue] A <tt>Thread::Queue</tt> or compatible object.
85
- def initialize(queue)
86
- @queue = queue
84
+ # [message_queue] A <tt>Concurrent::Promises::Channel</tt> or compatible object.
85
+ # [lifecycle] A <tt>Lifecycle</tt> for managing nested command execution.
86
+ def initialize(message_queue, lifecycle:)
87
+ @message_queue = message_queue
88
+ @live = lifecycle
87
89
  end
88
90
 
89
- # Sends a tagged message to the runtime.
91
+ # :nodoc: Internal infrastructure for nested command lifecycle sharing.
92
+ attr_reader :live
93
+
94
+ # Sends a message to the runtime.
90
95
  #
91
- # Builds an array <tt>[tag, *payload]</tt> and pushes it to the queue.
92
- # The update function pattern-matches on the tag.
96
+ # Custom commands produce results. Those results feed back into your
97
+ # update function. This method handles the wiring.
93
98
  #
94
- # Debug mode validates Ractor-shareability. It raises <tt>Error::Invariant</tt>
95
- # if the message is not shareable. Production skips this check.
99
+ # Call with one argument to send it directly. Call with multiple
100
+ # arguments and they arrive as an array.
96
101
  #
97
- # [tag] Symbol identifying the message type.
98
- # [payload] Additional arguments. Freeze them or use <tt>Ractor.make_shareable</tt>.
102
+ # Use it for complex data flows or transports Tea doesn't ship with.
103
+ #
104
+ # === Example
105
+ #
106
+ # out.put(:done) # Update receives :done
107
+ # out.put(current_user) # Update receives current_user
108
+ # out.put(:user, alice) # Update receives [:user, alice]
109
+ #
110
+ # Debug mode validates Ractor-shareability.
111
+ def put(*args)
112
+ message = (args.size == 1) ? args.first : args.freeze
113
+
114
+ if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(message)
115
+ raise RatatuiRuby::Error::Invariant,
116
+ "Message is not Ractor-shareable: #{message.inspect}\n" \
117
+ "Use Ractor.make_shareable or Object#freeze."
118
+ end
119
+
120
+ @message_queue.push(message)
121
+ end
122
+
123
+ # Runs a child command synchronously within a custom command.
124
+ #
125
+ # Use this to orchestrate multi-step workflows: fetch one result, then
126
+ # use it to compose the next command.
127
+ #
128
+ # The child runs asynchronously in a future. This method blocks until
129
+ # the child calls +put+, cancellation occurs, or the timeout expires.
130
+ #
131
+ # [command] A callable (lambda or Custom command) with +call(out, token)+.
132
+ # [token] The parent's cancellation token, passed through to the child.
133
+ # [timeout] Max seconds to wait for the child's result (default: 30.0).
134
+ #
135
+ # Returns the message from the child, or +nil+ if cancelled/timed out.
136
+ # Raises if the child command raised an exception.
99
137
  #
100
138
  # === Example
101
139
  #
@@ -104,22 +142,16 @@ module RatatuiRuby
104
142
  # SPDX-FileCopyrightText: 2026 Kerrick Long
105
143
  # SPDX-License-Identifier: MIT-0
106
144
  #++
107
- # out.put(:user_fetched, user: Ractor.make_shareable(user))
108
- # out.put(:error, message: "Connection failed".freeze)
109
- # out.put(:progress, percent: 42) # Integers are always shareable
145
+ # def call(out, token)
146
+ # user_result = out.source(fetch_user_cmd, token)
147
+ # return if user_result.nil?
148
+ # out.put(:user_loaded, user: user_result)
149
+ # end
110
150
  #--
111
151
  # SPDX-SnippetEnd
112
152
  #++
113
- def put(tag, *payload)
114
- message = [tag, *payload].freeze
115
-
116
- if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(message)
117
- raise RatatuiRuby::Error::Invariant,
118
- "Message is not Ractor-shareable: #{message.inspect}\n" \
119
- "Use Ractor.make_shareable or Object#freeze."
120
- end
121
-
122
- @queue << message
153
+ def source(command, token, timeout: 30.0)
154
+ @live.run_sync(command, token, timeout:)
123
155
  end
124
156
  end
125
157
  end