rooibos 0.5.0 → 0.6.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.
- checksums.yaml +4 -4
- data/.builds/ruby-3.2.yml +9 -5
- data/.builds/ruby-3.3.yml +9 -5
- data/.builds/ruby-3.4.yml +9 -5
- data/.builds/ruby-4.0.0.yml +9 -5
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +46 -0
- data/README.md +2 -2
- data/README.rdoc +374 -0
- data/REUSE.toml +5 -0
- data/Rakefile +1 -1
- data/doc/best_practices/forms_and_validation.md +20 -0
- data/doc/best_practices/http_workflows.md +20 -0
- data/doc/best_practices/index.md +26 -0
- data/doc/best_practices/lists_and_tables.md +20 -0
- data/doc/best_practices/modal_dialogs.md +20 -0
- data/doc/best_practices/no_stateful_widgets.md +184 -0
- data/doc/best_practices/orchestration.md +20 -0
- data/doc/best_practices/streaming_data.md +20 -0
- data/doc/contributors/design/commands_and_outlets.md +1 -1
- data/doc/contributors/documentation_plan.md +616 -0
- data/doc/contributors/documentation_stub_audit.md +112 -0
- data/doc/contributors/documentation_style.md +275 -0
- data/doc/contributors/e2e_pty.md +168 -0
- data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
- data/doc/contributors/specs/file_browser.md +789 -0
- data/doc/contributors/specs/file_browser_stories.md +774 -0
- data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
- data/doc/contributors/todo/scrollbar.md +118 -0
- data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
- data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
- data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
- data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
- data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
- data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
- data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
- data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
- data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
- data/doc/contributors/tutorial_old/12_going_further.md +20 -0
- data/doc/contributors/tutorial_old/index.md +20 -0
- data/doc/essentials/commands.md +20 -0
- data/doc/essentials/index.md +31 -0
- data/doc/essentials/messages.md +21 -0
- data/doc/essentials/models.md +21 -0
- data/doc/essentials/shortcuts.md +19 -0
- data/doc/essentials/the_elm_architecture.md +24 -0
- data/doc/essentials/the_runtime.md +21 -0
- data/doc/essentials/update_functions.md +20 -0
- data/doc/essentials/views.md +22 -0
- data/doc/getting_started/for_go_developers.md +16 -0
- data/doc/getting_started/for_python_developers.md +16 -0
- data/doc/getting_started/for_react_developers.md +17 -0
- data/doc/getting_started/index.md +52 -0
- data/doc/getting_started/install.md +20 -0
- data/doc/getting_started/quickstart.md +9 -45
- data/doc/getting_started/ruby_primer.md +19 -0
- data/doc/getting_started/why_rooibos.md +20 -0
- data/doc/index.md +79 -11
- data/doc/scaling_up/async_patterns.md +20 -0
- data/doc/scaling_up/command_composition.md +20 -0
- data/doc/scaling_up/custom_commands.md +21 -0
- data/doc/scaling_up/fractal_architecture.md +20 -0
- data/doc/scaling_up/index.md +30 -0
- data/doc/scaling_up/message_routing.md +20 -0
- data/doc/scaling_up/ractor_safety.md +20 -0
- data/doc/scaling_up/testing.md +21 -0
- data/doc/troubleshooting/common_errors.md +20 -0
- data/doc/troubleshooting/debugging.md +21 -0
- data/doc/troubleshooting/index.md +23 -0
- data/doc/troubleshooting/performance.md +20 -0
- data/doc/tutorial/01_project_setup.md +44 -0
- data/doc/tutorial/02_hello_world.md +45 -0
- data/doc/tutorial/03_static_file_list.md +44 -0
- data/doc/tutorial/04_arrow_navigation.md +47 -0
- data/doc/tutorial/05_real_files.md +45 -0
- data/doc/tutorial/06_safe_refactoring.md +21 -0
- data/doc/tutorial/07_red_first_tdd.md +26 -0
- data/doc/tutorial/08_file_metadata.md +42 -0
- data/doc/tutorial/09_text_preview.md +44 -0
- data/doc/tutorial/10_directory_tree.md +42 -0
- data/doc/tutorial/11_pane_focus.md +40 -0
- data/doc/tutorial/12_sorting.md +41 -0
- data/doc/tutorial/13_filtering.md +43 -0
- data/doc/tutorial/14_toggle_hidden.md +41 -0
- data/doc/tutorial/15_text_input_widget.md +43 -0
- data/doc/tutorial/16_rename_files.md +42 -0
- data/doc/tutorial/17_confirmation_dialogs.md +43 -0
- data/doc/tutorial/18_progress_indicators.md +43 -0
- data/doc/tutorial/19_atomic_operations.md +42 -0
- data/doc/tutorial/20_external_editor.md +42 -0
- data/doc/tutorial/21_modal_overlays.md +41 -0
- data/doc/tutorial/22_error_handling.md +43 -0
- data/doc/tutorial/23_terminal_capabilities.md +53 -0
- data/doc/tutorial/24_mouse_events.md +43 -0
- data/doc/tutorial/25_resize_events.md +43 -0
- data/doc/tutorial/26_loading_states.md +42 -0
- data/doc/tutorial/27_performance.md +43 -0
- data/doc/tutorial/28_color_schemes.md +47 -0
- data/doc/tutorial/29_configuration.md +124 -0
- data/doc/tutorial/30_going_further.md +17 -0
- data/doc/tutorial/index.md +17 -0
- data/examples/app_file_browser/app.rb +40 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
- data/examples/verify_website_first_app/app.rb +85 -0
- data/examples/verify_website_hello_mvu/app.rb +31 -0
- data/examples/widget_command_system/app.rb +15 -13
- data/exe/rooibos +10 -0
- data/generate_tutorial_stubs.rb +126 -0
- data/lib/rooibos/cli/commands/new.rb +373 -0
- data/lib/rooibos/cli/commands/run.rb +98 -0
- data/lib/rooibos/cli.rb +78 -0
- data/lib/rooibos/command/all.rb +25 -20
- data/lib/rooibos/command/batch.rb +26 -25
- data/lib/rooibos/command/custom.rb +84 -1
- data/lib/rooibos/command/http.rb +59 -55
- data/lib/rooibos/command/lifecycle.rb +5 -5
- data/lib/rooibos/command/open.rb +86 -0
- data/lib/rooibos/command/outlet.rb +105 -3
- data/lib/rooibos/command/wait.rb +5 -5
- data/lib/rooibos/command.rb +57 -74
- data/lib/rooibos/message/batch.rb +39 -0
- data/lib/rooibos/message/canceled.rb +51 -0
- data/lib/rooibos/message/error.rb +48 -0
- data/lib/rooibos/message/open.rb +30 -0
- data/lib/rooibos/message.rb +84 -4
- data/lib/rooibos/router.rb +11 -14
- data/lib/rooibos/runtime.rb +40 -43
- data/lib/rooibos/shortcuts.rb +47 -0
- data/lib/rooibos/test_helper.rb +71 -6
- data/lib/rooibos/version.rb +1 -1
- data/lib/rooibos/welcome.rb +237 -0
- data/lib/rooibos.rb +4 -3
- data/mise.toml +1 -1
- data/rbs_collection.lock.yaml +2 -2
- data/sig/concurrent.rbs +3 -0
- data/sig/gem.rbs +20 -0
- data/sig/rooibos/cli.rbs +42 -0
- data/sig/rooibos/command.rbs +48 -0
- data/sig/rooibos/message.rbs +60 -0
- data/sig/rooibos/shortcuts.rbs +14 -0
- data/sig/rooibos/test_helper.rbs +6 -2
- data/sig/rooibos/welcome.rbs +75 -0
- data/tasks/install.rake +29 -0
- data/tasks/resources/build.yml.erb +2 -0
- metadata +272 -38
- data/doc/concepts/application_architecture.md +0 -197
- data/doc/concepts/application_testing.md +0 -49
- data/doc/concepts/async_work.md +0 -164
- data/doc/concepts/commands.md +0 -530
- data/doc/concepts/message_processing.md +0 -51
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
- data/doc/contributors/WIP/implementation_plan.md +0 -409
- data/doc/contributors/WIP/init_callable_proposal.md +0 -344
- data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
- data/doc/contributors/WIP/task.md +0 -36
- data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
- data/doc/contributors/kit-no-outlet.md +0 -238
- data/doc/contributors/priorities.md +0 -38
- data/doc/images/.gitkeep +0 -0
- data/exe/.gitkeep +0 -0
- /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.
|