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


2. 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:

  1. Performance: Introspects methods on every call. For hot paths, app devs should override with a static implementation.

  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.

  3. 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

  1. Rename constants (lines 28, 34, 100):

  2. INITIAL → Init (make it a lambda: Init = -> { Model.new(...) })

  3. VIEW → View

  4. UPDATE → Update

  5. Change Tea.run call (line 128): “‘ruby # Current: RatatuiRuby::Tea.run(model: INITIAL, view: VIEW, update: UPDATE)

# Correct (Fragment-first API): RatatuiRuby::Tea.run(WidgetCommandSystem) “‘

  1. Update pattern matching in Update lambda (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 } “‘

  1. Fix variable shadowing bugs on lines 117, 121: command vs cmd


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

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