rooibos 0.5.0 → 0.6.1

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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +9 -5
  3. data/.builds/ruby-3.3.yml +9 -5
  4. data/.builds/ruby-3.4.yml +9 -5
  5. data/.builds/ruby-4.0.0.yml +9 -5
  6. data/AGENTS.md +1 -1
  7. data/CHANGELOG.md +57 -0
  8. data/README.md +2 -2
  9. data/README.rdoc +374 -0
  10. data/REUSE.toml +5 -0
  11. data/Rakefile +1 -1
  12. data/doc/best_practices/forms_and_validation.md +20 -0
  13. data/doc/best_practices/http_workflows.md +20 -0
  14. data/doc/best_practices/index.md +26 -0
  15. data/doc/best_practices/lists_and_tables.md +20 -0
  16. data/doc/best_practices/modal_dialogs.md +20 -0
  17. data/doc/best_practices/no_stateful_widgets.md +184 -0
  18. data/doc/best_practices/orchestration.md +20 -0
  19. data/doc/best_practices/streaming_data.md +20 -0
  20. data/doc/contributors/design/commands_and_outlets.md +1 -1
  21. data/doc/contributors/documentation_plan.md +616 -0
  22. data/doc/contributors/documentation_stub_audit.md +112 -0
  23. data/doc/contributors/documentation_style.md +275 -0
  24. data/doc/contributors/e2e_pty.md +168 -0
  25. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
  26. data/doc/contributors/specs/file_browser.md +789 -0
  27. data/doc/contributors/specs/file_browser_stories.md +774 -0
  28. data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
  29. data/doc/contributors/todo/scrollbar.md +118 -0
  30. data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
  31. data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
  32. data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
  33. data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
  34. data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
  35. data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
  36. data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
  37. data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
  38. data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
  39. data/doc/contributors/tutorial_old/12_going_further.md +20 -0
  40. data/doc/contributors/tutorial_old/index.md +20 -0
  41. data/doc/essentials/commands.md +20 -0
  42. data/doc/essentials/index.md +31 -0
  43. data/doc/essentials/messages.md +21 -0
  44. data/doc/essentials/models.md +21 -0
  45. data/doc/essentials/shortcuts.md +19 -0
  46. data/doc/essentials/the_elm_architecture.md +24 -0
  47. data/doc/essentials/the_runtime.md +21 -0
  48. data/doc/essentials/update_functions.md +20 -0
  49. data/doc/essentials/views.md +22 -0
  50. data/doc/getting_started/for_go_developers.md +16 -0
  51. data/doc/getting_started/for_python_developers.md +16 -0
  52. data/doc/getting_started/for_rails_developers.md +17 -0
  53. data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
  54. data/doc/getting_started/for_react_developers.md +17 -0
  55. data/doc/getting_started/index.md +52 -0
  56. data/doc/getting_started/install.md +20 -0
  57. data/doc/getting_started/quickstart.md +9 -45
  58. data/doc/getting_started/ruby_primer.md +19 -0
  59. data/doc/getting_started/why_rooibos.md +20 -0
  60. data/doc/index.md +79 -11
  61. data/doc/scaling_up/async_patterns.md +20 -0
  62. data/doc/scaling_up/command_composition.md +20 -0
  63. data/doc/scaling_up/custom_commands.md +21 -0
  64. data/doc/scaling_up/fractal_architecture.md +20 -0
  65. data/doc/scaling_up/index.md +30 -0
  66. data/doc/scaling_up/message_routing.md +20 -0
  67. data/doc/scaling_up/ractor_safety.md +20 -0
  68. data/doc/scaling_up/testing.md +21 -0
  69. data/doc/troubleshooting/common_errors.md +20 -0
  70. data/doc/troubleshooting/debugging.md +21 -0
  71. data/doc/troubleshooting/index.md +23 -0
  72. data/doc/troubleshooting/performance.md +20 -0
  73. data/doc/tutorial/01_project_setup.md +44 -0
  74. data/doc/tutorial/02_hello_world.md +45 -0
  75. data/doc/tutorial/03_static_file_list.md +44 -0
  76. data/doc/tutorial/04_arrow_navigation.md +47 -0
  77. data/doc/tutorial/05_real_files.md +45 -0
  78. data/doc/tutorial/06_safe_refactoring.md +21 -0
  79. data/doc/tutorial/07_red_first_tdd.md +26 -0
  80. data/doc/tutorial/08_file_metadata.md +42 -0
  81. data/doc/tutorial/09_text_preview.md +44 -0
  82. data/doc/tutorial/10_directory_tree.md +42 -0
  83. data/doc/tutorial/11_pane_focus.md +40 -0
  84. data/doc/tutorial/12_sorting.md +41 -0
  85. data/doc/tutorial/13_filtering.md +43 -0
  86. data/doc/tutorial/14_toggle_hidden.md +41 -0
  87. data/doc/tutorial/15_text_input_widget.md +43 -0
  88. data/doc/tutorial/16_rename_files.md +42 -0
  89. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  90. data/doc/tutorial/18_progress_indicators.md +43 -0
  91. data/doc/tutorial/19_atomic_operations.md +42 -0
  92. data/doc/tutorial/20_external_editor.md +42 -0
  93. data/doc/tutorial/21_modal_overlays.md +41 -0
  94. data/doc/tutorial/22_error_handling.md +43 -0
  95. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  96. data/doc/tutorial/24_mouse_events.md +43 -0
  97. data/doc/tutorial/25_resize_events.md +43 -0
  98. data/doc/tutorial/26_loading_states.md +42 -0
  99. data/doc/tutorial/27_performance.md +43 -0
  100. data/doc/tutorial/28_color_schemes.md +47 -0
  101. data/doc/tutorial/29_configuration.md +124 -0
  102. data/doc/tutorial/30_going_further.md +17 -0
  103. data/doc/tutorial/index.md +17 -0
  104. data/examples/app_file_browser/app.rb +40 -0
  105. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  106. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  107. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  108. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  112. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  113. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  114. data/examples/verify_website_first_app/app.rb +85 -0
  115. data/examples/verify_website_hello_mvu/app.rb +31 -0
  116. data/examples/widget_command_system/app.rb +15 -13
  117. data/exe/rooibos +10 -0
  118. data/generate_tutorial_stubs.rb +126 -0
  119. data/lib/rooibos/cli/commands/new.rb +373 -0
  120. data/lib/rooibos/cli/commands/run.rb +98 -0
  121. data/lib/rooibos/cli.rb +78 -0
  122. data/lib/rooibos/command/all.rb +76 -23
  123. data/lib/rooibos/command/batch.rb +61 -34
  124. data/lib/rooibos/command/custom.rb +84 -1
  125. data/lib/rooibos/command/http.rb +121 -55
  126. data/lib/rooibos/command/lifecycle.rb +5 -5
  127. data/lib/rooibos/command/open.rb +93 -0
  128. data/lib/rooibos/command/outlet.rb +105 -3
  129. data/lib/rooibos/command/wait.rb +9 -6
  130. data/lib/rooibos/command.rb +114 -89
  131. data/lib/rooibos/message/batch.rb +39 -0
  132. data/lib/rooibos/message/canceled.rb +51 -0
  133. data/lib/rooibos/message/error.rb +48 -0
  134. data/lib/rooibos/message/open.rb +30 -0
  135. data/lib/rooibos/message.rb +84 -4
  136. data/lib/rooibos/router.rb +11 -14
  137. data/lib/rooibos/runtime.rb +40 -43
  138. data/lib/rooibos/shortcuts.rb +47 -0
  139. data/lib/rooibos/test_helper.rb +71 -6
  140. data/lib/rooibos/version.rb +1 -1
  141. data/lib/rooibos/welcome.rb +237 -0
  142. data/lib/rooibos.rb +4 -3
  143. data/mise.toml +1 -1
  144. data/rbs_collection.lock.yaml +2 -2
  145. data/sig/concurrent.rbs +4 -0
  146. data/sig/gem.rbs +20 -0
  147. data/sig/rooibos/cli.rbs +42 -0
  148. data/sig/rooibos/command.rbs +59 -7
  149. data/sig/rooibos/message.rbs +66 -2
  150. data/sig/rooibos/shortcuts.rbs +14 -0
  151. data/sig/rooibos/test_helper.rbs +6 -2
  152. data/sig/rooibos/welcome.rbs +75 -0
  153. data/tasks/install.rake +29 -0
  154. data/tasks/resources/build.yml.erb +2 -0
  155. metadata +274 -38
  156. data/doc/concepts/application_architecture.md +0 -197
  157. data/doc/concepts/application_testing.md +0 -49
  158. data/doc/concepts/async_work.md +0 -164
  159. data/doc/concepts/commands.md +0 -530
  160. data/doc/concepts/message_processing.md +0 -51
  161. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  162. data/doc/contributors/WIP/implementation_plan.md +0 -409
  163. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  164. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  165. data/doc/contributors/WIP/task.md +0 -36
  166. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  167. data/doc/contributors/kit-no-outlet.md +0 -238
  168. data/doc/contributors/priorities.md +0 -38
  169. data/doc/images/.gitkeep +0 -0
  170. data/exe/.gitkeep +0 -0
  171. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -1,468 +0,0 @@
1
- <!--
2
- SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
- SPDX-License-Identifier: CC-BY-SA-4.0
4
- -->
5
-
6
- # v0.4.0 Remaining TODOs
7
-
8
- Outstanding items before releasing v0.4.0. Each section is self-contained with enough detail to implement independently.
9
-
10
- ---
11
-
12
- ## 1. ~~Rename `tag` → `envelope` in Command.system and Command.all~~ ✅ DONE
13
-
14
- ---
15
-
16
- ## 2. ~~Command.all Must Emit Message::All Instead of Raw Arrays~~ ✅ DONE
17
-
18
- ---
19
-
20
- # v0.4.1 Remaining TODOs
21
-
22
- Outstanding items before releasing v0.4.1. Each section is self-contained with enough detail to implement independently.
23
-
24
- ---
25
-
26
- ## 3. Add Default `deconstruct_keys` to Command::Custom
27
-
28
- ### Problem
29
- Design doc (lines 786-797) specifies that custom commands should have a default `deconstruct_keys` for hash-based pattern matching. Currently missing from `lib/rooibos/command/custom.rb`.
30
-
31
- ### Implementation
32
-
33
- Add to `lib/rooibos/command/custom.rb` in the `Custom` module:
34
-
35
- ```ruby
36
- # Methods inherited from common ancestor classes that are infrastructure,
37
- # not domain-specific query methods. Computed once from bare prototypes.
38
- INFRASTRUCTURE_METHODS = begin
39
- bare_data = Data.define(:_)
40
- bare_struct = Struct.new(:_)
41
-
42
- methods = Object.public_instance_methods +
43
- bare_data.public_instance_methods +
44
- bare_struct.public_instance_methods
45
-
46
- # Include OpenStruct only if already loaded (don't require it ourselves)
47
- if defined?(OpenStruct)
48
- methods += OpenStruct.new(_: nil).public_instance_methods
49
- end
50
-
51
- methods.uniq.freeze
52
- end
53
- private_constant :INFRASTRUCTURE_METHODS
54
-
55
- # Deconstructs for pattern matching.
56
- #
57
- # Introspects the object and builds a hash from all public query methods
58
- # (following CQS: methods with no side effects that return values).
59
- # Excludes infrastructure methods from Object, Data, and Struct to focus
60
- # on domain-specific fields.
61
- #
62
- # Always includes :type as a snake_case symbol of the class name.
63
- # Data.define members are automatically included since they generate
64
- # public accessor methods.
65
- #
66
- # This is a naive but practical default. Override for:
67
- # - Hot paths (introspects methods on every call)
68
- # - Ghost methods via method_missing/respond_to_missing?
69
- # - Methods with optional arguments (only zero-arity detected)
70
- #
71
- # === Example
72
- #
73
- # # For a Data.define(:envelope, :status, :body) command:
74
- # case msg
75
- # in { type: :http_response, envelope: :users, status: 200 }
76
- # # handle success
77
- # end
78
- def deconstruct_keys(keys)
79
- # Snake-case class name as type discriminator
80
- type_name = self.class.name.split("::").last
81
- .gsub(/([a-z])([A-Z])/, '\1_\2')
82
- .downcase
83
- .to_sym
84
-
85
- # Find public query methods: zero arity, not infrastructure, not commands/setters
86
- query_methods = (public_methods - INFRASTRUCTURE_METHODS)
87
- .select { |m| method(m).arity.zero? }
88
- .reject { |m| m.to_s.end_with?("=", "!") }
89
-
90
- # Build hash: filter by requested keys if provided
91
- result = { type: type_name }
92
- query_methods.each do |m|
93
- result[m] = public_send(m) if keys.nil? || keys.include?(m)
94
- end
95
- result
96
- end
97
- ```
98
-
99
- This provides a smart default that:
100
- - Computes infrastructure methods once from bare `Data.define`, `Struct`, and `OpenStruct` prototypes
101
- - Catches `members`, `with`, `to_h`, `deconstruct`, `deconstruct_keys`, etc. automatically
102
- - No hardcoded method lists — if Ruby adds new methods to these classes, they're excluded
103
- - Includes all Data.define members automatically (they generate accessor methods)
104
- - Excludes commands (methods ending in `!`) and setters (ending in `=`)
105
- - Respects the `keys` argument for performance (only calls methods for requested keys)
106
-
107
- ### Limitations
108
-
109
- This is a **naive but practical** default:
110
-
111
- 1. **Performance**: Introspects methods on every call. For hot paths, app devs should override with a static implementation.
112
- 2. **Metaprogramming**: Ghost methods via `method_missing`/`respond_to_missing?` don't appear in `public_methods`. RBS introspection could help but is overkill for a default.
113
- 3. **Custom accessors**: Only zero-arity methods are included. Methods with optional args won't be detected.
114
-
115
- **App devs who need more control should override `deconstruct_keys` directly.**
116
-
117
- ---
118
-
119
- ## 4. Update Doc Comments to Show Hash Pattern Matching
120
-
121
- ### Problem
122
- Doc comments in `lib/rooibos/command.rb` show array-based pattern matching (old style) instead of hash-based (new style per Appendix A).
123
-
124
- ### Files to Update
125
-
126
- **lib/rooibos/command.rb** — Lines 326-350 (Command.system examples):
127
-
128
- ```ruby
129
- # Current (wrong):
130
- # in [:got_files, {stdout:, status: 0}]
131
- # [model.with(files: stdout.lines), nil]
132
- # in [:log, :stdout, line]
133
- # [model.with(lines: [*model.lines, line]), nil]
134
-
135
- # Correct:
136
- # in { type: :system, envelope: :got_files, stdout:, status: 0 }
137
- # [model.with(files: stdout.lines), nil]
138
- # in { type: :system, envelope: :log, stream: :stdout, content: line }
139
- # [model.with(lines: [*model.lines, line]), nil]
140
- ```
141
-
142
- Also update:
143
- - `lib/rooibos.rb` lines 106, 112: Change `::UPDATE` → `::Update` in examples
144
- - `lib/rooibos/router.rb` lines 19-20, 37: Change `INITIAL`, `UPDATE`, `VIEW` → `Init`, `Update`, `View` in docstrings
145
-
146
- ---
147
-
148
- ## 5. Update widget_command_system Example
149
-
150
- ### Problem
151
- `examples/widget_command_system/app.rb` uses outdated conventions:
152
- - Screaming-case constants: `INITIAL`, `VIEW`, `UPDATE`
153
- - Old `Rooibos.run` API: `Rooibos.run(model:, view:, update:)`
154
-
155
- ### Implementation
156
-
157
- 1. Rename constants (lines 28, 34, 100):
158
- - `INITIAL` → `Init` (make it a lambda: `Init = -> { Model.new(...) }`)
159
- - `VIEW` → `View`
160
- - `UPDATE` → `Update`
161
-
162
- 2. Change `Rooibos.run` call (line 128):
163
- ```ruby
164
- # Current:
165
- Rooibos.run(model: INITIAL, view: VIEW, update: UPDATE)
166
-
167
- # Correct (Fragment-first API):
168
- Rooibos.run(WidgetCommandSystem)
169
- ```
170
-
171
- 3. Update pattern matching in `Update` lambda (lines 103-106) to use hash-based format:
172
- ```ruby
173
- # Current:
174
- in [:got_output, { stdout:, status: 0 }]
175
-
176
- # Correct:
177
- in { type: :system, envelope: :got_output, stdout:, status: 0 }
178
- ```
179
-
180
- 4. Fix variable shadowing bugs on lines 117, 121: `command` vs `cmd`
181
-
182
- ---
183
-
184
- ## 6. Rooibos.delegate Array Wrapping (Lower Priority)
185
-
186
- ### Problem
187
- Line 853 of design doc states: "Rooibos's delegate helper should probably not wrap in arrays."
188
-
189
- Currently `Rooibos.delegate` (lib/rooibos.rb:116) passes `rest = message[1..]` as an array to child update. The design suggests passing unwrapped messages.
190
-
191
- ### Consideration
192
- This may be intentional for consistency. Review whether child updates expect array or unwrapped messages before changing. If changing:
193
-
194
- ```ruby
195
- # Current:
196
- rest = message[1..]
197
- new_child, command = child_update.call(rest, child_model)
198
-
199
- # Potentially:
200
- rest = message[1] # Single value, not array
201
- new_child, command = child_update.call(rest, child_model)
202
- ```
203
-
204
- **Note:** This change may break existing code. Audit all `Rooibos.delegate` callers first.
205
-
206
- ---
207
-
208
- ## 7. Missing Tests (Technical Debt)
209
-
210
- ### Phase 3: Mixed Command Types Test
211
- Add test demonstrating `Command.batch` or `Command.all` with heterogeneous child commands:
212
-
213
- ```ruby
214
- def test_batch_with_mixed_command_types
215
- cmd = Command.batch(
216
- Command.http(:get, "/api/users", :users),
217
- Command.wait(0.1, :timer),
218
- Command.system("echo hello", :shell)
219
- )
220
- # Verify all three types execute and return properly
221
- end
222
- ```
223
-
224
- ### Phase 5: Sync→Parallel→Sync Flow Test
225
- Add test demonstrating `Outlet#source` orchestration:
226
-
227
- ```ruby
228
- def test_source_sync_parallel_sync_flow
229
- # Custom command that:
230
- # 1. source(single_command) - sync
231
- # 2. source(Command.all([...]) - parallel
232
- # 3. source(single_command) - sync again
233
- # Verify correct sequencing and result handling
234
- end
235
- ```
236
-
237
- ---
238
-
239
- ## 8. Update test_widget_command_system.rb
240
-
241
- ### Problem
242
- Uses `::UPDATE` and `::INITIAL` screaming-case (lines 14, 17, 28, 31).
243
-
244
- ### Implementation
245
- After updating the example (TODO #5), update the tests to use `::Update` and `::Init`.
246
-
247
- ---
248
-
249
- ## Verification
250
-
251
- After implementing all items, run:
252
-
253
- ```bash
254
- bundle exec rake test
255
- ```
256
-
257
- All tests should pass. The following patterns should work in update functions:
258
-
259
- ```ruby
260
- case msg
261
- in { type: :http, envelope: :users, status: 200, body: }
262
- # HTTP success
263
- in { type: :system, envelope: :files, stdout:, status: 0 }
264
- # System batch success
265
- in { type: :timer, envelope: :dismiss, elapsed: }
266
- # Timer completed
267
- in { type: :all, envelope: :dashboard, results: }
268
- # Aggregated parallel results
269
- end
270
- ```
271
-
272
- ---
273
-
274
- ## 9. Add `out.source_nonblock` for Parallel Streaming Commands
275
-
276
- ### Context
277
-
278
- Custom commands can orchestrate work using `out.source(cmd, token)` for sequential steps and `Command.all` for parallel-but-wait-for-all patterns. However, there's no way to run multiple streaming commands in parallel where each emits messages live as they arrive.
279
-
280
- ### Problem
281
-
282
- Consider a dashboard that:
283
- 1. Authenticates
284
- 2. Fetches initial data
285
- 3. Opens two Server-Sent Events (SSE) streams for live updates
286
- 4. Post-processes each stream's chunks differently
287
- 5. Emits processed chunks to the update function as they arrive
288
-
289
- With current primitives:
290
- - `out.source` blocks — can only read one stream at a time
291
- - `Command.all` waits for ALL to complete — no live streaming
292
- - `Command.batch` only works from `update`, not inside custom commands
293
- - Raw `Thread.new` causes silent hangs if threads crash (documented in `doc/concepts/async_work.md`)
294
-
295
- Developers are stuck.
296
-
297
- ### Solution
298
-
299
- Add `out.source_nonblock` to spawn async child commands that stream messages live:
300
-
301
- ```ruby
302
- # API:
303
- handle = out.source_nonblock(command, token) # messages pass directly to runtime
304
- handle = out.source_nonblock(command, token, processor) # messages go through processor first
305
- out.last(handle1, handle2, ...) # block until all handles complete
306
- ```
307
-
308
- ### Usage Example
309
-
310
- ```ruby
311
- class LoadDashboard < Data.define(:user_id, :tag)
312
- include Rooibos::Command::Custom
313
-
314
- def call(out, token)
315
- # Sequential: authenticate first
316
- auth = out.source(Authenticate.new(user_id:, tag: :_), token)
317
- return if auth.nil? || token.canceled?
318
-
319
- # Sequential: fetch initial data
320
- dashboard = out.source(
321
- Command.all(:_, [FetchProfile.new(...), FetchNotifications.new(...)]),
322
- token
323
- )
324
- return if dashboard.nil? || token.canceled?
325
- out.put(tag, dashboard.results)
326
-
327
- # Parallel streaming: two SSE connections
328
- # 5a: Pass messages directly to update function (no processing)
329
- h1 = out.source_nonblock(StreamNotifications.new(auth[:token]), token)
330
-
331
- # 5b: Post-process messages before they reach update function
332
- h2 = out.source_nonblock(
333
- StreamDashboardDeltas.new(dashboard.results[:profile][:id], auth[:token]),
334
- token,
335
- DeltaPostProcessor.new(user_id) # Ractor-shareable callable
336
- )
337
-
338
- # 5c: Block until both streams complete (or are cancelled)
339
- out.last(h1, h2)
340
- end
341
- end
342
-
343
- # The processor is a Ractor-shareable callable:
344
- DeltaPostProcessor = Data.define(:user_id) do
345
- def call(chunk, out)
346
- processed = transform(chunk)
347
- out.put(:delta, { user_id:, data: processed })
348
- end
349
-
350
- def transform(chunk)
351
- # post-processing logic
352
- end
353
- end
354
- ```
355
-
356
- ### Implementation
357
-
358
- #### 1. Add `AsyncHandle` class
359
-
360
- In `lib/rooibos/command/outlet.rb`:
361
-
362
- ```ruby
363
- AsyncHandle = Data.define(:future, :channel) do
364
- def done?
365
- future.resolved?
366
- end
367
- end
368
- ```
369
-
370
- #### 2. Add `source_nonblock` to `Outlet`
371
-
372
- ```ruby
373
- def source_nonblock(command, token, processor = nil)
374
- channel = Concurrent::Promises::Channel.new
375
-
376
- # Create outlet that writes to channel instead of parent queue
377
- child_outlet = if processor
378
- ProcessorOutlet.new(channel, processor, @message_queue)
379
- else
380
- ChannelOutlet.new(channel, @message_queue)
381
- end
382
-
383
- # Spawn child command
384
- future = Concurrent::Promises.future do
385
- command.call(child_outlet, token)
386
- channel.push(:done)
387
- rescue => e
388
- @message_queue.push Command::Error.new(command:, exception: e)
389
- channel.push(:done)
390
- end
391
-
392
- (@pending_async ||= []) << AsyncHandle.new(future:, channel:)
393
- AsyncHandle.new(future:, channel:)
394
- end
395
- ```
396
-
397
- #### 3. Add outlet variants
398
-
399
- ```ruby
400
- # Passes messages directly to runtime queue
401
- ChannelOutlet = Data.define(:channel, :message_queue) do
402
- def put(*args)
403
- message = (args.size == 1) ? args.first : args.freeze
404
- message_queue.push(message)
405
- end
406
- end
407
-
408
- # Invokes processor, which calls out.put on a Ractor-safe outlet
409
- ProcessorOutlet = Data.define(:channel, :processor, :message_queue) do
410
- def put(*args)
411
- message = (args.size == 1) ? args.first : args.freeze
412
- # Processor receives message and a simple outlet for its output
413
- output_outlet = ChannelOutlet.new(channel, message_queue)
414
- processor.call(message, output_outlet)
415
- rescue => e
416
- message_queue.push Command::Error.new(command: processor, exception: e)
417
- end
418
- end
419
- ```
420
-
421
- #### 4. Add `last` method
422
-
423
- ```ruby
424
- def last(*handles)
425
- handles = @pending_async if handles.empty?
426
- handles.each do |handle|
427
- handle.future.wait
428
- end
429
- @pending_async&.clear
430
- end
431
- ```
432
-
433
- ### Ractor Safety
434
-
435
- - **Child command**: runs in Ractor/future, must be shareable (existing requirement)
436
- - **Processor**: must be Ractor-shareable (e.g., `Data.define` with shareable members)
437
- - **Messages**: must be shareable (existing requirement)
438
- - **The block IS NOT USED**: callbacks are shareable callables, not closures
439
-
440
- The processor runs in the child's context (Ractor/future). It receives a Ractor-safe outlet that pushes to a channel. The runtime reads the channel and delivers messages — no closure crosses the Ractor boundary.
441
-
442
- ### Tests
443
-
444
- Add to `test/test_outlet.rb`:
445
-
446
- ```ruby
447
- def test_source_nonblock_streams_messages_live
448
- # Custom command that:
449
- # 1. source(single_command) - sync
450
- # 2. source_nonblock(streaming_cmd1), source_nonblock(streaming_cmd2)
451
- # 3. last(h1, h2)
452
- # Verify messages arrive interleaved as streams produce them
453
- end
454
-
455
- def test_source_nonblock_with_processor_transforms_messages
456
- # Verify processor.call is invoked for each message
457
- # Verify transformed output reaches update function
458
- end
459
-
460
- def test_source_nonblock_crash_produces_command_error
461
- # Verify child crash doesn't hang — becomes Command::Error
462
- end
463
-
464
- def test_source_nonblock_processor_crash_produces_command_error
465
- # Verify processor crash doesn't hang — becomes Command::Error
466
- end
467
- ```
468
-
@@ -1,238 +0,0 @@
1
- <!--
2
- SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
-
4
- SPDX-License-Identifier: CC-BY-SA-4.0
5
- -->
6
-
7
- # Why Kit Doesn't Need Outlet
8
-
9
- > **Context**: The `unified-draft.md` specifies `Rooibos::Command::Outlet` for message passing from custom commands to the Rooibos runtime. This document explains why Kit (the component-based runtime) does not need this abstraction.
10
-
11
- ---
12
-
13
- ## The Core Insight: Immediate-Mode Rendering
14
-
15
- RatatuiRuby's Engine is **immediate-mode**:
16
-
17
- > "Each frame, your code describes the entire UI from scratch. The Engine draws it and immediately forgets it, holding no application state between renders."
18
-
19
- This means Kit walks the entire component tree and calls `render` **every frame**. Components don't need to notify anyone of state changes—the next frame automatically sees updated state.
20
-
21
- ---
22
-
23
- ## Rooibos vs Kit: Different Problems
24
-
25
- | Concern | Rooibos | Kit |
26
- |---------|-----|-----|
27
- | **State model** | Immutable (Ractor-safe) | Mutable (encapsulated) |
28
- | **State location** | Single Model in runtime | Distributed in components |
29
- | **Async results** | Message → Queue → Update | Callback → mutate state |
30
- | **Render trigger** | After update function | **Every frame** |
31
- | **Re-render notification** | Implicit (update always renders) | **Not needed** (always renders) |
32
-
33
- ---
34
-
35
- ## WebSocket Example: Rooibos vs Kit
36
-
37
- ### Rooibos: Outlet Required
38
-
39
- ```ruby
40
-
41
- class WebSocketCommand
42
- include Rooibos::Command::Custom
43
-
44
- def call(out, token)
45
- ws = WebSocket::Client.new(@url)
46
- ws.on_message { |msg| out.put(:ws, :message, data: msg) }
47
- ws.connect
48
-
49
- until token.cancelled?
50
- sleep 1
51
- end
52
-
53
- ws.close
54
- end
55
- end
56
-
57
- def update(msg, model)
58
- case msg
59
- in [:ws, :message, data:]
60
- model.with(messages: model.messages + [data])
61
- end
62
- end
63
- ```
64
-
65
- Rooibos needs Outlet because:
66
- 1. Command runs in a separate thread
67
- 2. Model is immutable—can't mutate from callback
68
- 3. Runtime must receive messages to call `update`
69
-
70
- ### Kit: Direct Mutation
71
-
72
- ```ruby
73
- class WebSocketTab
74
- include Kit::Component
75
-
76
- def initialize(url:)
77
- @url = url
78
- @messages = []
79
- end
80
-
81
- def mount
82
- @ws = WebSocket::Client.new(@url)
83
- @ws.on_message { |msg| @messages << msg }
84
- @ws.connect_async
85
- end
86
-
87
- def unmount
88
- @ws&.close
89
- end
90
-
91
- def render(frame, area)
92
- frame.render_widget(tui.list(items: @messages.last(10)), area)
93
- end
94
- end
95
- ```
96
-
97
- Kit doesn't need Outlet because:
98
- 1. Component owns the WebSocket directly
99
- 2. State is mutable—callbacks mutate `@messages`
100
- 3. Next frame's `render` sees updated state automatically
101
-
102
- ---
103
-
104
- ## Why Each Rooibos Concept Is Unnecessary in Kit
105
-
106
- ### Outlet (Message Gateway)
107
-
108
- **Rooibos**: Routes messages from command thread → runtime queue → update function.
109
-
110
- **Kit**: Not needed. Callbacks mutate component state directly. Immediate-mode rendering sees changes next frame.
111
-
112
- ### CancellationToken
113
-
114
- **Rooibos**: Runtime signals command to stop cooperatively, since commands run in spawned threads tracked by the runtime.
115
-
116
- **Kit**: Not needed. Components have `unmount` lifecycle hook. Component stops its own resources:
117
-
118
- ```ruby
119
- def unmount
120
- @ws&.close
121
- @polling_thread&.kill
122
- end
123
- ```
124
-
125
- ### Ractor Safety
126
-
127
- **Rooibos**: Messages cross thread boundaries and must be Ractor-shareable for future Ruby 4.0 compatibility.
128
-
129
- **Kit**: Not needed. Components are mutable by design. State stays within the component. No Ractor isolation required.
130
-
131
- ### Thread Tracking
132
-
133
- **Rooibos**: Runtime tracks spawned command threads to ensure clean shutdown.
134
-
135
- **Kit**: Not needed. Each component tracks its own resources. Tree traversal during shutdown calls `unmount` on each component.
136
-
137
- ---
138
-
139
- ## What Kit DOES Need
140
-
141
- ### 1. Lifecycle Hooks
142
-
143
- ```ruby
144
- module Kit::Component
145
- def mount
146
- # Called when component enters tree
147
- end
148
-
149
- def unmount
150
- # Called when component leaves tree
151
- end
152
- end
153
- ```
154
-
155
- These replace Rooibos's command spawning and cancellation.
156
-
157
- ### 2. Thread Safety for Complex Mutations
158
-
159
- For simple mutations (array append, boolean toggle), Ruby's GIL is sufficient.
160
-
161
- For complex mutations:
162
-
163
- ```ruby
164
- def mount
165
- @mutex = Mutex.new
166
- @ws = WebSocket::Client.new(@url)
167
- @ws.on_message do |msg|
168
- @mutex.synchronize { @messages << msg }
169
- end
170
- end
171
-
172
- def render(frame, area)
173
- messages = @mutex.synchronize { @messages.dup }
174
- # render with messages
175
- end
176
- ```
177
-
178
- ### 3. Error Handling
179
-
180
- Components should rescue and surface errors:
181
-
182
- ```ruby
183
- def mount
184
- @ws = WebSocket::Client.new(@url)
185
- @ws.on_error { |e| @error = e.message }
186
- rescue => e
187
- @error = e.message
188
- end
189
- ```
190
-
191
- ---
192
-
193
- ## Comparison Table
194
-
195
- | Abstraction | Rooibos | Kit | Why Different |
196
- |-------------|-----|-----|---------------|
197
- | **Outlet** | ✓ Required | ✗ Not needed | Kit mutates directly |
198
- | **CancellationToken** | ✓ Required | ✗ Not needed | Kit has `unmount` |
199
- | **Ractor safety** | ✓ Required | ✗ Not needed | Kit is mutable |
200
- | **Thread tracking** | ✓ Runtime tracks | ✗ Not needed | Components self-manage |
201
- | **Lifecycle hooks** | ✗ Not applicable | ✓ Required | Kit needs mount/unmount |
202
- | **Mutex** | ✗ Uses Outlet | ⚠ If complex | Kit needs manual locking |
203
-
204
- ---
205
-
206
- ## Architectural Summary
207
-
208
- ```
209
- ┌─────────────────────────────────────────────────────────────────┐
210
- │ ENGINE │
211
- │ (Immediate-mode, renders every frame) │
212
- ├─────────────────────────────┬───────────────────────────────────┤
213
- │ ROOIBOS │ KIT │
214
- │ │ │
215
- │ Immutable Model │ Mutable Components │
216
- │ Commands spawn threads │ Components own resources │
217
- │ Outlet sends messages │ Callbacks mutate state │
218
- │ Runtime tracks threads │ unmount cleans up │
219
- │ Ractor-safe required │ GIL + Mutex sufficient │
220
- │ │ │
221
- │ NEEDS OUTLET │ DOESN'T NEED OUTLET │
222
- └─────────────────────────────┴───────────────────────────────────┘
223
- ```
224
-
225
- ---
226
-
227
- ## Conclusion
228
-
229
- **Outlet is Rooibos-specific.** It solves the problem of getting async results into an immutable, unidirectional data flow.
230
-
231
- Kit's paradigm—mutable components with immediate-mode rendering—makes Outlet unnecessary:
232
-
233
- 1. **Callbacks mutate state** → No message routing needed
234
- 2. **Render every frame** → No change notification needed
235
- 3. **`unmount` hook** → No external cancellation needed
236
- 4. **Components own resources** → No runtime tracking needed
237
-
238
- The Outlet stays in `Rooibos::Command::Outlet`. Kit needs no equivalent.