ratatui_ruby-tea 0.3.0 → 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +51 -7
  3. data/CHANGELOG.md +109 -0
  4. data/README.md +25 -5
  5. data/Rakefile +1 -1
  6. data/Steepfile +3 -3
  7. data/doc/concepts/async_work.md +164 -0
  8. data/doc/concepts/commands.md +528 -0
  9. data/doc/concepts/message_processing.md +51 -0
  10. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  11. data/doc/contributors/WIP/implementation_plan.md +405 -0
  12. data/doc/contributors/WIP/init_callable_proposal.md +341 -0
  13. data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -0
  14. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  15. data/doc/contributors/WIP/task.md +36 -0
  16. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  17. data/doc/contributors/design/commands_and_outlets.md +214 -0
  18. data/doc/contributors/kit-no-outlet.md +237 -0
  19. data/doc/contributors/priorities.md +22 -24
  20. data/examples/app_fractal_dashboard/app.rb +3 -7
  21. data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
  22. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
  23. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
  24. data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
  25. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
  26. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  27. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
  28. data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
  29. data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
  30. data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
  31. data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
  32. data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
  33. data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
  34. data/examples/verify_readme_usage/README.md +7 -4
  35. data/examples/verify_readme_usage/app.rb +7 -4
  36. data/lib/ratatui_ruby/tea/command/all.rb +71 -0
  37. data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
  38. data/lib/ratatui_ruby/tea/command/custom.rb +106 -0
  39. data/lib/ratatui_ruby/tea/command/http.rb +194 -0
  40. data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
  41. data/lib/ratatui_ruby/tea/command/outlet.rb +159 -0
  42. data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
  43. data/lib/ratatui_ruby/tea/command.rb +416 -13
  44. data/lib/ratatui_ruby/tea/message/all.rb +47 -0
  45. data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
  46. data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
  47. data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
  48. data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
  49. data/lib/ratatui_ruby/tea/message.rb +40 -0
  50. data/lib/ratatui_ruby/tea/router.rb +155 -87
  51. data/lib/ratatui_ruby/tea/runtime.rb +329 -150
  52. data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
  53. data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
  54. data/lib/ratatui_ruby/tea/version.rb +1 -1
  55. data/lib/ratatui_ruby/tea.rb +44 -10
  56. data/rbs_collection.lock.yaml +108 -0
  57. data/rbs_collection.yaml +15 -0
  58. data/sig/concurrent.rbs +72 -0
  59. data/sig/examples/verify_readme_usage/app.rbs +1 -1
  60. data/sig/examples/widget_command_system/app.rbs +1 -1
  61. data/sig/open3.rbs +17 -0
  62. data/sig/ratatui_ruby/tea/command.rbs +226 -6
  63. data/sig/ratatui_ruby/tea/message.rbs +123 -0
  64. data/sig/ratatui_ruby/tea/router.rbs +110 -54
  65. data/sig/ratatui_ruby/tea/runtime.rbs +63 -12
  66. data/sig/ratatui_ruby/tea/shortcuts.rbs +18 -0
  67. data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
  68. data/sig/ratatui_ruby/tea/version.rbs +10 -0
  69. data/sig/ratatui_ruby/tea.rbs +39 -7
  70. data/tasks/steep.rake +11 -0
  71. metadata +75 -12
  72. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
@@ -0,0 +1,82 @@
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 one-shot timer command.
12
+ #
13
+ # Applications need timed events. Notification auto-dismissal,
14
+ # debounced search, and animation frames all depend on delays.
15
+ # Building timers from scratch with threads is error-prone.
16
+ # Cancellation is tricky.
17
+ #
18
+ # This command waits, then sends a message. It responds to
19
+ # cancellation cooperatively. When cancelled, it sends
20
+ # <tt>Command.cancel(self)</tt> so you know the timer stopped.
21
+ #
22
+ # Use it for delayed actions, debounced inputs, or animation loops.
23
+ #
24
+ # === Example: Notification dismissal
25
+ #
26
+ # def update(msg, model)
27
+ # case msg
28
+ # in :save_clicked
29
+ # [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
30
+ # in :dismiss
31
+ # [model.with(notification: nil), nil]
32
+ # in Command::Cancel
33
+ # [model.with(notification: nil), nil] # User navigated away
34
+ # end
35
+ # end
36
+ #
37
+ # === Example: Animation loop
38
+ #
39
+ # def update(msg, model)
40
+ # case msg
41
+ # in :start_animation
42
+ # [model.with(frame: 0), Command.tick(0.1, :animate)]
43
+ # in :animate
44
+ # frame = (model.frame + 1) % 10
45
+ # [model.with(frame:), Command.tick(0.1, :animate)]
46
+ # end
47
+ # end
48
+ Wait = Data.define(:seconds, :envelope) do
49
+ include Custom
50
+
51
+ # Cooperative cancellation needs no grace period.
52
+ # The command responds instantly to cancellation via
53
+ # <tt>Concurrent::Cancellation.timeout</tt>.
54
+ def tea_cancellation_grace_period
55
+ 0
56
+ end
57
+
58
+ # Executes the timer.
59
+ #
60
+ # Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
61
+ # If cancelled, sends <tt>Command.cancel(self)</tt> instead.
62
+ #
63
+ # [out] Outlet for sending messages.
64
+ # [token] Cancellation token from the runtime.
65
+ def call(out, token)
66
+ start_time = Time.now
67
+ timer_cancellation, _origin = Concurrent::Cancellation.timeout(seconds)
68
+ combined = token.join(timer_cancellation)
69
+ combined.origin.wait
70
+
71
+ if token.canceled?
72
+ out.put(Command.cancel(self))
73
+ else
74
+ elapsed = Time.now - start_time
75
+ response = Message::Timer.new(envelope:, elapsed:)
76
+ out.put(Ractor.make_shareable(response))
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -5,6 +5,15 @@
5
5
  # SPDX-License-Identifier: LGPL-3.0-or-later
6
6
  #++
7
7
 
8
+ require "concurrent-edge"
9
+ require_relative "command/custom"
10
+ require_relative "command/outlet"
11
+ require_relative "command/lifecycle"
12
+ require_relative "command/wait"
13
+ require_relative "command/batch"
14
+ require_relative "command/all"
15
+ require_relative "command/http"
16
+
8
17
  module RatatuiRuby
9
18
  module Tea
10
19
  # Commands represent side effects.
@@ -30,7 +39,14 @@ module RatatuiRuby
30
39
  # Sentinel value for application termination.
31
40
  #
32
41
  # The runtime detects this before dispatching. It breaks the loop immediately.
33
- Exit = Data.define
42
+ class Exit < Data.define
43
+ include Custom
44
+
45
+ # Stub - Exit is a sentinel handled by runtime before dispatch.
46
+ def call(_out, _token)
47
+ raise "Exit command should never be dispatched"
48
+ end
49
+ end
34
50
 
35
51
  # Creates a quit command.
36
52
  #
@@ -50,23 +66,243 @@ module RatatuiRuby
50
66
  Exit.new
51
67
  end
52
68
 
53
- # Command to run a shell command via Open3.
69
+ # Creates a fresh cancellation that never fires.
70
+ #
71
+ # Some I/O operations cannot be cancelled mid-execution. Ruby's <tt>Net::HTTP</tt>
72
+ # blocks until completion or timeout — there is no way to interrupt it.
73
+ #
74
+ # A shared singleton would be unsafe. If any code path accidentally resolves
75
+ # the origin, all commands using it become cancelled.
76
+ #
77
+ # Use it for commands that wrap non-cancellable blocking I/O.
78
+ #
79
+ # === Example
80
+ #
81
+ # token = Command.uncancellable
82
+ # HttpCommand.new(url).call(outlet, token)
83
+ def self.uncancellable
84
+ cancellation, _origin = Concurrent::Cancellation.new(Concurrent::Promises.resolvable_event)
85
+ cancellation
86
+ end
87
+
88
+ # Sentinel value for command cancellation.
89
+ #
90
+ # Long-running commands (WebSocket listeners, database pollers) run until stopped.
91
+ # Stopping them requires signaling from outside the command. The runtime tracks
92
+ # active commands by their object identity and routes cancel requests.
93
+ #
94
+ # This type carries the handle (command object) to cancel. The runtime pattern-matches
95
+ # on <tt>Command::Cancel</tt> and signals the token.
96
+ class Cancel < Data.define(:handle)
97
+ include Custom
98
+
99
+ # Stub - Cancel is a sentinel handled by runtime before dispatch.
100
+ def call(_out, _token)
101
+ raise "Cancel command should never be dispatched"
102
+ end
103
+ end
104
+
105
+ # Request cancellation of a running command.
106
+ #
107
+ # The model stores the command handle (the command object itself). Returning
108
+ # <tt>Command.cancel(handle)</tt> signals the runtime to stop it.
54
109
  #
55
- # The runtime executes the command and produces messages. In batch mode
56
- # (default), a single message arrives: <tt>[tag, {stdout:, stderr:, status:}]</tt>.
110
+ # [handle] The command object to cancel.
57
111
  #
58
- # In streaming mode, messages arrive incrementally:
112
+ # === Example
113
+ #
114
+ # # Dispatch and store handle
115
+ # cmd = FetchData.new(url)
116
+ # [model.with(active_fetch: cmd), cmd]
117
+ #
118
+ # # User clicks cancel
119
+ # when :cancel_clicked
120
+ # [model.with(active_fetch: nil), Command.cancel(model.active_fetch)]
121
+ def self.cancel(handle)
122
+ Cancel.new(handle:)
123
+ end
124
+
125
+ # Error message from a failed command.
126
+ #
127
+ # Commands run in background threads. Exceptions bubble up silently.
128
+ # Your update function never sees them. Backtraces in STDERR corrupt the TUI.
129
+ #
130
+ # The runtime catches exceptions and wraps them in Error messages.
131
+ # Pattern match on Error in your update function. Display the error, log it, or recover.
132
+ #
133
+ # Use it to surface failures from HTTP requests, file I/O, or external processes.
134
+ #
135
+ # === Examples
136
+ #
137
+ # Update = ->(message, model) {
138
+ # case message
139
+ # in Command::Error[command:, exception:]
140
+ # # Show error toast
141
+ # [model.with(error: exception.message), nil]
142
+ # in Command::Error[command: Command::Http, exception:]
143
+ # # Retry HTTP request
144
+ # [model, command]
145
+ # in Command::Error
146
+ # # Log and continue
147
+ # warn "Command failed: #{message.exception}"
148
+ # [model, nil]
149
+ # end
150
+ # }
151
+ class Error < Data.define(:command, :exception); end
152
+
153
+ # Creates an error sentinel.
154
+ #
155
+ # The runtime produces this automatically when a command raises.
156
+ # Use this factory for testing or for commands that want to signal
157
+ # error completion without raising.
158
+ #
159
+ # [command] The command that failed.
160
+ # [exception] The exception that was raised.
161
+ #
162
+ # === Example
163
+ #
164
+ # def update(message, model)
165
+ # case message
166
+ # in Command::Error(command:, exception:)
167
+ # model.with(error: "#{command.class} failed: #{exception.message}")
168
+ # end
169
+ # end
170
+ def self.error(command, exception)
171
+ Error.new(command:, exception:)
172
+ end
173
+
174
+ # Runs a shell command and routes its output back as messages.
175
+ #
176
+ # Apps run external tools: linters, compilers, scripts, system utilities.
177
+ # The runtime dispatches the command in a thread, so the UI stays responsive.
178
+ # Batch mode (default) waits for completion; streaming mode shows output live.
179
+ # Orphaned child processes linger and waste resources, so cancellation sends
180
+ # <tt>SIGTERM</tt> for graceful shutdown, then <tt>SIGKILL</tt> to prevent orphans.
181
+ #
182
+ # Use it to run builds, lint files, execute scripts, or invoke any CLI tool.
183
+ #
184
+ # === Batch Mode (default)
185
+ #
186
+ # A single message arrives when the command finishes:
187
+ # <tt>[tag, {stdout:, stderr:, status:}]</tt>
188
+ #
189
+ # === Streaming Mode
190
+ #
191
+ # Messages arrive incrementally:
59
192
  # - <tt>[tag, :stdout, line]</tt> for each stdout line
60
193
  # - <tt>[tag, :stderr, line]</tt> for each stderr line
61
194
  # - <tt>[tag, :complete, {status:}]</tt> when the command finishes
62
195
  # - <tt>[tag, :error, {message:}]</tt> if the command cannot start
63
196
  #
64
197
  # The <tt>status</tt> is the integer exit code (0 = success).
65
- System = Data.define(:command, :tag, :stream) do
198
+ class System < Data.define(:command, :envelope, :stream)
199
+ include Custom
200
+
66
201
  # Returns true if streaming mode is enabled.
67
202
  def stream?
68
203
  stream
69
204
  end
205
+
206
+ # Executes the shell command and sends results via outlet.
207
+ #
208
+ # In batch mode, sends a single message with all output.
209
+ # In streaming mode, sends incremental messages as output arrives.
210
+ # Respects cancellation token by sending SIGTERM (then SIGKILL) to child.
211
+ def call(out, token)
212
+ require "open3"
213
+
214
+ if stream?
215
+ stream_execution(out, token)
216
+ else
217
+ batch_execution(out)
218
+ end
219
+ end
220
+
221
+ private def batch_execution(out)
222
+ stdout, stderr, status = Open3.capture3(command)
223
+ message = Message::System::Batch.new(
224
+ envelope:,
225
+ stdout:,
226
+ stderr:,
227
+ status: status.exitstatus
228
+ )
229
+ out.put(Ractor.make_shareable(message))
230
+ end
231
+
232
+ private def stream_execution(out, token)
233
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
234
+ stdin.close
235
+ pid = wait_thr.pid
236
+
237
+ # Track which streams are still open
238
+ streams = { stdout => :stdout, stderr => :stderr }
239
+
240
+ until streams.empty?
241
+ # Check cancellation before blocking on IO.select
242
+ if token.canceled? && wait_thr.alive?
243
+ begin
244
+ Process.kill("TERM", pid)
245
+ rescue Errno::ESRCH
246
+ # Already dead
247
+ end
248
+ end
249
+
250
+ # Wait up to 0.05s for any stream to have data
251
+ ready = IO.select(streams.keys, nil, nil, 0.05)
252
+ next unless ready
253
+
254
+ ready[0].each do |io|
255
+ stream_type = streams[io]
256
+ begin
257
+ line = io.read_nonblock(8192, exception: false)
258
+ case line
259
+ when :wait_readable
260
+ next
261
+ when nil, ""
262
+ # EOF - stream closed
263
+ streams.delete(io)
264
+ else
265
+ # Split into lines and send each
266
+ line.each_line do |l|
267
+ msg = Message::System::Stream.new(
268
+ envelope:,
269
+ stream: stream_type,
270
+ content: l.freeze,
271
+ status: nil
272
+ )
273
+ out.put(Ractor.make_shareable(msg))
274
+ end
275
+ end
276
+ rescue EOFError
277
+ streams.delete(io)
278
+ rescue IOError
279
+ # Stream forcibly closed
280
+ streams.delete(io)
281
+ end
282
+ end
283
+ end
284
+
285
+ # Wait for process to finish
286
+ wait_thr.join
287
+
288
+ status = wait_thr.value.exitstatus
289
+ msg = Message::System::Stream.new(
290
+ envelope:,
291
+ stream: :complete,
292
+ content: nil,
293
+ status:
294
+ )
295
+ out.put(Ractor.make_shareable(msg))
296
+ end
297
+ rescue Errno::ENOENT, Errno::EACCES => e
298
+ msg = Message::System::Stream.new(
299
+ envelope:,
300
+ stream: :error,
301
+ content: e.message,
302
+ status: nil
303
+ )
304
+ out.put(Ractor.make_shareable(msg))
305
+ end
70
306
  end
71
307
 
72
308
  # Creates a shell execution command.
@@ -110,16 +346,49 @@ module RatatuiRuby
110
346
  # [model.with(loading: false, error: message), nil]
111
347
  # end
112
348
  # end
113
- def self.system(command, tag, stream: false)
114
- System.new(command:, tag:, stream:)
349
+ def self.system(command, envelope, stream: false)
350
+ System.new(command:, envelope:, stream:)
115
351
  end
116
352
 
117
- # Command that wraps another command's result with a transformation.
353
+ # Wraps another command's result with a transformation.
354
+ #
355
+ # Fractal Architecture requires composition. Child fragments produce commands
356
+ # with their own tags. Parent fragments need those results routed back with
357
+ # a parent prefix. Without transformation, update functions become
358
+ # monolithic "God Reducers" that know about every child's internals.
118
359
  #
119
- # Fractal Architecture requires composition. Child bags produce commands.
120
- # Parent bags route child results back to themselves. +Mapped+ wraps a
121
- # child bag's command and transforms its result message into a parent message.
122
- Mapped = Data.define(:inner_command, :mapper)
360
+ # This command wraps an inner command and transforms its result message.
361
+ # The parent fragment delegates to the child, then intercepts the result and
362
+ # adds its routing prefix. Clean separation. No coupling.
363
+ #
364
+ # Use it to compose child fragments that return their own commands.
365
+ class Mapped < Data.define(:inner_command, :mapper)
366
+ include Custom
367
+
368
+ # Grace period delegates to inner command.
369
+ def tea_cancellation_grace_period
370
+ inner_command.respond_to?(:tea_cancellation_grace_period) ?
371
+ inner_command.tea_cancellation_grace_period : 0.1
372
+ end
373
+
374
+ # Executes the inner command, waits for result, and transforms it.
375
+ def call(out, token)
376
+ inner_channel = Concurrent::Promises::Channel.new
377
+ inner_outlet = Outlet.new(inner_channel, lifecycle: out.live)
378
+
379
+ # Dispatch inner command
380
+ if inner_command.respond_to?(:call)
381
+ inner_command.call(inner_outlet, token)
382
+ else
383
+ raise ArgumentError, "Inner command must respond to #call"
384
+ end
385
+
386
+ # Transform result and send
387
+ inner_message = inner_channel.pop
388
+ transformed = mapper.call(inner_message)
389
+ out.put(*transformed)
390
+ end
391
+ end
123
392
 
124
393
  # Creates a mapped command for Fractal Architecture composition.
125
394
  #
@@ -140,6 +409,140 @@ module RatatuiRuby
140
409
  def self.map(inner_command, &mapper)
141
410
  Mapped.new(inner_command:, mapper:)
142
411
  end
412
+
413
+ # Gives a callable unique identity for cancellation.
414
+ #
415
+ # Reusable procs and lambdas share identity. Dispatch them twice, and
416
+ # +Command.cancel+ would cancel both. Wrap them to get distinct handles.
417
+ #
418
+ # The callable must be Ractor-shareable (cannot capture mutable state).
419
+ # Create resources like database connections inside the callable, not in
420
+ # the closure. See the Custom Commands guide for details.
421
+ #
422
+ # [callable] Proc, lambda, or any object responding to +call(out, token)+.
423
+ # If omitted, the block is used.
424
+ # [grace_period] Cleanup time override. Default: 2.0 seconds.
425
+ #
426
+ # === Example
427
+ #
428
+ # # With callable
429
+ # cmd = Command.custom(->(out, token) { out.put(:fetched, data) })
430
+ #
431
+ # # With block
432
+ # cmd = Command.custom(grace_period: 5.0) do |out, token|
433
+ # until token.canceled?
434
+ # out.put(:tick, Time.now)
435
+ # sleep 1
436
+ # end
437
+ # end
438
+ def self.custom(callable = nil, grace_period: nil, &block)
439
+ c = callable || block
440
+
441
+ # Debug mode: validate that callable can be made shareable (fail fast)
442
+ if RatatuiRuby::Debug.enabled?
443
+ begin
444
+ c = Ractor.make_shareable(c)
445
+ rescue Ractor::IsolationError
446
+ raise RatatuiRuby::Error::Invariant,
447
+ "Command.custom requires a Ractor-shareable callable. " \
448
+ "#{c.class} is not shareable. Use Ractor.make_shareable or define at top-level."
449
+ end
450
+ end
451
+ # Production mode: skip validation (Ractors not yet used, avoid overhead)
452
+
453
+ Wrapped.new(callable: c, grace_period:)
454
+ end
455
+
456
+ # Creates a one-shot timer command.
457
+ #
458
+ # Waits for +seconds+ then sends +TimerResponse+ to the update function.
459
+ # Use for delayed actions like notification dismissal or debounced search.
460
+ #
461
+ # [seconds] Duration to wait (Float or Integer).
462
+ # [envelope] Symbol to tag the result message.
463
+ def self.wait(seconds, envelope)
464
+ Wait.new(seconds:, envelope:)
465
+ end
466
+
467
+ # Creates a recurring timer command.
468
+ #
469
+ # Identical to +wait+, but semantically used for animation frames where
470
+ # the update function re-dispatches to continue the animation loop.
471
+ #
472
+ # [interval] Duration between ticks (Float or Integer).
473
+ # [tag] Symbol to tag the result message.
474
+ singleton_class.alias_method :tick, :wait
475
+
476
+ # Creates a parallel batch command.
477
+ #
478
+ # Applications fetch data from multiple sources. Dashboard panels load
479
+ # users, stats, and notifications. Waiting sequentially is slow.
480
+ # Managing threads and error handling manually is error-prone.
481
+ #
482
+ # This command runs children in parallel. Each child sends its own messages
483
+ # independently. The batch completes when all children finish or when
484
+ # cancellation fires.
485
+ #
486
+ # Use it for parallel fetches, concurrent refreshes, or any work that
487
+ # does not need coordinated results.
488
+ #
489
+ # [commands] One or more commands to run in parallel. Pass multiple
490
+ # arguments or a single array.
491
+ #
492
+ # === Example
493
+ #
494
+ # # Variadic syntax
495
+ # Command.batch(
496
+ # Command.http(:get, "/users", :users),
497
+ # Command.http(:get, "/stats", :stats),
498
+ # )
499
+ #
500
+ # # Array syntax
501
+ # Command.batch([cmd1, cmd2, cmd3])
502
+ def self.batch(*)
503
+ Batch.new(*)
504
+ end
505
+
506
+ # Creates an aggregating parallel command.
507
+ #
508
+ # Applications load dashboards that combine user, settings, and stats.
509
+ # Fire-and-forget loses correlation. This command waits for all children
510
+ # and returns their results together in a single message.
511
+ #
512
+ # [commands] One or more commands to run in parallel. Pass multiple
513
+ # arguments or a single array.
514
+ #
515
+ # === Example
516
+ #
517
+ # # Variadic syntax
518
+ # Command.all(
519
+ # Command.http(:get, "/users", :_),
520
+ # Command.http(:get, "/stats", :_),
521
+ # )
522
+ # # Produces: [:all, [user_result, stats_result]]
523
+ def self.all(envelope, *)
524
+ All.new(envelope, *)
525
+ end
526
+
527
+ # Creates an HTTP request command.
528
+ # Supports DWIM arity - see Http.new for patterns.
529
+ def self.http(*, **)
530
+ Http.new(*, **)
531
+ end
532
+
533
+ # :nodoc:
534
+ class Wrapped < Data.define(:callable, :grace_period)
535
+ include Custom
536
+ def tea_cancellation_grace_period
537
+ grace_period || super
538
+ end
539
+
540
+ # :nodoc:
541
+ def call(out, token)
542
+ callable.call(out, token)
543
+ end
544
+ end
545
+ private_constant :Wrapped
143
546
  end
144
547
  end
145
548
  end
@@ -0,0 +1,47 @@
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 Message
11
+ # Response from Command.all aggregating parallel execution.
12
+ #
13
+ # Running multiple commands in parallel needs aggregated results. Without
14
+ # structured responses, handling mixed success/failure requires manual
15
+ # array parsing.
16
+ #
17
+ # This response collects all child results and provides predicates for
18
+ # success checking. Include Predicates for safe predicate calls.
19
+ #
20
+ # Use it to handle <tt>Command.all</tt> completions.
21
+ #
22
+ # === Example
23
+ #
24
+ # case msg
25
+ # in { type: :all, envelope: :parallel, results: }
26
+ # model.with(outputs: results)
27
+ # end
28
+ #
29
+ All = Data.define(:envelope, :results, :nested) do
30
+ include Predicates
31
+
32
+ # Returns <tt>true</tt> for all responses.
33
+ def all?
34
+ true
35
+ end
36
+
37
+ # Deconstructs for pattern matching.
38
+ #
39
+ # Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, <tt>:results</tt>,
40
+ # and <tt>:nested</tt>.
41
+ def deconstruct_keys(_keys)
42
+ { type: :all, envelope:, results:, nested: }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,63 @@
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 Message
11
+ # Response from an HTTP command.
12
+ #
13
+ # HTTP requests return status codes, bodies, and headers. Errors may occur.
14
+ # Without structured responses, handling success vs. error requires manual
15
+ # checks on raw data.
16
+ #
17
+ # This response includes predicates for common checks and deconstructs for
18
+ # pattern matching. Include Predicates for safe predicate calls on any message.
19
+ #
20
+ # Use it to handle +Command.http+ completions.
21
+ #
22
+ # === Example
23
+ #
24
+ # case msg
25
+ # in { type: :http, envelope: :users, status:, body: }
26
+ # model.with(users: JSON.parse(body))
27
+ # in { type: :http, envelope: :users, error: }
28
+ # model.with(error:)
29
+ # end
30
+ #
31
+ HttpResponse = Data.define(:envelope, :status, :body, :headers, :error) do
32
+ include Predicates
33
+
34
+ # Returns +true+ for HTTP responses.
35
+ def http?
36
+ true
37
+ end
38
+
39
+ # Returns +true+ if status is 2xx.
40
+ def success?
41
+ status&.between?(200, 299)
42
+ end
43
+
44
+ # Returns +true+ if an error occurred.
45
+ def error?
46
+ !!error
47
+ end
48
+
49
+ # Deconstructs for pattern matching.
50
+ #
51
+ # Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, and either
52
+ # <tt>:error</tt> or <tt>:status</tt>, <tt>:body</tt>, <tt>:headers</tt>.
53
+ def deconstruct_keys(_keys)
54
+ if error
55
+ { type: :http, envelope:, error: }
56
+ else
57
+ { type: :http, envelope:, status:, body:, headers: }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end