v0.4.0 Remaining TODOs
Outstanding items before releasing v0.4.0. Each section is self-contained with enough detail to implement independently.
1. Rename tag → envelope in Command.system and Command.all ✅ DONE
tag → envelope in Command.system and Command.all2. Command.all Must Emit Message::All Instead of Raw Arrays ✅ DONE
v0.4.1 Remaining TODOs
Outstanding items before releasing v0.4.1. Each section is self-contained with enough detail to implement independently.
3. Add Default deconstruct_keys to Command::Custom
Problem
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.
Implementation
Add to lib/ratatui_ruby/tea/command/custom.rb in the Custom module:
# Methods inherited from common ancestor classes that are infrastructure, # not domain-specific query methods. Computed once from bare prototypes. INFRASTRUCTURE_METHODS = begin bare_data = Data.define(:_) bare_struct = Struct.new(:_) methods = Object.public_instance_methods + bare_data.public_instance_methods + bare_struct.public_instance_methods # Include OpenStruct only if already loaded (don't require it ourselves) if defined?(OpenStruct) methods += OpenStruct.new(_: nil).public_instance_methods end methods.uniq.freeze end private_constant :INFRASTRUCTURE_METHODS # Deconstructs for pattern matching. # # Introspects the object and builds a hash from all public query methods # (following CQS: methods with no side effects that return values). # Excludes infrastructure methods from Object, Data, and Struct to focus # on domain-specific fields. # # Always includes :type as a snake_case symbol of the class name. # Data.define members are automatically included since they generate # public accessor methods. # # This is a naive but practical default. Override for: # - Hot paths (introspects methods on every call) # - Ghost methods via method_missing/respond_to_missing? # - Methods with optional arguments (only zero-arity detected) # # === Example # # # For a Data.define(:envelope, :status, :body) command: # case msg # in { type: :http_response, envelope: :users, status: 200 } # # handle success # end def deconstruct_keys(keys) # Snake-case class name as type discriminator type_name = self.class.name.split("::").last .gsub(/([a-z])([A-Z])/, '\1_\2') .downcase .to_sym # Find public query methods: zero arity, not infrastructure, not commands/setters query_methods = (public_methods - INFRASTRUCTURE_METHODS) .select { |m| method(m).arity.zero? } .reject { |m| m.to_s.end_with?("=", "!") } # Build hash: filter by requested keys if provided result = { type: type_name } query_methods.each do |m| result[m] = public_send(m) if keys.nil? || keys.include?(m) end result end
This provides a smart default that: - Computes infrastructure methods once from bare Data.define, Struct, and OpenStruct prototypes - Catches members, with, to_h, deconstruct, deconstruct_keys, etc. automatically - No hardcoded method lists — if Ruby adds new methods to these classes, they’re excluded - Includes all Data.define members automatically (they generate accessor methods) - Excludes commands (methods ending in !) and setters (ending in =) - Respects the keys argument for performance (only calls methods for requested keys)
Limitations
This is a naive but practical default:
-
Performance: Introspects methods on every call. For hot paths, app devs should override with a static implementation.
-
Metaprogramming: Ghost methods via
method_missing/respond_to_missing?don’t appear inpublic_methods. RBS introspection could help but is overkill for a default. -
Custom accessors: Only zero-arity methods are included. Methods with optional args won’t be detected.
App devs who need more control should override deconstruct_keys directly.
4. Update Doc Comments to Show Hash Pattern Matching
Problem
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).
Files to Update
lib/ratatui_ruby/tea/command.rb — Lines 326-350 (Command.system examples):
# Current (wrong): # in [:got_files, {stdout:, status: 0}] # [model.with(files: stdout.lines), nil] # in [:log, :stdout, line] # [model.with(lines: [*model.lines, line]), nil] # Correct: # in { type: :system, envelope: :got_files, stdout:, status: 0 } # [model.with(files: stdout.lines), nil] # in { type: :system, envelope: :log, stream: :stdout, content: line } # [model.with(lines: [*model.lines, line]), nil]
Also update: - lib/ratatui_ruby/tea.rb lines 106, 112: Change ::UPDATE → ::Update in examples - lib/ratatui_ruby/tea/router.rb lines 19-20, 37: Change INITIAL, UPDATE, VIEW → Init, Update, View in docstrings
5. Update widget_command_system Example
Problem
examples/widget_command_system/app.rb uses outdated conventions: - Screaming-case constants: INITIAL, VIEW, UPDATE - Old Tea.run API: Tea.run(model:, view:, update:)
Implementation
-
Rename constants (lines 28, 34, 100):
-
INITIAL→Init(make it a lambda:Init = -> { Model.new(...) }) -
VIEW→View -
UPDATE→Update -
Change
Tea.runcall (line 128): “‘ruby # Current:RatatuiRuby::Tea.run(model: INITIAL, view: VIEW, update: UPDATE)
# Correct (Fragment-first API): RatatuiRuby::Tea.run(WidgetCommandSystem) “‘
-
Update pattern matching in
Updatelambda (lines 103-106) to use hash-based format: “‘ruby # Current: in [:got_output, { stdout:, status: 0 }]
# Correct: in { type: :system, envelope: :got_output, stdout:, status: 0 } “‘
-
Fix variable shadowing bugs on lines 117, 121:
commandvscmd
6. Tea.delegate Array Wrapping (Lower Priority)
Problem
Line 853 of design doc states: “Tea’s delegate helper should probably not wrap in arrays.”
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.
Consideration
This may be intentional for consistency. Review whether child updates expect array or unwrapped messages before changing. If changing:
# Current: rest = message[1..] new_child, command = child_update.call(rest, child_model) # Potentially: rest = message[1] # Single value, not array new_child, command = child_update.call(rest, child_model)
Note: This change may break existing code. Audit all Tea.delegate callers first.
7. Missing Tests (Technical Debt)
Phase 3: Mixed Command Types Test
Add test demonstrating Command.batch or Command.all with heterogeneous child commands:
def test_batch_with_mixed_command_types cmd = Command.batch( Command.http(:get, "/api/users", :users), Command.wait(0.1, :timer), Command.system("echo hello", :shell) ) # Verify all three types execute and return properly end
Phase 5: Sync→Parallel→Sync Flow Test
Add test demonstrating Outlet#source orchestration:
def test_source_sync_parallel_sync_flow # Custom command that: # 1. source(single_command) - sync # 2. source(Command.all([...]) - parallel # 3. source(single_command) - sync again # Verify correct sequencing and result handling end
8. Update test_widget_command_system.rb
Problem
Uses ::UPDATE and ::INITIAL screaming-case (lines 14, 17, 28, 31).
Implementation
After updating the example (TODO #5), update the tests to use ::Update and ::Init.
Verification
After implementing all items, run:
bundle exec rake test
All tests should pass. The following patterns should work in update functions:
case msg in { type: :http, envelope: :users, status: 200, body: } # HTTP success in { type: :system, envelope: :files, stdout:, status: 0 } # System batch success in { type: :timer, envelope: :dismiss, elapsed: } # Timer completed in { type: :all, envelope: :dashboard, results: } # Aggregated parallel results end
9. Add out.source_nonblock for Parallel Streaming Commands
Context
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.
Problem
Consider a dashboard that: 1. Authenticates 2. Fetches initial data 3. Opens two Server-Sent Events (SSE) streams for live updates 4. Post-processes each stream’s chunks differently 5. Emits processed chunks to the update function as they arrive
With current primitives: - out.source blocks — can only read one stream at a time - Command.all waits for ALL to complete — no live streaming - Command.batch only works from update, not inside custom commands - Raw Thread.new causes silent hangs if threads crash (documented in doc/concepts/async_work.md)
Developers are stuck.
Solution
Add out.source_nonblock to spawn async child commands that stream messages live:
# API: handle = out.source_nonblock(command, token) # messages pass directly to runtime handle = out.source_nonblock(command, token, processor) # messages go through processor first out.last(handle1, handle2, ...) # block until all handles complete
Usage Example
class LoadDashboard < Data.define(:user_id, :tag) include RatatuiRuby::Tea::Command::Custom def call(out, token) # Sequential: authenticate first auth = out.source(Authenticate.new(user_id:, tag: :_), token) return if auth.nil? || token.canceled? # Sequential: fetch initial data dashboard = out.source( Command.all(:_, [FetchProfile.new(...), FetchNotifications.new(...)]), token ) return if dashboard.nil? || token.canceled? out.put(tag, dashboard.results) # Parallel streaming: two SSE connections # 5a: Pass messages directly to update function (no processing) h1 = out.source_nonblock(StreamNotifications.new(auth[:token]), token) # 5b: Post-process messages before they reach update function h2 = out.source_nonblock( StreamDashboardDeltas.new(dashboard.results[:profile][:id], auth[:token]), token, DeltaPostProcessor.new(user_id) # Ractor-shareable callable ) # 5c: Block until both streams complete (or are cancelled) out.last(h1, h2) end end # The processor is a Ractor-shareable callable: DeltaPostProcessor = Data.define(:user_id) do def call(chunk, out) processed = transform(chunk) out.put(:delta, { user_id:, data: processed }) end def transform(chunk) # post-processing logic end end
Implementation
1. Add AsyncHandle class
In lib/ratatui_ruby/tea/command/outlet.rb:
AsyncHandle = Data.define(:future, :channel) do def done? future.resolved? end end
2. Add source_nonblock to Outlet
def source_nonblock(command, token, processor = nil) channel = Concurrent::Promises::Channel.new # Create outlet that writes to channel instead of parent queue child_outlet = if processor ProcessorOutlet.new(channel, processor, @message_queue) else ChannelOutlet.new(channel, @message_queue) end # Spawn child command future = Concurrent::Promises.future do command.call(child_outlet, token) channel.push(:done) rescue => e @message_queue.push Command::Error.new(command:, exception: e) channel.push(:done) end (@pending_async ||= []) << AsyncHandle.new(future:, channel:) AsyncHandle.new(future:, channel:) end
3. Add outlet variants
# Passes messages directly to runtime queue ChannelOutlet = Data.define(:channel, :message_queue) do def put(*args) message = (args.size == 1) ? args.first : args.freeze message_queue.push(message) end end # Invokes processor, which calls out.put on a Ractor-safe outlet ProcessorOutlet = Data.define(:channel, :processor, :message_queue) do def put(*args) message = (args.size == 1) ? args.first : args.freeze # Processor receives message and a simple outlet for its output output_outlet = ChannelOutlet.new(channel, message_queue) processor.call(message, output_outlet) rescue => e message_queue.push Command::Error.new(command: processor, exception: e) end end
4. Add last method
def last(*handles) handles = @pending_async if handles.empty? handles.each do |handle| handle.future.wait end @pending_async&.clear end
Ractor Safety
-
Child command: runs in Ractor/future, must be shareable (existing requirement)
-
Processor: must be Ractor-shareable (e.g.,
Data.definewith shareable members) -
Messages: must be shareable (existing requirement)
-
The block IS NOT USED: callbacks are shareable callables, not closures
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.
Tests
Add to test/test_outlet.rb:
def test_source_nonblock_streams_messages_live # Custom command that: # 1. source(single_command) - sync # 2. source_nonblock(streaming_cmd1), source_nonblock(streaming_cmd2) # 3. last(h1, h2) # Verify messages arrive interleaved as streams produce them end def test_source_nonblock_with_processor_transforms_messages # Verify processor.call is invoked for each message # Verify transformed output reaches update function end def test_source_nonblock_crash_produces_command_error # Verify child crash doesn't hang — becomes Command::Error end def test_source_nonblock_processor_crash_produces_command_error # Verify processor crash doesn't hang — becomes Command::Error end