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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c83e509aceb1826713abd9e4835055c1d28397627438d3041783564195b1821b
4
- data.tar.gz: d452ab612362d0d3216101ab9d454c950a8af07afcd913c3fa8627e69eb09887
3
+ metadata.gz: fa0b4786fb2cf3f47481a630d0629da5870f2bcb5b2791a568760b3a20393724
4
+ data.tar.gz: 2e3064cfc39f24ea43ae1dc374d64b501723ca0c70d734446ba153388ee369d7
5
5
  SHA512:
6
- metadata.gz: 6c211329907d95912192da187769d97ef56142cb4b1abe87ed2ed38c61b519d31f6823ab7f1154bc930e430b5fb19f1301c9497a9c68856c3c136c1ec0f6b962
7
- data.tar.gz: 12b22830443dcc0783323bcccb8f2c1337a416cfe0cebaf89b0988d949f68e3ab73c4fbc37a6c140bc8b103465c8e467dad443654259961987dd91de7fa95767
6
+ metadata.gz: e3e093cdb72ceeb471c70e4ca2bfc8910a73229e514608a79873be5c3ab8acd414023ab0343dfc79546a8104c782a17d2dd770a6b8a054dc9e61a3646a9be5cf
7
+ data.tar.gz: af6f46ba59e0539b0d09025c63e9ca36df0035d000bced8fb4952065e5e4d275775ebdbca71afb53862063536f43b67e2574f00dd5aa53ba917af18c5e9bb560
data/AGENTS.md CHANGED
@@ -34,8 +34,8 @@ Description: Part of the RatatuiRuby ecosystem.
34
34
  ### Tea-Specific Vocabulary
35
35
 
36
36
  - **BANNED WORD: "component"** — Reserved for Kit.
37
- - **Avoid "widget" for Tea units** — "Widget" refers to Engine/Ratatui render primitives. In Tea, call them **bags**.
38
- - **Bag:** A module containing `Model`, `INITIAL`, `UPDATE`, and `VIEW` constants. Bags compose: parent bags delegate to child bags.
37
+ - **Avoid "widget" for Tea units** — "Widget" refers to Engine/Ratatui render primitives. In Tea, call them **fragments**.
38
+ - **Fragment:** A module containing `Model`, `INITIAL`, `UPDATE`, and `VIEW` constants. Fragments compose: parent fragments delegate to child fragments.
39
39
  - Use "model", "update", "view" for the MVU pattern. Use "message" (not "msg") and "command" (not "cmd").
40
40
 
41
41
  ### Ruby Standards
@@ -66,3 +66,43 @@ Before considering a task complete and returning control to the user, you **MUST
66
66
  4. **Commit Message Suggested:** You **MUST** ensure the final message to the user includes a suggested commit message block. This is NOT optional.
67
67
  - You MUST also check `git log -n1` to see the current standard AI footer ("Generated with" and "Co-Authored-By") and include it in your suggested message.
68
68
 
69
+ ## 4. Committing
70
+
71
+ - Who commits: DON'T stage (DON'T `git add`) unless explicitly instructed. DON'T commit unless explicitly instructed. DO suggest a commit message when you finish, even if not instructed..
72
+ - When: Before reporting the task as complete to the user, suggest the commit message.
73
+ - What: Consider not what you remember, but EVERYTHING in the `git diff` and `git diff --cached`.
74
+ - **Format:**
75
+ - Format: Use [Conventional Commits](https://www.conventionalcommits.org/).
76
+ - Body: Explanation if necessary (wrap at 72 chars).
77
+ - Explain why this is the implementation, as opposed to other possible implementations.
78
+ - Skip the body entirely if it's rote, a duplication of the diff, or otherwise unhelpful.
79
+ - **DON'T list the files changed or the edits made in the body.** Don't provide a bulleted list of changes. Use prose to explain the problem and the solution.
80
+ - **DON'T use markdown syntax** (no backticks, no bolding, no lists, no links). The commit message must be plain text.
81
+ - **Type conventions by directory:**
82
+ - `lib/`, `ext/`, `sig/`: Use `feat`, `fix`, `refactor`, `perf` as appropriate.
83
+ - `bin/`, `tasks/`, `.builds/`, CI/CD: Use `chore` for tooling internal to developing this gem. Use `feat`/`fix` for user-facing executables or changes that affect downstream users.
84
+ - `examples/`: Always `docs` (documentation by example).
85
+ - `test/`: Use `test` for new/changed tests, or match the type of the code being tested.
86
+ - `doc/`: Always `docs`.
87
+
88
+ ### 5. Changelog
89
+
90
+ - Follow [Semantic Versioning](https://semver.org/)
91
+ - Follow the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) specification.
92
+ - **What belongs in CHANGELOG:** Only changes that affect **application developers** or **higher-level library developers** who use or depend on `ratatui_ruby`:
93
+ - New public APIs or widget parameters
94
+ - Backwards-incompatible type signature changes, or behavioral additions to type signature changes
95
+ - Observable behavior changes (rendering, styling, layout)
96
+ - Deprecations and removals
97
+ - Breaking changes
98
+ - **What does NOT belong in CHANGELOG:** Internal or non-behavioral changes that don't affect downstream users:
99
+ - Test additions or improvements
100
+ - Documentation updates, RDoc fixes, markdown clarifications
101
+ - Refactors of internal code
102
+ - New or modified example code
103
+ - Internal tooling, CI/CD, or build configuration changes
104
+ - Code style or linting changes
105
+ - Performance improvements that affect applications
106
+ - Changelogs should be useful to downstream developers (both app and library developers), not simple restatements of diffs or commit messages.
107
+ - The Unreleased section MUST be considered "since the last git tag". Therefore, if a change was done in one commit and undone in another (both since the last tag), the second commit should remove its changelog entry.
108
+ - **Location:** New entries ALWAYS go in `## [Unreleased]`. Never edit past version sections (e.g., `## [0.4.0]`)—those are frozen history.
data/CHANGELOG.md CHANGED
@@ -21,6 +21,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
21
 
22
22
  ### Removed
23
23
 
24
+ ## [0.4.0] - 2026-01-16
25
+
26
+ ### Added
27
+
28
+ - **Timer Commands**: `Command.wait(seconds, tag)` and `Command.tick(seconds, tag)` for timed events. After the duration, sends `tag` to the update function. Responds to cancellation cooperatively — sends `Command.cancel(self)` when cancelled so you can handle it. Uses `Concurrent::Cancellation.timeout` internally. `Command.tick` is an alias for `Command.wait`; the "recurring tick" pattern is achieved by re-dispatching in the update function.
29
+
30
+ - **Command.uncancellable Factory**: Creates a fresh `Concurrent::Cancellation` that never fires. Use for commands wrapping non-cancellable blocking I/O (e.g., `Net::HTTP` requests).
31
+
32
+ - **Command.batch**: Fire-and-forget parallel execution. `Command.batch(cmd1, cmd2)` or `Command.batch([cmds])` runs children in parallel; each child sends its own messages independently. On cancellation, emits `Command.cancel(self)` so you can detect the batch was stopped. Child errors surface as `Command::Error` so you can handle failures in your update function. One failing child does not stop the others. Requires all child commands to be Ractor-shareable.
33
+
34
+ - **Command.all**: Aggregating parallel execution. `Command.all(:tag, cmd1, cmd2)` or `Command.all(:tag, [cmds])` runs children in parallel and waits for all to complete, then sends a single aggregated message. Array syntax produces `[:tag, [results]]`; variadic syntax splats results as `[:tag, result1, result2]`. On cancellation, emits `Command.cancel(self)`. Child errors surface as `Command::Error`. Requires all child commands to be Ractor-shareable.
35
+
36
+ - **Outlet#source**: Command composition for multi-step workflows. `out.source(child_command, token, timeout: 30.0)` runs a child command synchronously within a custom command, returning its result (or `nil` if cancelled/timed out). Exceptions from failed children propagate to the caller. Use this to orchestrate sequential API calls, conditional fetches, or any workflow that needs one result before starting the next.
37
+
38
+ - **Command.http**: Native HTTP client for API calls. Supports GET, POST, PUT, PATCH, DELETE with DWIM syntax: `Command.http(get: 'url')`, `Command.http(:get, 'url', :tag)`, etc. Returns hash-based `HttpResponse` with `deconstruct_keys` for pattern matching: `{ type: :http, envelope:, status:, body:, headers: }` or `{ type: :http, envelope:, error: }`. Optional `parser:` keyword invokes a callable on the response body for JSON, YAML, CSV, or custom parsing. SSL, default 10s timeout, and cancellation-before-request are supported. Parsers and parsed results must be Ractor-shareable.
39
+
40
+ - **Streaming Command Data Loss Fix**: Fixed race condition in `Command.system(stream: true)` where fast commands could lose stdout/stderr data. Reader threads now `join` instead of `kill` to ensure all output is processed before completion.
41
+
42
+ - **Message::Predicates Mixin**: Include in custom message types for safe predicate calls. Returns `false` for any unknown predicate method (ending in `?`). Enables pattern matching workflows where messages can respond to predicates like `msg.http?` or `msg.timer?` without raising `NoMethodError`. Includes `respond_to_missing?` for introspection parity.
43
+
44
+ - **Message::Timer**: Predicate-rich response type for timer commands (`Command.wait`, `Command.tick`). Includes `timer?` predicate and `deconstruct_keys` for pattern matching with `type: :timer` discriminator. Contains `envelope` (routing symbol) and `elapsed` (actual wait time in seconds).
45
+
46
+ - **Message::HttpResponse**: Moved from `Command::HttpResponse` to `Message::HttpResponse` with added predicates. Includes `http?`, `success?`, and `error?` predicates. Implements `deconstruct_keys` for pattern matching with `type: :http` discriminator.
47
+
48
+ - **Message::System::Batch**: Response type for `Command.system` (batch mode). Includes `system?`, `success?` (exit 0), and `error?` (non-zero exit) predicates. Contains `envelope`, `stdout`, `stderr`, and `status`. Implements `deconstruct_keys` for pattern matching.
49
+
50
+ - **Message::System::Stream**: Response type for `Command.system(..., stream: true)`. Includes `system?`, `stdout?`, `stderr?`, and `complete?` predicates. Contains `envelope`, `stream` type (`:stdout`, `:stderr`, `:complete`), `content` (for output lines), and `status` (for completion). Implements conditional `deconstruct_keys` based on stream type.
51
+
52
+ - **Message::All**: Response type for `Command.all` aggregating parallel execution. Includes `all?` predicate. Contains `envelope`, `results` array, and `nested` boolean. Implements `deconstruct_keys` for pattern matching.
53
+
54
+ - **Command Parameter Rename**: Renamed `tag` parameter to `envelope` across all commands for consistency: `Command.wait`, `Command.tick`, `Command.system`, and `Command.all`. These now use `envelope:` in their data definitions. Messages emit with `envelope:` for pattern matching.
55
+
56
+ - **Dependencies**: Added `concurrent-ruby` (~> 1.3) and `concurrent-ruby-edge` (~> 0.7) for robust concurrency primitives.
57
+
58
+ - **Tea.normalize_init Helper**: New `RatatuiRuby::Tea.normalize_init(result)` normalizes Init callable returns. Accepts the output of a `Fragment.Init` callable and always returns `[model, command]`. Use when composing child fragment initialization in parent fragments.
59
+
60
+ ### Changed
61
+
62
+ - **Terminology: "Bag" → "Fragment"**: Renamed Fractal Architecture units from "bags" to "fragments" throughout the codebase. A fragment is a module containing `Model`, `INITIAL`, `UPDATE`, and `VIEW` constants. Parent fragments compose child fragments via routing. The `examples/app_fractal_dashboard/bags/` directory is now `examples/app_fractal_dashboard/fragments/`. All API documentation, code comments, and examples updated to reflect this terminology change.
63
+
64
+ - **Runtime API Signature (Breaking)**: `Tea.run` signature changed:
65
+ - Fragment parameter is now **positional** instead of keyword: `Tea.run(MyApp)` instead of `Tea.run(fragment: MyApp)`
66
+ - Removed `argv:` and `env:` parameters - Runtime now automatically uses `ARGV` and `ENV` globals
67
+ - Added `fps:` parameter (default 60) for configurable frame rate
68
+ - Renamed `init:` parameter to `command:` in explicit parameters API for clarity
69
+
70
+ - **Fragment Convention Rename (Breaking)**: Fragment constants have new naming conventions:
71
+ - `INITIAL` constant → `Init` callable. The runtime calls `Init.()` to get the initial model.
72
+ - `UPDATE` constant → `Update` callable (capitalized).
73
+ - `VIEW` constant → `View` callable (capitalized).
74
+ - Init is now a lambda/callable instead of a frozen constant. This enables parameterized initialization and returning `[model, command]` tuples for initial commands.
75
+
76
+ - **Internal Method Rename (Breaking)**: `Runtime.normalize_update_result` renamed to `Runtime.normalize_update_return` for clarity. Only affects code calling private Runtime internals.
77
+
78
+ - **Command.custom Ractor Validation (Breaking)**: `Command.custom(callable)` now validates in debug mode that the callable is Ractor-shareable. Callables that capture mutable state will raise `Invariant`. Define callables at module level or use `Ractor.make_shareable`.
79
+
80
+ - **Model Validation Timing (Breaking)**: Runtime now validates model Ractor-shareability **immediately after Init returns**, not just during the Update cycle. This catches mutable models earlier, enforcing immutability at startup. Models must be frozen (`.freeze`) or use immutable data structures (`Data.define`). This is a good breaking change that prevents subtle concurrency bugs.
81
+
82
+ - **CancellationToken Replaced (Breaking)**: Custom commands now receive `Concurrent::Cancellation` instead of `CancellationToken`. The method to check cancellation changes from `token.cancelled?` (British) to `token.canceled?` (American).
83
+
84
+ - **Outlet Accepts Channel (Breaking)**: `Outlet.new` now accepts a `Concurrent::Promises::Channel` instead of `Thread::Queue`.
85
+
86
+ - **Outlet#put (Breaking)**: `out.put(msg)`, the common case now sends `msg` directly instead of `[msg]`; `out.put(a, b, c)` sends `[a, b, c]`. Previously all calls wrapped arguments in an array. Update functions that matched `when Array` may need adjustment.
87
+
88
+ - **Command.all Output (Breaking)**: `Command.all` now emits `Message::All` objects instead of raw arrays. Previously emitted `[:tag, [results]]` (nested) or `[:tag, result1, result2]` (variadic). Now emits `Message::All` with `envelope`, `results`, and `nested` fields. Use hash pattern matching: `in { type: :all, envelope:, results:, nested: }`.
89
+
90
+ ### Fixed
91
+
92
+ - **Streaming Command Data Loss**: Fixed race condition where fast commands (e.g., `echo hello`) could lose stdout/stderr messages. The streaming reader threads were being killed immediately after the child process exited, before they could finish reading buffered output. Now the runtime joins the reader threads to ensure all data is processed before sending `:complete`.
93
+
94
+ ### Removed
95
+
96
+ - **CancellationToken Class**: Removed in favor of `Concurrent::Cancellation` from concurrent-ruby-edge.
97
+ - **CancellationToken::NONE**: Removed. Use `Command.uncancellable` factory instead.
98
+
24
99
  ## [0.3.1] - 2026-01-11
25
100
 
26
101
  ### Added
@@ -115,6 +190,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
115
190
  - **First Release**: Empty release of `ratatui_ruby-tea`, a Ruby implementation of The Elm Architecture (TEA) for `ratatui_ruby`. Scaffolding generated by `ratatui_ruby-devtools`.
116
191
 
117
192
  [Unreleased]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/HEAD
193
+ [0.4.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.4.0
118
194
  [0.3.1]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.3.1
119
195
  [0.3.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.3.0
120
196
  [0.2.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.2.0
data/README.md CHANGED
@@ -20,7 +20,7 @@ Mailing List: Announcements](https://img.shields.io/badge/mailing_list-announcem
20
20
  **ratatui_ruby** is a community wrapper that is not affiliated with [the Ratatui team](https://github.com/orgs/ratatui/people).
21
21
 
22
22
  > [!WARNING]
23
- > **ratatui_ruby-tea** is currently in **PRE-RELEASE**. The API will change rapidly, even between minor and patch versions.
23
+ > **ratatui_ruby-tea** is currently in **ALPHA**. The API may change with minor versions.
24
24
 
25
25
  **[Why RatatuiRuby?](https://man.sr.ht/~kerrick/ratatui_ruby/why.md)** — Native Rust performance, zero runtime overhead, and Ruby's expressiveness. [See how we compare](https://man.sr.ht/~kerrick/ratatui_ruby/why.md) to CharmRuby, raw Rust, and Go.
26
26
 
@@ -107,9 +107,12 @@ gem install ratatui_ruby-tea
107
107
  <!-- SYNC:START:examples/verify_readme_usage/app.rb:mvu -->
108
108
  ```ruby
109
109
  Model = Data.define(:text)
110
- MODEL = Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
111
110
 
112
- VIEW = -> (model, tui) do
111
+ Init = -> do
112
+ Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
113
+ end
114
+
115
+ View = -> (model, tui) do
113
116
  tui.paragraph(
114
117
  text: model.text,
115
118
  alignment: :center,
@@ -121,7 +124,7 @@ VIEW = -> (model, tui) do
121
124
  )
122
125
  end
123
126
 
124
- UPDATE = -> (msg, model) do
127
+ Update = -> (msg, model) do
125
128
  if msg.q? || msg.ctrl_c?
126
129
  RatatuiRuby::Tea::Command.exit
127
130
  else
@@ -130,7 +133,7 @@ UPDATE = -> (msg, model) do
130
133
  end
131
134
 
132
135
  def run
133
- RatatuiRuby::Tea.run(model: MODEL, view: VIEW, update: UPDATE)
136
+ RatatuiRuby::Tea.run(VerifyReadmeUsage)
134
137
  end
135
138
  ```
136
139
  <!-- SYNC:END -->
@@ -0,0 +1,164 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+ # Async Work
6
+
7
+ ## Context
8
+
9
+ Your application does concurrent work. It fetches from multiple APIs. It reads from sockets. It processes streams. These operations overlap in time.
10
+
11
+ ## Problem
12
+
13
+ Threads are hard. Exceptions in spawned threads vanish silently. The main thread never learns what happened. Your application hangs, waiting for messages that will never arrive. Debugging this is miserable.
14
+
15
+ ## Solution
16
+
17
+ Tea handles concurrency for you. Two patterns cover nearly every case. Use them instead of raw threads.
18
+
19
+ ## Pattern 1: Command Orchestration
20
+
21
+ Compose child commands instead of spawning threads. Use <tt>out.source</tt> for sequential steps. Use <tt>Command.all</tt> for parallel steps.
22
+
23
+ <!-- SPDX-SnippetBegin -->
24
+ <!--
25
+ SPDX-FileCopyrightText: 2026 Kerrick Long
26
+ SPDX-License-Identifier: MIT-0
27
+ -->
28
+ ```ruby
29
+ class LoadDashboard < Data.define(:user_id, :tag)
30
+ include RatatuiRuby::Tea::Command::Custom
31
+
32
+ def call(out, token)
33
+ # Step 1: Authenticate (sequential - we need the token first)
34
+ auth = out.source(Authenticate.new(user_id:, tag: :_), token)
35
+ return if auth.nil? || token.canceled?
36
+
37
+ # Step 2: Fetch dashboard data in parallel, waiting for all to complete
38
+ dashboard = out.source(
39
+ Command.all(:_, [
40
+ FetchProfile.new(token: auth[:token], tag: :profile),
41
+ FetchNotifications.new(token: auth[:token], tag: :notifications),
42
+ FetchWeather.new(tag: :weather)
43
+ ]),
44
+ token
45
+ )
46
+ return if dashboard.nil? || token.canceled?
47
+
48
+ # Step 3: Send a message to the update with the dashboard data
49
+ out.put(tag, dashboard.results)
50
+ return if token.canceled?
51
+
52
+ # Step 4: Log the access (sequential - after we have data)
53
+ out.source(LogAccess.new(user_id:, tag: :_), token)
54
+ return if token.canceled?
55
+
56
+ # COMING SOON: out.source_nonblock
57
+ # Step 5: Watch for new data via HTTP Server-Sent Events, handling parallel streaming data
58
+ # 5a: Pass messages from the StreamNotifications custom command directly to LoadDashboard's out.put
59
+ notifications = out.source_nonblocki(
60
+ StreamNotifications.new(:user_id, auth[:token]),
61
+ token,
62
+ )
63
+ # 5b: Do work to the messages before sending them to the update function
64
+ deltas = out.source_nonblock(
65
+ StreamDashboardDeltas.new(dashboard.results[:profile][:id], auth[:token]),
66
+ token,
67
+ DeltaPostProcessor.new(:user_id)
68
+ )
69
+ # 5c: block this command on both async outsourced commands finishing
70
+ out.last(notifications, deltas)
71
+ return if token.canceled?
72
+
73
+ # Step 6: Log completion
74
+ out.source(LogAccess.new(user_id:, tag: :_, finished: true), token)
75
+ end
76
+ end
77
+ ```
78
+ <!-- SPDX-SnippetEnd -->
79
+
80
+ <tt>out.source</tt> blocks until the child command finishes. Pass <tt>Command.all</tt> to run children in parallel. Exceptions propagate correctly. Cancellation stops the workflow at any point.
81
+
82
+ Use this for any multi-step workflow with dependencies between stages.
83
+
84
+ ## Pattern 2: Multiplexed I/O
85
+
86
+ Read from multiple sources without threads. Ruby's <tt>IO.select</tt> waits for any of several IOs to become ready.
87
+
88
+ <!-- SPDX-SnippetBegin -->
89
+ <!--
90
+ SPDX-FileCopyrightText: 2026 Kerrick Long
91
+ SPDX-License-Identifier: MIT-0
92
+ -->
93
+ ```ruby
94
+ class MultiSocketReader < Data.define(:sockets, :tag)
95
+ include RatatuiRuby::Tea::Command::Custom
96
+
97
+ def call(out, token)
98
+ remaining = sockets.dup
99
+
100
+ until remaining.empty? || token.canceled?
101
+ # Wait up to 0.1s for any socket to have data
102
+ ready = IO.select(remaining, nil, nil, 0.1)
103
+ next unless ready
104
+
105
+ ready[0].each do |socket|
106
+ data = socket.read_nonblock(4096, exception: false)
107
+ case data
108
+ when :wait_readable
109
+ next
110
+ when nil
111
+ remaining.delete(socket)
112
+ else
113
+ out.put(:data, { socket: socket, chunk: data })
114
+ end
115
+ end
116
+ end
117
+
118
+ out.put(tag, :complete)
119
+ end
120
+ end
121
+ ```
122
+ <!-- SPDX-SnippetEnd -->
123
+
124
+ <tt>IO.select</tt> multiplexes reads across sockets, pipes, or files. One thread handles many connections. No spawned threads means no silent failures.
125
+
126
+ Use this for chat clients, log tailers, or any multi-stream scenario.
127
+
128
+ ## Why Not Threads?
129
+
130
+ You might wonder: "Why can't I just spawn a thread?"
131
+
132
+ <!-- SPDX-SnippetBegin -->
133
+ <!--
134
+ SPDX-FileCopyrightText: 2026 Kerrick Long
135
+ SPDX-License-Identifier: MIT-0
136
+ -->
137
+ ```ruby
138
+ # ❌ Don't do this
139
+ def call(out, token)
140
+ Thread.new do
141
+ data = fetch_something
142
+ out.put(:result, data)
143
+ end
144
+ end
145
+ ```
146
+ <!-- SPDX-SnippetEnd -->
147
+
148
+ This looks harmless. It hides a trap.
149
+
150
+ If <tt>fetch_something</tt> raises, the exception happens in the spawned thread. Ruby logs it. The main thread never sees it. Your <tt>call</tt> method returns. The runtime considers the command complete. But <tt>out.put</tt> never ran. Your update function waits for <tt>:result</tt> forever.
151
+
152
+ The runtime cannot protect you from this. Threads spawned inside your command escape its error handling. The framework wraps <tt>call</tt> in a rescue. It does not wrap threads you create.
153
+
154
+ Use the patterns above instead. They keep errors visible.
155
+
156
+ ## Choosing the Right Pattern
157
+
158
+ | Situation | Pattern |
159
+ |-----------|---------|
160
+ | Multi-step workflows | <tt>out.source</tt> + <tt>Command.all</tt> |
161
+ | Read from multiple sockets/pipes | <tt>IO.select</tt> |
162
+ | One blocking operation | Just do it in <tt>call</tt> |
163
+
164
+ Commands already run off the main thread. You rarely need additional concurrency. When you do, these patterns handle the hard parts.