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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +42 -2
  3. data/CHANGELOG.md +76 -0
  4. data/README.md +8 -5
  5. data/doc/concepts/async_work.md +164 -0
  6. data/doc/concepts/commands.md +528 -0
  7. data/doc/concepts/message_processing.md +51 -0
  8. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  9. data/doc/contributors/WIP/implementation_plan.md +405 -0
  10. data/doc/contributors/WIP/init_callable_proposal.md +341 -0
  11. data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -0
  12. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  13. data/doc/contributors/WIP/task.md +36 -0
  14. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  15. data/doc/contributors/design/commands_and_outlets.md +11 -1
  16. data/doc/contributors/priorities.md +22 -24
  17. data/examples/app_fractal_dashboard/app.rb +3 -7
  18. data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
  19. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
  20. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
  21. data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
  22. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
  23. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  24. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
  25. data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
  26. data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
  27. data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
  28. data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
  29. data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
  30. data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
  31. data/examples/verify_readme_usage/README.md +7 -4
  32. data/examples/verify_readme_usage/app.rb +7 -4
  33. data/lib/ratatui_ruby/tea/command/all.rb +71 -0
  34. data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
  35. data/lib/ratatui_ruby/tea/command/custom.rb +1 -1
  36. data/lib/ratatui_ruby/tea/command/http.rb +194 -0
  37. data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
  38. data/lib/ratatui_ruby/tea/command/outlet.rb +59 -27
  39. data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
  40. data/lib/ratatui_ruby/tea/command.rb +245 -64
  41. data/lib/ratatui_ruby/tea/message/all.rb +47 -0
  42. data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
  43. data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
  44. data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
  45. data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
  46. data/lib/ratatui_ruby/tea/message.rb +40 -0
  47. data/lib/ratatui_ruby/tea/router.rb +11 -11
  48. data/lib/ratatui_ruby/tea/runtime.rb +320 -185
  49. data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
  50. data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
  51. data/lib/ratatui_ruby/tea/version.rb +1 -1
  52. data/lib/ratatui_ruby/tea.rb +44 -10
  53. data/rbs_collection.lock.yaml +1 -17
  54. data/sig/concurrent.rbs +72 -0
  55. data/sig/ratatui_ruby/tea/command.rbs +141 -37
  56. data/sig/ratatui_ruby/tea/message.rbs +123 -0
  57. data/sig/ratatui_ruby/tea/router.rbs +1 -1
  58. data/sig/ratatui_ruby/tea/runtime.rbs +39 -6
  59. data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
  60. data/sig/ratatui_ruby/tea.rbs +24 -4
  61. metadata +63 -11
  62. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
  63. data/lib/ratatui_ruby/tea/command/cancellation_token.rb +0 -135
@@ -0,0 +1,528 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+ # Custom Commands
6
+
7
+ ## Context
8
+
9
+ Your application needs to do work in the background. Fetch data from APIs. Query databases. Read files. Process images. These operations block. Running them on the main thread freezes the UI.
10
+
11
+ ## Problem
12
+
13
+ Callbacks create race conditions. Threads scatter state across the codebase. Managing concurrency manually is error-prone. Your update function needs to stay pure and deterministic.
14
+
15
+ ## Solution
16
+
17
+ Commands wrap background work in a protocol. The runtime dispatches them in threads. They send messages back to your update function. Your logic stays pure. The framework handles concurrency.
18
+
19
+ Use commands for HTTP requests, database queries, file I/O, or any asynchronous work.
20
+
21
+ ## Three Patterns
22
+
23
+ ### Pattern 1: Procs for One-Off Tasks
24
+
25
+ Define a lambda. Pass it to <tt>Command.custom</tt>:
26
+
27
+ <!-- SPDX-SnippetBegin -->
28
+ <!--
29
+ SPDX-FileCopyrightText: 2026 Kerrick Long
30
+ SPDX-License-Identifier: MIT-0
31
+ -->
32
+ ```ruby
33
+ fetch_data = -> (out, token) {
34
+ response = HTTParty.get("https://api.example.com/users")
35
+ out.put(:users_loaded, response.parsed_response)
36
+ }
37
+
38
+ [model, Command.custom(fetch_data)]
39
+ ```
40
+ <!-- SPDX-SnippetEnd -->
41
+
42
+ The lambda receives two arguments:
43
+
44
+ - <tt>out</tt>: An outlet to send messages back to update
45
+ - <tt>token</tt>: A cancellation token to check if work should stop
46
+
47
+ ### Pattern 2: Classes for Reusable Commands
48
+
49
+ Define a class. Include <tt>Command::Custom</tt>. Implement <tt>call</tt>:
50
+
51
+ <!-- SPDX-SnippetBegin -->
52
+ <!--
53
+ SPDX-FileCopyrightText: 2026 Kerrick Long
54
+ SPDX-License-Identifier: MIT-0
55
+ -->
56
+ ```ruby
57
+ class FetchUsers < Data.define(:url, :tag)
58
+ include RatatuiRuby::Tea::Command::Custom
59
+
60
+ def call(out, token)
61
+ response = HTTParty.get(url)
62
+ out.put(tag, response.parsed_response)
63
+ end
64
+ end
65
+
66
+ # Dispatch it
67
+ [model, FetchUsers.new(
68
+ url: "https://api.example.com/users",
69
+ tag: :users_loaded
70
+ )]
71
+ ```
72
+ <!-- SPDX-SnippetEnd -->
73
+
74
+ The <tt>Data.define</tt> base class makes your command immutable and thread-safe.
75
+
76
+ ### Pattern 3: Composition for Multi-Step Workflows
77
+
78
+ Use <tt>out.source</tt> to run child commands synchronously. Fetch one result. Use it to compose the next command:
79
+
80
+ <!-- SPDX-SnippetBegin -->
81
+ <!--
82
+ SPDX-FileCopyrightText: 2026 Kerrick Long
83
+ SPDX-License-Identifier: MIT-0
84
+ -->
85
+ ```ruby
86
+ class FetchUserWithCompany < Data.define(:user_id, :tag)
87
+ include RatatuiRuby::Tea::Command::Custom
88
+
89
+ def call(out, token)
90
+ # Step 1: Fetch user profile
91
+ user_result = out.source(
92
+ FetchUser.new(user_id: user_id, tag: :_),
93
+ token,
94
+ timeout: 10.0
95
+ )
96
+ return if user_result.nil?
97
+ return if token.canceled?
98
+
99
+ # Extract company_id from user result
100
+ user_data = user_result.last
101
+ company_id = user_data[:company_id]
102
+
103
+ # Step 2: Fetch company using ID from step 1
104
+ company_result = out.source(
105
+ FetchCompany.new(company_id: company_id, tag: :_),
106
+ token,
107
+ timeout: 10.0
108
+ )
109
+ return if token.canceled?
110
+
111
+ # Combine results
112
+ out.put(tag, {
113
+ user: user_data,
114
+ company: company_result&.last
115
+ })
116
+ end
117
+ end
118
+ ```
119
+ <!-- SPDX-SnippetEnd -->
120
+
121
+ The <tt>source</tt> method blocks until the child command sends a message. It returns <tt>nil</tt> if cancelled or timed out. Exceptions from children propagate to the parent.
122
+
123
+ Use this for sequential API calls, conditional fetches, or any workflow where step 2 depends on step 1's result.
124
+
125
+ ## The Shareability Rule
126
+
127
+ Commands run in background threads. Ruby's Ractor system requires thread-safe objects to be "shareable." This means they cannot hold mutable state.
128
+
129
+ ### For Procs: Don't Capture Locals
130
+
131
+ A proc captures variables from its surrounding scope. If those variables are mutable, the proc cannot be shared.
132
+
133
+ **❌ Wrong — Captures Mutable Variable:**
134
+
135
+ <!-- SPDX-SnippetBegin -->
136
+ <!--
137
+ SPDX-FileCopyrightText: 2026 Kerrick Long
138
+ SPDX-License-Identifier: MIT-0
139
+ -->
140
+ ```ruby
141
+ conn = DatabaseConnection.new # Created outside
142
+
143
+ fetch_users = -> (out, token) {
144
+ users = conn.query("SELECT * FROM users") # Captures 'conn'
145
+ out.put(:users, users)
146
+ }
147
+
148
+ Command.custom(fetch_users) # 💥 Fails - conn is not shareable
149
+ ```
150
+ <!-- SPDX-SnippetEnd -->
151
+
152
+ **✅ Correct — Creates Connection Inside:**
153
+
154
+ <!-- SPDX-SnippetBegin -->
155
+ <!--
156
+ SPDX-FileCopyrightText: 2026 Kerrick Long
157
+ SPDX-License-Identifier: MIT-0
158
+ -->
159
+ ```ruby
160
+ fetch_users = -> (out, token) {
161
+ conn = DatabaseConnection.new # Created inside
162
+ users = conn.query("SELECT * FROM users")
163
+ out.put(:users, users)
164
+ conn.close
165
+ }
166
+
167
+ Command.custom(fetch_users) # ✅ Works - no captured state
168
+ ```
169
+ <!-- SPDX-SnippetEnd -->
170
+
171
+ The lambda now captures nothing. It creates the connection fresh on every execution.
172
+
173
+ ### For Classes: Reference Constants
174
+
175
+ Instance methods don't create closures. They can reference constants without shareability issues.
176
+
177
+ **✅ Database Connection Pattern:**
178
+
179
+ <!-- SPDX-SnippetBegin -->
180
+ <!--
181
+ SPDX-FileCopyrightText: 2026 Kerrick Long
182
+ SPDX-License-Identifier: MIT-0
183
+ -->
184
+ ```ruby
185
+ # Create a persistent connection or connection pool
186
+ DB = Sequel.connect(ENV["DATABASE_URL"])
187
+
188
+ class FetchUsers < Data.define(:tag)
189
+ include RatatuiRuby::Tea::Command::Custom
190
+
191
+ def call(out, token)
192
+ users = DB[:users].where(active: true).all
193
+ out.put(tag, users)
194
+ end
195
+ end
196
+ ```
197
+ <!-- SPDX-SnippetEnd -->
198
+
199
+ The <tt>FetchUsers</tt> instance only holds <tt>tag</tt> (a symbol, always shareable). The <tt>call</tt> method references the global <tt>DB</tt> constant at runtime.
200
+
201
+ **✅ Pass Configuration as Attributes:**
202
+
203
+ <!-- SPDX-SnippetBegin -->
204
+ <!--
205
+ SPDX-FileCopyrightText: 2026 Kerrick Long
206
+ SPDX-License-Identifier: MIT-0
207
+ -->
208
+ ```ruby
209
+ # frozen_string_literal: true
210
+
211
+ class DatabaseQuery < Data.define(:sql, :params, :tag)
212
+ include RatatuiRuby::Tea::Command::Custom
213
+
214
+ def call(out, token)
215
+ result = DB[sql, *params].all
216
+ out.put(tag, result)
217
+ end
218
+ end
219
+
220
+ # Usage
221
+ DatabaseQuery.new(
222
+ sql: "SELECT * FROM users WHERE role = ?",
223
+ params: ["admin"].freeze,
224
+ tag: :admin_users
225
+ )
226
+ ```
227
+ <!-- SPDX-SnippetEnd -->
228
+
229
+ The query and parameters are frozen strings and symbols. The database connection is a global constant.
230
+
231
+ ## What Makes Objects Shareable?
232
+
233
+ - Frozen strings
234
+ - Symbols
235
+ - Numbers
236
+ - <tt>true</tt>, <tt>false</tt>, <tt>nil</tt>
237
+ - <tt>Data.define</tt> instances with shareable attributes
238
+ - Constants (accessed at runtime, not captured in closures)
239
+
240
+ ## What Cannot Be Shared?
241
+
242
+ - Mutable strings (<tt>String.new</tt>)
243
+ - Database connections
244
+ - File handles
245
+ - Test instance references (<tt>self</tt> in a test method)
246
+ - Any object that holds mutable state
247
+
248
+ ## Debug Mode Validation
249
+
250
+ In tests, <tt>Command.custom</tt> validates shareability. It tries to make your callable shareable. If it fails, you see:
251
+
252
+ ```
253
+ RatatuiRuby::Error::Invariant: Command.custom requires a Ractor-shareable callable.
254
+ Proc is not shareable. Use Ractor.make_shareable or define at top-level.
255
+ ```
256
+
257
+ This means your lambda captures something mutable. Common causes:
258
+
259
+ **1. Test Instance Capture**
260
+
261
+ Defining a lambda inside a test method captures <tt>self</tt>:
262
+
263
+ <!-- SPDX-SnippetBegin -->
264
+ <!--
265
+ SPDX-FileCopyrightText: 2026 Kerrick Long
266
+ SPDX-License-Identifier: MIT-0
267
+ -->
268
+ ```ruby
269
+ class MyTest < Minitest::Test
270
+ def test_command
271
+ # ❌ Lambda captures 'self' (the test instance)
272
+ cmd = Command.custom { |out, token| out.put(@result) }
273
+ end
274
+ end
275
+ ```
276
+ <!-- SPDX-SnippetEnd -->
277
+
278
+ **Solution: Define at Class Level**
279
+
280
+ <!-- SPDX-SnippetBegin -->
281
+ <!--
282
+ SPDX-FileCopyrightText: 2026 Kerrick Long
283
+ SPDX-License-Identifier: MIT-0
284
+ -->
285
+ ```ruby
286
+ class MyTest < Minitest::Test
287
+ # Lambda defined at class level doesn't capture instance
288
+ TEST_COMMAND = Command.custom(-> (out, token) {
289
+ Thread.current[:test_result] = :done
290
+ out.put(:complete)
291
+ })
292
+
293
+ def test_command
294
+ Tea.run(..., command: TEST_COMMAND)
295
+ assert_equal :done, Thread.current[:test_result]
296
+ end
297
+ end
298
+ ```
299
+ <!-- SPDX-SnippetEnd -->
300
+
301
+ **2. Mutable Closure**
302
+
303
+ Referencing a local variable from outside the lambda:
304
+
305
+ <!-- SPDX-SnippetBegin -->
306
+ <!--
307
+ SPDX-FileCopyrightText: 2026 Kerrick Long
308
+ SPDX-License-Identifier: MIT-0
309
+ -->
310
+ ```ruby
311
+ result = [] # Mutable array
312
+
313
+ cmd = Command.custom { |out, token|
314
+ result << "item" # ❌ Captures 'result'
315
+ out.put(:done)
316
+ }
317
+ ```
318
+ <!-- SPDX-SnippetEnd -->
319
+
320
+ **Solution: Use Constants or Create at Runtime**
321
+
322
+ <!-- SPDX-SnippetBegin -->
323
+ <!--
324
+ SPDX-FileCopyrightText: 2026 Kerrick Long
325
+ SPDX-License-Identifier: MIT-0
326
+ -->
327
+ ```ruby
328
+ cmd = Command.custom { |out, token|
329
+ result = [] # Created inside
330
+ result << "item"
331
+ out.put(:done, result)
332
+ }
333
+ ```
334
+ <!-- SPDX-SnippetEnd -->
335
+
336
+ ## Production Mode
337
+
338
+ In production (non-test environments), <tt>Command.custom</tt> skips validation. This avoids overhead since the framework doesn't yet use Ractors.
339
+
340
+ Validation only runs in debug mode. Catch bugs during development. Ship fast in production.
341
+
342
+ ## Cancellation
343
+
344
+ Long-running commands should check the cancellation token:
345
+
346
+ <!-- SPDX-SnippetBegin -->
347
+ <!--
348
+ SPDX-FileCopyrightText: 2026 Kerrick Long
349
+ SPDX-License-Identifier: MIT-0
350
+ -->
351
+ ```ruby
352
+ class PollAPI < Data.define(:url, :interval_seconds, :tag)
353
+ include RatatuiRuby::Tea::Command::Custom
354
+
355
+ def call(out, token)
356
+ until token.canceled?
357
+ response = HTTParty.get(url)
358
+ out.put(tag, response.parsed_response)
359
+ sleep interval_seconds
360
+ end
361
+ end
362
+ end
363
+ ```
364
+ <!-- SPDX-SnippetEnd -->
365
+
366
+ Store the command handle in your model. Cancel it when the user dismisses the view:
367
+
368
+ <!-- SPDX-SnippetBegin -->
369
+ <!--
370
+ SPDX-FileCopyrightText: 2026 Kerrick Long
371
+ SPDX-License-Identifier: MIT-0
372
+ -->
373
+ ```ruby
374
+ # Start polling
375
+ cmd = PollAPI.new(
376
+ url: "https://api.example.com/status",
377
+ interval_seconds: 30,
378
+ tag: :status_update
379
+ )
380
+ [model.with(poller: cmd), cmd]
381
+
382
+ # Later, cancel it
383
+ [model.with(poller: nil), Command.cancel(model.poller)]
384
+ ```
385
+ <!-- SPDX-SnippetEnd -->
386
+
387
+ ### Grace Periods
388
+
389
+ The <tt>grace_period</tt> controls how long the runtime waits for a command to finish after cancellation at application exit. Default is 0.1 seconds. If your command has a long grace period and ignores <tt>token.canceled?</tt>, it may prevent the application from exiting. Users will not like that.
390
+
391
+ Commands that ignore <tt>token.canceled?</tt> are orphaned (left running until process exit). The runtime uses cooperative cancellation only. It will not forcibly kill threads. Ruby, however, _will_ forcibly kill threads when the process exits.
392
+
393
+ Override the grace period for commands that need more time to clean up:
394
+
395
+ <!-- SPDX-SnippetBegin -->
396
+ <!--
397
+ SPDX-FileCopyrightText: 2026 Kerrick Long
398
+ SPDX-License-Identifier: MIT-0
399
+ -->
400
+ ```ruby
401
+ class WebSocketListener < Data.define(:url, :tag)
402
+ include RatatuiRuby::Tea::Command::Custom
403
+
404
+ def tea_cancellation_grace_period
405
+ 5.0 # Give the WS close handshake time to complete
406
+ end
407
+
408
+ def call(out, token)
409
+ ws = connect_websocket(url)
410
+
411
+ until token.canceled?
412
+ message = ws.receive
413
+ out.put(tag, message)
414
+ end
415
+
416
+ ws.close # Cleanup
417
+ end
418
+ end
419
+ ```
420
+ <!-- SPDX-SnippetEnd -->
421
+
422
+ ## Common Patterns
423
+
424
+ ### Background File Processing
425
+
426
+ <!-- SPDX-SnippetBegin -->
427
+ <!--
428
+ SPDX-FileCopyrightText: 2026 Kerrick Long
429
+ SPDX-License-Identifier: MIT-0
430
+ -->
431
+ ```ruby
432
+ class ProcessFile < Data.define(:path, :tag)
433
+ include RatatuiRuby::Tea::Command::Custom
434
+
435
+ def call(out, token)
436
+ lines = File.readlines(path)
437
+ processed = lines.map(&:strip).reject(&:empty?)
438
+ out.put(tag, processed)
439
+ end
440
+ end
441
+ ```
442
+ <!-- SPDX-SnippetEnd -->
443
+
444
+ ### Batch Operations with Progress
445
+
446
+ <!-- SPDX-SnippetBegin -->
447
+ <!--
448
+ SPDX-FileCopyrightText: 2026 Kerrick Long
449
+ SPDX-License-Identifier: MIT-0
450
+ -->
451
+ ```ruby
452
+ class BatchImport < Data.define(:items, :tag)
453
+ include RatatuiRuby::Tea::Command::Custom
454
+
455
+ def call(out, token)
456
+ items.each_with_index do |item, index|
457
+ return if token.canceled?
458
+
459
+ import_item(item)
460
+
461
+ # Send progress updates
462
+ out.put(:progress, {
463
+ current: index + 1,
464
+ total: items.size
465
+ })
466
+ end
467
+
468
+ out.put(tag, :complete)
469
+ end
470
+
471
+ private
472
+
473
+ def import_item(item)
474
+ # Your import logic
475
+ end
476
+ end
477
+ ```
478
+ <!-- SPDX-SnippetEnd -->
479
+
480
+ ### Database Query with Connection Pooling
481
+
482
+ <!-- SPDX-SnippetBegin -->
483
+ <!--
484
+ SPDX-FileCopyrightText: 2026 Kerrick Long
485
+ SPDX-License-Identifier: MIT-0
486
+ -->
487
+ ```ruby
488
+ # Connection pool (created once at app startup)
489
+ DB = Sequel.connect(
490
+ ENV["DATABASE_URL"],
491
+ max_connections: 10
492
+ )
493
+
494
+ class FetchUserProfile < Data.define(:user_id, :tag)
495
+ include RatatuiRuby::Tea::Command::Custom
496
+
497
+ def call(out, token)
498
+ user = DB[:users].where(id: user_id).first
499
+ posts = DB[:posts].where(user_id: user_id).limit(10).all
500
+
501
+ out.put(tag, { user: user, posts: posts })
502
+ end
503
+ end
504
+ ```
505
+ <!-- SPDX-SnippetEnd -->
506
+
507
+ ## Summary
508
+
509
+ **For one-off tasks:**
510
+ - Use <tt>Command.custom</tt> with lambdas
511
+ - Don't capture mutable variables
512
+ - Create resources inside the lambda
513
+
514
+ **For reusable commands:**
515
+ - Use <tt>Data.define</tt> classes
516
+ - Include <tt>Command::Custom</tt>
517
+ - Reference constants for database connections
518
+ - Pass configuration as frozen attributes
519
+
520
+ **Debug mode catches bugs:**
521
+ - Validates shareability in tests
522
+ - Provides clear error messages
523
+ - Skipped in production for performance
524
+
525
+ **Cancellation:**
526
+ - Check <tt>token.canceled?</tt> in loops
527
+ - Set <tt>grace_period</tt> for cleanup time
528
+ - Store command handles in your model
@@ -0,0 +1,51 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # Message Processing
7
+
8
+ The runtime processes messages one at a time.
9
+
10
+ You build interactive apps. Events arrive from everywhere: keyboard, mouse,
11
+ timers, HTTP responses. Coordinating concurrent results feels complex.
12
+
13
+ TEA handles the concurrency. Your `update` function handles exactly one message
14
+ per invocation. The runtime schedules everything else.
15
+
16
+ ## Recurring Ticks
17
+
18
+ For animations or polling, re-dispatch the tick in your update function:
19
+
20
+ ```ruby
21
+ def update(msg, model)
22
+ case msg
23
+ when [:tick, _elapsed]
24
+ [model.with(frame: model.frame + 1), Command.tick(0.016, :tick)]
25
+ end
26
+ end
27
+ ```
28
+
29
+ Can a tick and a keyboard event arrive at the same time?
30
+
31
+ No. The runtime serializes all messages. Each frame, it polls for user input,
32
+ calls `update` with any event it finds, and dispatches the returned command.
33
+ Then it drains the background channel, calling `update` once for each queued
34
+ message. Two events that occur in the same frame become two sequential calls.
35
+ Each returns its own command. They never collide.
36
+
37
+ ## When to Use Command.batch
38
+
39
+ Use `Command.batch` when a single message triggers multiple effects:
40
+
41
+ ```ruby
42
+ when :init
43
+ [model, Command.batch(
44
+ Command.tick(0.016, :tick),
45
+ Command.http(get: "/api/data", :loaded)
46
+ )]
47
+ ```
48
+
49
+ This differs from "two messages arrived simultaneously." Here, you send two
50
+ commands at once. Even if both complete in exactly the same time, their results
51
+ arrive as two distinct calls to `update`.