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.
- checksums.yaml +4 -4
- data/AGENTS.md +42 -2
- data/CHANGELOG.md +76 -0
- data/README.md +8 -5
- data/doc/concepts/async_work.md +164 -0
- data/doc/concepts/commands.md +528 -0
- data/doc/concepts/message_processing.md +51 -0
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
- data/doc/contributors/WIP/implementation_plan.md +405 -0
- data/doc/contributors/WIP/init_callable_proposal.md +341 -0
- data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -0
- data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
- data/doc/contributors/WIP/task.md +36 -0
- data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
- data/doc/contributors/design/commands_and_outlets.md +11 -1
- data/doc/contributors/priorities.md +22 -24
- data/examples/app_fractal_dashboard/app.rb +3 -7
- data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
- data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
- data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
- data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
- data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
- data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
- data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
- data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
- data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
- data/examples/verify_readme_usage/README.md +7 -4
- data/examples/verify_readme_usage/app.rb +7 -4
- data/lib/ratatui_ruby/tea/command/all.rb +71 -0
- data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
- data/lib/ratatui_ruby/tea/command/custom.rb +1 -1
- data/lib/ratatui_ruby/tea/command/http.rb +194 -0
- data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
- data/lib/ratatui_ruby/tea/command/outlet.rb +59 -27
- data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
- data/lib/ratatui_ruby/tea/command.rb +245 -64
- data/lib/ratatui_ruby/tea/message/all.rb +47 -0
- data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
- data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
- data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
- data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
- data/lib/ratatui_ruby/tea/message.rb +40 -0
- data/lib/ratatui_ruby/tea/router.rb +11 -11
- data/lib/ratatui_ruby/tea/runtime.rb +320 -185
- data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
- data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
- data/lib/ratatui_ruby/tea/version.rb +1 -1
- data/lib/ratatui_ruby/tea.rb +44 -10
- data/rbs_collection.lock.yaml +1 -17
- data/sig/concurrent.rbs +72 -0
- data/sig/ratatui_ruby/tea/command.rbs +141 -37
- data/sig/ratatui_ruby/tea/message.rbs +123 -0
- data/sig/ratatui_ruby/tea/router.rbs +1 -1
- data/sig/ratatui_ruby/tea/runtime.rbs +39 -6
- data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
- data/sig/ratatui_ruby/tea.rbs +24 -4
- metadata +63 -11
- data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
- data/lib/ratatui_ruby/tea/command/cancellation_token.rb +0 -135
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
|
|
4
|
+
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
# Runtime Refactoring - Final Status
|
|
8
|
+
|
|
9
|
+
## ✅ Completed
|
|
10
|
+
|
|
11
|
+
1. **CHANGE LOG documented** - Breaking changes added:
|
|
12
|
+
- Runtime API signature (positional fragment, fps param, removed argv/env, renamed init to command)
|
|
13
|
+
- Model validation timing (now validates immediately after Init)
|
|
14
|
+
|
|
15
|
+
2. **RBS Signatures** - Fully updated for new signature
|
|
16
|
+
|
|
17
|
+
3. **Tea.run signatures** - All test files updated from `fragment:` to positional
|
|
18
|
+
|
|
19
|
+
4. **Model Ractor-shareability** - All tests fixed with `Ractor.make_shareable(..., copy: true)`
|
|
20
|
+
|
|
21
|
+
## ⚠️ Remaining Issues (3 errors from agent_rake)
|
|
22
|
+
|
|
23
|
+
### 1. test_runtime.rb:237 - Test body deleted by accident
|
|
24
|
+
**File**: `test/test_runtime.rb`
|
|
25
|
+
**Issue**: Deleted lines 237-245 which contained the test body for `test_init_triggers_update_before_first_event`
|
|
26
|
+
|
|
27
|
+
**Fix needed**: Restore test body. The test should validate that `command:` parameter gets dispatched at startup.
|
|
28
|
+
|
|
29
|
+
### 2. test_fragment_first_api.rb:48 - Missing with_argv helper
|
|
30
|
+
**File**: `test/test_fragment_first_api.rb`
|
|
31
|
+
**Issue**: `NoMethodError: undefined method 'with_argv'`
|
|
32
|
+
|
|
33
|
+
**Fix needed**: Add `with_argv` helper to `/Users/kerrick/Developer/ratatui_ruby/test/test_helper.rb` (already documented earlier in conversation)
|
|
34
|
+
|
|
35
|
+
### 3. test_runtime_timer.rb:155 - Cancel assertion
|
|
36
|
+
**File**: `test/test_runtime_timer.rb`
|
|
37
|
+
**Issue**: Assertion compares Cancel object vs Wait command directly
|
|
38
|
+
|
|
39
|
+
**Current**: `assert_same original_cmd, cancel_msg`
|
|
40
|
+
**Should be**: `assert_same original_cmd, cancel_msg.handle`
|
|
41
|
+
|
|
42
|
+
The sed command ran but didn't match because cancel_msg is on its own line.
|
|
43
|
+
|
|
44
|
+
## Summary
|
|
45
|
+
|
|
46
|
+
**272 runs, 779 assertions, 2 failures, 1 errors from 95+ errors**
|
|
47
|
+
Major progress! Just need to fix these 3 small issues.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
|
|
4
|
+
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
# Task: Update Tests and Docs for Runtime Refactoring
|
|
8
|
+
|
|
9
|
+
## Signature Changes
|
|
10
|
+
- [x] Update RBS signatures for new Runtime.run signature
|
|
11
|
+
- [x] Add with_argv helper to ratatui_ruby test_helper
|
|
12
|
+
- [x] Update Tea.run wrapper signature
|
|
13
|
+
|
|
14
|
+
## Test Updates
|
|
15
|
+
- [x] Update test_fragment_first_api.rb - change fragment: to positional
|
|
16
|
+
- [x] Fix argv/env tests to use with_argv/with_env helpers
|
|
17
|
+
- [x] Fix all Command.custom tests to use Ractor-shareable callables
|
|
18
|
+
- [ ] Fix test_init_triggers_update_before_first_event - still failing
|
|
19
|
+
- [ ] Fix test_fragment_first_api_passes_argv_and_env_to_init - ENV hash comparison issue
|
|
20
|
+
- [ ] Update test_snapshots.rb - change all Tea.run calls (15+ occurrences)
|
|
21
|
+
- [ ] Update test_fractal_dashboard.rb - change Tea.run calls
|
|
22
|
+
- [ ] Update all other test files using Tea.run
|
|
23
|
+
- [ ] Add test for fps: parameter
|
|
24
|
+
|
|
25
|
+
## Example Updates
|
|
26
|
+
- [ ] Update verify_readme_usage/app.rb
|
|
27
|
+
- [ ] Update app_fractal_dashboard Init callables (remove argv/env params)
|
|
28
|
+
|
|
29
|
+
## Current Status
|
|
30
|
+
**3 failures, 1 error**: Making final fixes
|
|
31
|
+
- test_init_triggers_update_before_first_event - still needs investigation
|
|
32
|
+
- test_fragment_first_api_passes_argv_and_env_to_init - ENV not being passed correctly
|
|
33
|
+
- test_cancelled_wait_acknowledges_cancellation - intermittent
|
|
34
|
+
- test_custom_accepts_block - fixing Command.custom wrapper pattern
|
|
35
|
+
|
|
36
|
+
Restructuring Command.custom tests to demonstrate automatic shareability handling.
|
|
@@ -0,0 +1,468 @@
|
|
|
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/ratatui_ruby/tea/command/custom.rb`.
|
|
30
|
+
|
|
31
|
+
### Implementation
|
|
32
|
+
|
|
33
|
+
Add to `lib/ratatui_ruby/tea/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/ratatui_ruby/tea/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/ratatui_ruby/tea/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/ratatui_ruby/tea.rb` lines 106, 112: Change `::UPDATE` → `::Update` in examples
|
|
144
|
+
- `lib/ratatui_ruby/tea/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 `Tea.run` API: `Tea.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 `Tea.run` call (line 128):
|
|
163
|
+
```ruby
|
|
164
|
+
# Current:
|
|
165
|
+
RatatuiRuby::Tea.run(model: INITIAL, view: VIEW, update: UPDATE)
|
|
166
|
+
|
|
167
|
+
# Correct (Fragment-first API):
|
|
168
|
+
RatatuiRuby::Tea.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. Tea.delegate Array Wrapping (Lower Priority)
|
|
185
|
+
|
|
186
|
+
### Problem
|
|
187
|
+
Line 853 of design doc states: "Tea's delegate helper should probably not wrap in arrays."
|
|
188
|
+
|
|
189
|
+
Currently `Tea.delegate` (lib/ratatui_ruby/tea.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 `Tea.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 RatatuiRuby::Tea::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/ratatui_ruby/tea/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
|
+
|
|
@@ -75,6 +75,16 @@ The runtime catches unhandled exceptions and pushes `Command::Error` to the queu
|
|
|
75
75
|
|
|
76
76
|
This mirrors the sentinel pattern used for `Command::Exit` and `Command::Cancel`.
|
|
77
77
|
|
|
78
|
+
**Error categorization:** We use a single `Command::Error` sentinel for all command exceptions. Distinguishing "framework bugs" vs "user bugs" at the sentinel level adds complexity with marginal benefit. Instead, exception *classes* provide the signal:
|
|
79
|
+
|
|
80
|
+
| Exception Class | Source |
|
|
81
|
+
|-----------------|--------|
|
|
82
|
+
| `RatatuiRuby::Error::Invariant` | Framework validation (debug mode) |
|
|
83
|
+
| `RatatuiRuby::Error::Internal` | Framework bugs |
|
|
84
|
+
| `ArgumentError`, `RuntimeError`, etc. | User code |
|
|
85
|
+
|
|
86
|
+
The update function can pattern-match on `error_msg.exception.class` if it needs to distinguish sources.
|
|
87
|
+
|
|
78
88
|
### 5. Ractor Readiness
|
|
79
89
|
|
|
80
90
|
This design is forward-compatible with Ruby's Ractor-based parallelism.
|
|
@@ -150,7 +160,7 @@ This design implements several established patterns from the software architectu
|
|
|
150
160
|
| Library | Pattern | Mapping |
|
|
151
161
|
|---------|---------|---------|
|
|
152
162
|
| **Redux Thunk** | Raw dispatch access | Direct queue access (rejected) |
|
|
153
|
-
| **Redux Saga** | `put()` effect dispatches actions | **Outlet
|
|
163
|
+
| **Redux Saga** | `put()` effect dispatches actions | **Outlet#put** ← adopted |
|
|
154
164
|
| **Redux Observable** | RxJS Observables | RxRuby (rejected for complexity) |
|
|
155
165
|
| **redux-loop** | Elm-style Cmd | Recursive commands |
|
|
156
166
|
|
|
@@ -5,36 +5,34 @@
|
|
|
5
5
|
|
|
6
6
|
# Feature Priorities
|
|
7
7
|
|
|
8
|
-
This document outlines the
|
|
8
|
+
This document outlines the remaining work before `ratatui_ruby-tea` reaches v1.0.0.
|
|
9
9
|
|
|
10
|
-
## 1.
|
|
10
|
+
## 1. Built-In Commands
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Six primitives remain unimplemented. See [Command Composition Design](./design/command_composition.md) for full specifications.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
| Command / Method | Purpose | Complexity |
|
|
15
|
+
|------------------|---------|------------|
|
|
16
|
+
| `Command.wait(seconds, tag)` | One-shot timer | Low |
|
|
17
|
+
| `Command.tick(interval, tag)` | Recurring timer (subscriptions) | Low |
|
|
18
|
+
| `Command.batch([...])` | Parallel execution (fire-and-forget) | Medium |
|
|
19
|
+
| `Command.all([...])` | Parallel execution (aggregating) | Medium |
|
|
20
|
+
| `Command.http(method, url, tag)` | HTTP requests via stdlib | Medium |
|
|
21
|
+
| `Outlet#source(command, token)` | Command composition | Low |
|
|
15
22
|
|
|
16
|
-
|
|
23
|
+
Implementation order follows increasing complexity. Each can be tested independently.
|
|
17
24
|
|
|
18
|
-
|
|
25
|
+
> [!NOTE]
|
|
26
|
+
> `Command.sequence` was considered but rejected. See [Rejected Alternatives](./design/command_composition.md#rejected-alternatives) for rationale.
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
## 2. Documentation
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
The README warns: "Because this gem is in pre-release, it lacks documentation."
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
Before v1.0.0:
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
**Solution:** Implement `Cmd.sequence([cmd1, cmd2, ...])`. This command executes the first command. If successful, it runs the next. If any fail, it stops. Note: This assumes commands have a standard "success/failure" result shape, or simply runs them blindly. (Design decision required: does `sequence` wait for the message, or just the execution?)
|
|
33
|
-
|
|
34
|
-
## 4. Time (Cmd.tick)
|
|
35
|
-
|
|
36
|
-
**Context:** Animations and real-time updates. A spinner rotating. A clock ticking. Evaluation metrics updating live.
|
|
37
|
-
|
|
38
|
-
**Problem:** The runtime blocks on input. If the user doesn't type or click, the screen stays frozen. You cannot implement a simple "Loading..." spinner because the frame never updates.
|
|
39
|
-
|
|
40
|
-
**Solution:** Implement `Cmd.tick(interval, tag)`. This command sleeps for the interval and then sends a message. The `update` function handles the message and returns the *same* tick command again. This creates a recursive loop, driving the frame rate independent of user input.
|
|
34
|
+
- [ ] Document all public `Command.*` factories with RDoc examples
|
|
35
|
+
- [ ] Write Quickstart guide
|
|
36
|
+
- [ ] Write Fractal Architecture guide
|
|
37
|
+
- [ ] Write Custom Commands guide (including `out.source` composition)
|
|
38
|
+
- [ ] Ensure all examples are copy-pasteable and tested
|
|
@@ -19,7 +19,7 @@ require "ratatui_ruby/tea"
|
|
|
19
19
|
# ruby app.rb helpers # Tea.route and Tea.delegate helpers
|
|
20
20
|
# ruby app.rb router # Tea::Router DSL
|
|
21
21
|
#
|
|
22
|
-
# All three share the same
|
|
22
|
+
# All three share the same fragments, Model, INITIAL, and VIEW. Only the UPDATE
|
|
23
23
|
# implementation differs. Compare the three update_*.rb files to see the
|
|
24
24
|
# progression from verbose to declarative.
|
|
25
25
|
#
|
|
@@ -31,7 +31,7 @@ require "ratatui_ruby/tea"
|
|
|
31
31
|
# ├── update_manual.rb
|
|
32
32
|
# ├── update_helpers.rb
|
|
33
33
|
# └── update_router.rb
|
|
34
|
-
#
|
|
34
|
+
# fragments/
|
|
35
35
|
# ├── system_info.rb
|
|
36
36
|
# ├── disk_usage.rb
|
|
37
37
|
# ├── ping.rb
|
|
@@ -60,8 +60,4 @@ dashboard = case mode
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
puts "Running with #{mode} UPDATE..."
|
|
63
|
-
RatatuiRuby::Tea.run(
|
|
64
|
-
model: dashboard::INITIAL,
|
|
65
|
-
view: dashboard::VIEW,
|
|
66
|
-
update: dashboard::UPDATE
|
|
67
|
-
)
|
|
63
|
+
RatatuiRuby::Tea.run(fragment: dashboard)
|