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
@@ -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,9 +5,14 @@
5
5
  # SPDX-License-Identifier: LGPL-3.0-or-later
6
6
  #++
7
7
 
8
- require_relative "command/cancellation_token"
8
+ require "concurrent-edge"
9
9
  require_relative "command/custom"
10
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"
11
16
 
12
17
  module RatatuiRuby
13
18
  module Tea
@@ -34,7 +39,14 @@ module RatatuiRuby
34
39
  # Sentinel value for application termination.
35
40
  #
36
41
  # The runtime detects this before dispatching. It breaks the loop immediately.
37
- 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
38
50
 
39
51
  # Creates a quit command.
40
52
  #
@@ -54,6 +66,25 @@ module RatatuiRuby
54
66
  Exit.new
55
67
  end
56
68
 
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
+
57
88
  # Sentinel value for command cancellation.
58
89
  #
59
90
  # Long-running commands (WebSocket listeners, database pollers) run until stopped.
@@ -62,7 +93,14 @@ module RatatuiRuby
62
93
  #
63
94
  # This type carries the handle (command object) to cancel. The runtime pattern-matches
64
95
  # on <tt>Command::Cancel</tt> and signals the token.
65
- Cancel = Data.define(:handle)
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
66
104
 
67
105
  # Request cancellation of a running command.
68
106
  #
@@ -84,16 +122,33 @@ module RatatuiRuby
84
122
  Cancel.new(handle:)
85
123
  end
86
124
 
87
- # Sentinel value for command errors.
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.
88
132
  #
89
- # Commands run in threads. Exceptions bubble up silently. The update function
90
- # never sees them, and backtraces in STDERR corrupt the TUI display.
133
+ # Use it to surface failures from HTTP requests, file I/O, or external processes.
91
134
  #
92
- # The runtime catches exceptions and pushes <tt>Error</tt> to the queue.
93
- # Pattern match on it in your update function.
135
+ # === Examples
94
136
  #
95
- # Analogous to <tt>Exit</tt> and <tt>Cancel</tt>.
96
- Error = Data.define(:command, :exception)
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
97
152
 
98
153
  # Creates an error sentinel.
99
154
  #
@@ -140,16 +195,8 @@ module RatatuiRuby
140
195
  # - <tt>[tag, :error, {message:}]</tt> if the command cannot start
141
196
  #
142
197
  # The <tt>status</tt> is the integer exit code (0 = success).
143
- System = Data.define(:command, :tag, :stream) do
144
- # Command identification — runtime uses this to dispatch as a command.
145
- def tea_command?
146
- true
147
- end
148
-
149
- # Grace period for cleanup after cancellation.
150
- def tea_cancellation_grace_period
151
- 0.1
152
- end
198
+ class System < Data.define(:command, :envelope, :stream)
199
+ include Custom
153
200
 
154
201
  # Returns true if streaming mode is enabled.
155
202
  def stream?
@@ -173,7 +220,13 @@ module RatatuiRuby
173
220
 
174
221
  private def batch_execution(out)
175
222
  stdout, stderr, status = Open3.capture3(command)
176
- out.put(tag, Ractor.make_shareable({ stdout:, stderr:, status: status.exitstatus }))
223
+ message = Message::System::Batch.new(
224
+ envelope:,
225
+ stdout:,
226
+ stderr:,
227
+ status: status.exitstatus
228
+ )
229
+ out.put(Ractor.make_shareable(message))
177
230
  end
178
231
 
179
232
  private def stream_execution(out, token)
@@ -181,46 +234,74 @@ module RatatuiRuby
181
234
  stdin.close
182
235
  pid = wait_thr.pid
183
236
 
184
- stdout_thread = Thread.new do
185
- stdout.each_line { |line| out.put(tag, :stdout, line.freeze) }
186
- rescue IOError
187
- # Stream closed - SIGKILL the child if still alive (forcible cleanup)
188
- begin
189
- Process.kill("KILL", pid) if wait_thr.alive?
190
- rescue Errno::ESRCH
191
- # Already dead
192
- end
193
- end
194
- stderr_thread = Thread.new do
195
- stderr.each_line { |line| out.put(tag, :stderr, line.freeze) }
196
- rescue IOError
197
- # Stream closed
198
- end
237
+ # Track which streams are still open
238
+ streams = { stdout => :stdout, stderr => :stderr }
199
239
 
200
- # Cooperative cancellation: SIGTERM when token is cancelled
201
- cancellation_watcher = Thread.new do
202
- sleep 0.01 until token.cancelled? || !wait_thr.alive?
203
- if token.cancelled? && wait_thr.alive?
240
+ until streams.empty?
241
+ # Check cancellation before blocking on IO.select
242
+ if token.canceled? && wait_thr.alive?
204
243
  begin
205
244
  Process.kill("TERM", pid)
206
245
  rescue Errno::ESRCH
207
246
  # Already dead
208
247
  end
209
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
210
283
  end
211
284
 
285
+ # Wait for process to finish
212
286
  wait_thr.join
213
287
 
214
- # Child exited; clean up threads
215
- stdout_thread.kill
216
- stderr_thread.kill
217
- cancellation_watcher.kill
218
-
219
288
  status = wait_thr.value.exitstatus
220
- out.put(tag, :complete, Ractor.make_shareable({ status: }))
289
+ msg = Message::System::Stream.new(
290
+ envelope:,
291
+ stream: :complete,
292
+ content: nil,
293
+ status:
294
+ )
295
+ out.put(Ractor.make_shareable(msg))
221
296
  end
222
297
  rescue Errno::ENOENT, Errno::EACCES => e
223
- out.put(tag, :error, Ractor.make_shareable({ message: e.message }))
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))
224
305
  end
225
306
  end
226
307
 
@@ -265,25 +346,24 @@ module RatatuiRuby
265
346
  # [model.with(loading: false, error: message), nil]
266
347
  # end
267
348
  # end
268
- def self.system(command, tag, stream: false)
269
- System.new(command:, tag:, stream:)
349
+ def self.system(command, envelope, stream: false)
350
+ System.new(command:, envelope:, stream:)
270
351
  end
271
352
 
272
353
  # Wraps another command's result with a transformation.
273
354
  #
274
- # Fractal Architecture requires composition. Child bags produce commands
275
- # with their own tags. Parent bags need those results routed back with
355
+ # Fractal Architecture requires composition. Child fragments produce commands
356
+ # with their own tags. Parent fragments need those results routed back with
276
357
  # a parent prefix. Without transformation, update functions become
277
358
  # monolithic "God Reducers" that know about every child's internals.
278
359
  #
279
360
  # This command wraps an inner command and transforms its result message.
280
- # The parent bag delegates to the child, then intercepts the result and
361
+ # The parent fragment delegates to the child, then intercepts the result and
281
362
  # adds its routing prefix. Clean separation. No coupling.
282
363
  #
283
- # Use it to compose child bags that return their own commands.
284
- Mapped = Data.define(:inner_command, :mapper) do
285
- # Command identification for runtime dispatch.
286
- def tea_command? = true
364
+ # Use it to compose child fragments that return their own commands.
365
+ class Mapped < Data.define(:inner_command, :mapper)
366
+ include Custom
287
367
 
288
368
  # Grace period delegates to inner command.
289
369
  def tea_cancellation_grace_period
@@ -293,8 +373,8 @@ module RatatuiRuby
293
373
 
294
374
  # Executes the inner command, waits for result, and transforms it.
295
375
  def call(out, token)
296
- inner_queue = Queue.new
297
- inner_outlet = Outlet.new(inner_queue)
376
+ inner_channel = Concurrent::Promises::Channel.new
377
+ inner_outlet = Outlet.new(inner_channel, lifecycle: out.live)
298
378
 
299
379
  # Dispatch inner command
300
380
  if inner_command.respond_to?(:call)
@@ -304,7 +384,7 @@ module RatatuiRuby
304
384
  end
305
385
 
306
386
  # Transform result and send
307
- inner_message = inner_queue.pop
387
+ inner_message = inner_channel.pop
308
388
  transformed = mapper.call(inner_message)
309
389
  out.put(*transformed)
310
390
  end
@@ -335,6 +415,10 @@ module RatatuiRuby
335
415
  # Reusable procs and lambdas share identity. Dispatch them twice, and
336
416
  # +Command.cancel+ would cancel both. Wrap them to get distinct handles.
337
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
+ #
338
422
  # [callable] Proc, lambda, or any object responding to +call(out, token)+.
339
423
  # If omitted, the block is used.
340
424
  # [grace_period] Cleanup time override. Default: 2.0 seconds.
@@ -346,20 +430,117 @@ module RatatuiRuby
346
430
  #
347
431
  # # With block
348
432
  # cmd = Command.custom(grace_period: 5.0) do |out, token|
349
- # until token.cancelled?
433
+ # until token.canceled?
350
434
  # out.put(:tick, Time.now)
351
435
  # sleep 1
352
436
  # end
353
437
  # end
354
438
  def self.custom(callable = nil, grace_period: nil, &block)
355
- Wrapped.new(callable: callable || block, grace_period:)
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(*, **)
356
531
  end
357
532
 
358
533
  # :nodoc:
359
- Wrapped = Data.define(:callable, :grace_period) do
534
+ class Wrapped < Data.define(:callable, :grace_period)
360
535
  include Custom
361
- def tea_cancellation_grace_period = grace_period || super
362
- def call(out, token) = callable.call(out, token)
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
363
544
  end
364
545
  private_constant :Wrapped
365
546
  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
@@ -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
+ # System command response types.
12
+ #
13
+ # Contains message types for system command results.
14
+ module System
15
+ # Response from a system command (batch mode).
16
+ #
17
+ # Shell commands capture output and status. Without structured responses,
18
+ # handling success vs error requires manual parsing of arrays.
19
+ #
20
+ # This response includes predicates for common checks and deconstructs for
21
+ # pattern matching. Include Predicates for safe predicate calls.
22
+ #
23
+ # Use it to handle <tt>Command.system</tt> completions (batch mode).
24
+ #
25
+ # === Example
26
+ #
27
+ # case msg
28
+ # in { type: :system, envelope: :build, status: 0, stdout: }
29
+ # model.with(output: stdout)
30
+ # in { type: :system, envelope: :build, status: }
31
+ # model.with(error: "Build failed with exit #{status}")
32
+ # end
33
+ #
34
+ Batch = Data.define(:envelope, :stdout, :stderr, :status) do
35
+ include Predicates
36
+
37
+ # Returns <tt>true</tt> for system responses.
38
+ def system?
39
+ true
40
+ end
41
+
42
+ # Returns <tt>true</tt> if status is 0.
43
+ def success?
44
+ status == 0
45
+ end
46
+
47
+ # Returns <tt>true</tt> if status is non-zero.
48
+ def error?
49
+ status != 0
50
+ end
51
+
52
+ # Deconstructs for pattern matching.
53
+ #
54
+ # Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, <tt>:stdout</tt>,
55
+ # <tt>:stderr</tt>, and <tt>:status</tt>.
56
+ def deconstruct_keys(_keys)
57
+ { type: :system, envelope:, stdout:, stderr:, status: }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end