ratatui_ruby-tea 0.3.0 → 0.3.1

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.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ module Tea
10
+ module Command
11
+ # Cooperative cancellation mechanism for long-running commands.
12
+ #
13
+ # Long-running commands block the event loop. Commands that poll, stream, or wait
14
+ # indefinitely prevent clean shutdown. Killing threads mid-operation corrupts state.
15
+ #
16
+ # This class signals cancellation requests. Commands check +cancelled?+ periodically
17
+ # and stop gracefully. The runtime calls +cancel!+ when shutdown begins.
18
+ #
19
+ # Use it to implement WebSocket handlers, database pollers, or any command that loops.
20
+ #
21
+ # === Example
22
+ #
23
+ #--
24
+ # SPDX-SnippetBegin
25
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
26
+ # SPDX-License-Identifier: MIT-0
27
+ #++
28
+ # class PollerCommand
29
+ # include Tea::Command::Custom
30
+ #
31
+ # def call(out, token)
32
+ # until token.cancelled?
33
+ # data = fetch_batch
34
+ # out.put(:batch, data)
35
+ # sleep 5
36
+ # end
37
+ # out.put(:poller_stopped)
38
+ # end
39
+ # end
40
+ #--
41
+ # SPDX-SnippetEnd
42
+ #++
43
+ class CancellationToken
44
+ # Number of times +cancel!+ has been called. :nodoc:
45
+ #
46
+ # Exposed for testing thread-safety. Not part of the public API.
47
+ attr_reader :cancel_count
48
+
49
+ # Creates a new cancellation token in the non-cancelled state.
50
+ def initialize
51
+ @cancel_count = 0
52
+ @mutex = Mutex.new
53
+ end
54
+
55
+ # Signals cancellation. Thread-safe.
56
+ #
57
+ # Call this to request the command stop. The command checks +cancelled?+
58
+ # and stops at the next safe point.
59
+ #
60
+ # === Example
61
+ #
62
+ #--
63
+ # SPDX-SnippetBegin
64
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
65
+ # SPDX-License-Identifier: MIT-0
66
+ #++
67
+ # token = CancellationToken.new
68
+ # token.cancel!
69
+ # token.cancelled? # => true
70
+ #--
71
+ # SPDX-SnippetEnd
72
+ #++
73
+ def cancel!
74
+ @mutex.synchronize do
75
+ current = @cancel_count
76
+ sleep 0 # Force context switch (enables thread-safety testing)
77
+ @cancel_count = current + 1
78
+ end
79
+ end
80
+
81
+ # Checks if cancellation was requested. Thread-safe.
82
+ #
83
+ # Commands call this periodically in their main loop. When it returns
84
+ # <tt>true</tt>, the command should clean up and exit.
85
+ #
86
+ # === Example
87
+ #
88
+ #--
89
+ # SPDX-SnippetBegin
90
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
91
+ # SPDX-License-Identifier: MIT-0
92
+ #++
93
+ # until token.cancelled?
94
+ # do_work
95
+ # sleep 1
96
+ # end
97
+ #--
98
+ # SPDX-SnippetEnd
99
+ #++
100
+ def cancelled?
101
+ @cancel_count > 0
102
+ end
103
+
104
+ # Null object for commands that ignore cancellation.
105
+ #
106
+ # Some commands complete quickly and do not check for cancellation.
107
+ # Pass this when the command signature requires a token but the
108
+ # command does not use it.
109
+ #
110
+ # Ractor-shareable. Calling <tt>cancel!</tt> does nothing.
111
+ class NoneToken < Data.define(:cancelled?)
112
+ # Does nothing. Ignores cancellation requests.
113
+ #
114
+ # === Example
115
+ #
116
+ #--
117
+ # SPDX-SnippetBegin
118
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
119
+ # SPDX-License-Identifier: MIT-0
120
+ #++
121
+ # CancellationToken::NONE.cancel! # => nil (no effect)
122
+ #--
123
+ # SPDX-SnippetEnd
124
+ #++
125
+ def cancel!
126
+ nil
127
+ end
128
+ end
129
+
130
+ # Singleton null token. Always returns <tt>cancelled? == false</tt>.
131
+ NONE = NoneToken.new(cancelled?: false)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ module Tea
10
+ module Command
11
+ # Mixin for user-defined custom commands.
12
+ #
13
+ # Custom commands extend Tea with side effects: WebSockets, gRPC, database polls,
14
+ # background tasks. The runtime dispatches them in threads and routes results
15
+ # back as messages.
16
+ #
17
+ # Include this module to identify your class as a command. The runtime uses
18
+ # +tea_command?+ to distinguish commands from plain models. Override
19
+ # +tea_cancellation_grace_period+ if your cleanup takes longer than 100 milliseconds.
20
+ #
21
+ # Use it to build real-time features, long-polling connections, or background workers.
22
+ #
23
+ # === Example
24
+ #
25
+ #--
26
+ # SPDX-SnippetBegin
27
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
28
+ # SPDX-License-Identifier: MIT-0
29
+ #++
30
+ # class WebSocketCommand
31
+ # include RatatuiRuby::Tea::Command::Custom
32
+ #
33
+ # def initialize(url)
34
+ # @url = url
35
+ # end
36
+ #
37
+ # def call(out, token)
38
+ # ws = WebSocket::Client.new(@url)
39
+ # ws.on_message { |msg| out.put(:ws_message, msg) }
40
+ # ws.connect
41
+ #
42
+ # until token.cancelled?
43
+ # ws.ping
44
+ # sleep 1
45
+ # end
46
+ #
47
+ # ws.close
48
+ # end
49
+ #
50
+ # # WebSocket close handshake needs extra time
51
+ # def tea_cancellation_grace_period
52
+ # 5.0
53
+ # end
54
+ # end
55
+ #--
56
+ # SPDX-SnippetEnd
57
+ #++
58
+ module Custom
59
+ # Brand predicate for command identification.
60
+ #
61
+ # The runtime calls this to distinguish commands from plain models.
62
+ # Returns <tt>true</tt> unconditionally.
63
+ #
64
+ # You do not need to override this method.
65
+ def tea_command?
66
+ true
67
+ end
68
+
69
+ # Cleanup time after cancellation is requested. In seconds.
70
+ #
71
+ # When the runtime cancels your command (app exit, navigation, explicit cancel),
72
+ # it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
73
+ # If your command does not exit within this window, it is force-killed.
74
+ #
75
+ # *This is NOT a lifetime limit.* Your command runs indefinitely until cancelled.
76
+ # A WebSocket open for 15 minutes is fine. This timeout only applies to the
77
+ # cleanup phase after cancellation is requested.
78
+ #
79
+ # Override this method to specify how long your cleanup takes:
80
+ #
81
+ # - <tt>0.5</tt> — Quick HTTP abort, no cleanup needed
82
+ # - <tt>2.0</tt> — Default, suitable for most commands
83
+ # - <tt>5.0</tt> — WebSocket close handshake with remote server
84
+ # - <tt>Float::INFINITY</tt> — Never force-kill (database transactions)
85
+ #
86
+ # === Example
87
+ #
88
+ #--
89
+ # SPDX-SnippetBegin
90
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
91
+ # SPDX-License-Identifier: MIT-0
92
+ #++
93
+ # # Database transactions should never be interrupted mid-write
94
+ # def tea_cancellation_grace_period
95
+ # Float::INFINITY
96
+ # end
97
+ #--
98
+ # SPDX-SnippetEnd
99
+ #++
100
+ def tea_cancellation_grace_period
101
+ 0.1
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "ratatui_ruby"
9
+
10
+ module RatatuiRuby
11
+ module Tea
12
+ module Command
13
+ # Messaging gateway for custom commands.
14
+ #
15
+ # Custom commands run in background threads. They produce results that the
16
+ # main loop consumes.
17
+ #
18
+ # Managing queues and message formats manually is tedious. It scatters queue
19
+ # logic across your codebase and makes mistakes easy.
20
+ #
21
+ # This class wraps the queue with a clean API. Call +put+ to send tagged
22
+ # messages. Debug mode validates Ractor-shareability.
23
+ #
24
+ # Use it to send results from HTTP requests, WebSocket streams, or database polls.
25
+ #
26
+ # === Example (One-Shot)
27
+ #
28
+ # Commands run in their own thread. Blocking calls work fine:
29
+ #
30
+ #--
31
+ # SPDX-SnippetBegin
32
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
33
+ # SPDX-License-Identifier: MIT-0
34
+ #++
35
+ # class FetchUserCommand
36
+ # include Tea::Command::Custom
37
+ #
38
+ # def initialize(user_id)
39
+ # @user_id = user_id
40
+ # end
41
+ #
42
+ # def call(out, _token)
43
+ # response = Net::HTTP.get(URI("https://api.example.com/users/#{@user_id}"))
44
+ # user = JSON.parse(response)
45
+ # out.put(:user_fetched, Ractor.make_shareable(user: user))
46
+ # rescue => e
47
+ # out.put(:user_fetch_failed, error: e.message.freeze)
48
+ # end
49
+ # end
50
+ #--
51
+ # SPDX-SnippetEnd
52
+ #++
53
+ #
54
+ # === Example (Long-Running)
55
+ #
56
+ # Commands that loop check the cancellation token:
57
+ #
58
+ #--
59
+ # SPDX-SnippetBegin
60
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
61
+ # SPDX-License-Identifier: MIT-0
62
+ #++
63
+ # class PollerCommand
64
+ # include Tea::Command::Custom
65
+ #
66
+ # def call(out, token)
67
+ # until token.cancelled?
68
+ # data = fetch_batch
69
+ # out.put(:batch, Ractor.make_shareable(data))
70
+ # sleep 5
71
+ # end
72
+ # out.put(:poller_stopped)
73
+ # end
74
+ # end
75
+ #--
76
+ # SPDX-SnippetEnd
77
+ #++
78
+ class Outlet
79
+ # Creates an outlet for the given queue.
80
+ #
81
+ # The runtime provides the queue. Custom commands receive the outlet as
82
+ # their first argument.
83
+ #
84
+ # [queue] A <tt>Thread::Queue</tt> or compatible object.
85
+ def initialize(queue)
86
+ @queue = queue
87
+ end
88
+
89
+ # Sends a tagged message to the runtime.
90
+ #
91
+ # Builds an array <tt>[tag, *payload]</tt> and pushes it to the queue.
92
+ # The update function pattern-matches on the tag.
93
+ #
94
+ # Debug mode validates Ractor-shareability. It raises <tt>Error::Invariant</tt>
95
+ # if the message is not shareable. Production skips this check.
96
+ #
97
+ # [tag] Symbol identifying the message type.
98
+ # [payload] Additional arguments. Freeze them or use <tt>Ractor.make_shareable</tt>.
99
+ #
100
+ # === Example
101
+ #
102
+ #--
103
+ # SPDX-SnippetBegin
104
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
105
+ # SPDX-License-Identifier: MIT-0
106
+ #++
107
+ # out.put(:user_fetched, user: Ractor.make_shareable(user))
108
+ # out.put(:error, message: "Connection failed".freeze)
109
+ # out.put(:progress, percent: 42) # Integers are always shareable
110
+ #--
111
+ # SPDX-SnippetEnd
112
+ #++
113
+ def put(tag, *payload)
114
+ message = [tag, *payload].freeze
115
+
116
+ if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(message)
117
+ raise RatatuiRuby::Error::Invariant,
118
+ "Message is not Ractor-shareable: #{message.inspect}\n" \
119
+ "Use Ractor.make_shareable or Object#freeze."
120
+ end
121
+
122
+ @queue << message
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -5,6 +5,10 @@
5
5
  # SPDX-License-Identifier: LGPL-3.0-or-later
6
6
  #++
7
7
 
8
+ require_relative "command/cancellation_token"
9
+ require_relative "command/custom"
10
+ require_relative "command/outlet"
11
+
8
12
  module RatatuiRuby
9
13
  module Tea
10
14
  # Commands represent side effects.
@@ -50,12 +54,86 @@ module RatatuiRuby
50
54
  Exit.new
51
55
  end
52
56
 
53
- # Command to run a shell command via Open3.
57
+ # Sentinel value for command cancellation.
58
+ #
59
+ # Long-running commands (WebSocket listeners, database pollers) run until stopped.
60
+ # Stopping them requires signaling from outside the command. The runtime tracks
61
+ # active commands by their object identity and routes cancel requests.
62
+ #
63
+ # This type carries the handle (command object) to cancel. The runtime pattern-matches
64
+ # on <tt>Command::Cancel</tt> and signals the token.
65
+ Cancel = Data.define(:handle)
66
+
67
+ # Request cancellation of a running command.
68
+ #
69
+ # The model stores the command handle (the command object itself). Returning
70
+ # <tt>Command.cancel(handle)</tt> signals the runtime to stop it.
71
+ #
72
+ # [handle] The command object to cancel.
73
+ #
74
+ # === Example
75
+ #
76
+ # # Dispatch and store handle
77
+ # cmd = FetchData.new(url)
78
+ # [model.with(active_fetch: cmd), cmd]
79
+ #
80
+ # # User clicks cancel
81
+ # when :cancel_clicked
82
+ # [model.with(active_fetch: nil), Command.cancel(model.active_fetch)]
83
+ def self.cancel(handle)
84
+ Cancel.new(handle:)
85
+ end
86
+
87
+ # Sentinel value for command errors.
88
+ #
89
+ # Commands run in threads. Exceptions bubble up silently. The update function
90
+ # never sees them, and backtraces in STDERR corrupt the TUI display.
91
+ #
92
+ # The runtime catches exceptions and pushes <tt>Error</tt> to the queue.
93
+ # Pattern match on it in your update function.
94
+ #
95
+ # Analogous to <tt>Exit</tt> and <tt>Cancel</tt>.
96
+ Error = Data.define(:command, :exception)
97
+
98
+ # Creates an error sentinel.
99
+ #
100
+ # The runtime produces this automatically when a command raises.
101
+ # Use this factory for testing or for commands that want to signal
102
+ # error completion without raising.
103
+ #
104
+ # [command] The command that failed.
105
+ # [exception] The exception that was raised.
106
+ #
107
+ # === Example
108
+ #
109
+ # def update(message, model)
110
+ # case message
111
+ # in Command::Error(command:, exception:)
112
+ # model.with(error: "#{command.class} failed: #{exception.message}")
113
+ # end
114
+ # end
115
+ def self.error(command, exception)
116
+ Error.new(command:, exception:)
117
+ end
118
+
119
+ # Runs a shell command and routes its output back as messages.
120
+ #
121
+ # Apps run external tools: linters, compilers, scripts, system utilities.
122
+ # The runtime dispatches the command in a thread, so the UI stays responsive.
123
+ # Batch mode (default) waits for completion; streaming mode shows output live.
124
+ # Orphaned child processes linger and waste resources, so cancellation sends
125
+ # <tt>SIGTERM</tt> for graceful shutdown, then <tt>SIGKILL</tt> to prevent orphans.
126
+ #
127
+ # Use it to run builds, lint files, execute scripts, or invoke any CLI tool.
128
+ #
129
+ # === Batch Mode (default)
54
130
  #
55
- # The runtime executes the command and produces messages. In batch mode
56
- # (default), a single message arrives: <tt>[tag, {stdout:, stderr:, status:}]</tt>.
131
+ # A single message arrives when the command finishes:
132
+ # <tt>[tag, {stdout:, stderr:, status:}]</tt>
57
133
  #
58
- # In streaming mode, messages arrive incrementally:
134
+ # === Streaming Mode
135
+ #
136
+ # Messages arrive incrementally:
59
137
  # - <tt>[tag, :stdout, line]</tt> for each stdout line
60
138
  # - <tt>[tag, :stderr, line]</tt> for each stderr line
61
139
  # - <tt>[tag, :complete, {status:}]</tt> when the command finishes
@@ -63,10 +141,87 @@ module RatatuiRuby
63
141
  #
64
142
  # The <tt>status</tt> is the integer exit code (0 = success).
65
143
  System = Data.define(:command, :tag, :stream) do
144
+ # Command identification — runtime uses this to dispatch as a command.
145
+ def tea_command?
146
+ true
147
+ end
148
+
149
+ # Grace period for cleanup after cancellation.
150
+ def tea_cancellation_grace_period
151
+ 0.1
152
+ end
153
+
66
154
  # Returns true if streaming mode is enabled.
67
155
  def stream?
68
156
  stream
69
157
  end
158
+
159
+ # Executes the shell command and sends results via outlet.
160
+ #
161
+ # In batch mode, sends a single message with all output.
162
+ # In streaming mode, sends incremental messages as output arrives.
163
+ # Respects cancellation token by sending SIGTERM (then SIGKILL) to child.
164
+ def call(out, token)
165
+ require "open3"
166
+
167
+ if stream?
168
+ stream_execution(out, token)
169
+ else
170
+ batch_execution(out)
171
+ end
172
+ end
173
+
174
+ private def batch_execution(out)
175
+ stdout, stderr, status = Open3.capture3(command)
176
+ out.put(tag, Ractor.make_shareable({ stdout:, stderr:, status: status.exitstatus }))
177
+ end
178
+
179
+ private def stream_execution(out, token)
180
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
181
+ stdin.close
182
+ pid = wait_thr.pid
183
+
184
+ stdout_thread = Thread.new do
185
+ stdout.each_line { |line| out.put(tag, :stdout, line.freeze) }
186
+ rescue IOError
187
+ # Stream closed - SIGKILL the child if still alive (forcible cleanup)
188
+ begin
189
+ Process.kill("KILL", pid) if wait_thr.alive?
190
+ rescue Errno::ESRCH
191
+ # Already dead
192
+ end
193
+ end
194
+ stderr_thread = Thread.new do
195
+ stderr.each_line { |line| out.put(tag, :stderr, line.freeze) }
196
+ rescue IOError
197
+ # Stream closed
198
+ end
199
+
200
+ # Cooperative cancellation: SIGTERM when token is cancelled
201
+ cancellation_watcher = Thread.new do
202
+ sleep 0.01 until token.cancelled? || !wait_thr.alive?
203
+ if token.cancelled? && wait_thr.alive?
204
+ begin
205
+ Process.kill("TERM", pid)
206
+ rescue Errno::ESRCH
207
+ # Already dead
208
+ end
209
+ end
210
+ end
211
+
212
+ wait_thr.join
213
+
214
+ # Child exited; clean up threads
215
+ stdout_thread.kill
216
+ stderr_thread.kill
217
+ cancellation_watcher.kill
218
+
219
+ status = wait_thr.value.exitstatus
220
+ out.put(tag, :complete, Ractor.make_shareable({ status: }))
221
+ end
222
+ rescue Errno::ENOENT, Errno::EACCES => e
223
+ out.put(tag, :error, Ractor.make_shareable({ message: e.message }))
224
+ end
70
225
  end
71
226
 
72
227
  # Creates a shell execution command.
@@ -114,12 +269,46 @@ module RatatuiRuby
114
269
  System.new(command:, tag:, stream:)
115
270
  end
116
271
 
117
- # Command that wraps another command's result with a transformation.
272
+ # Wraps another command's result with a transformation.
118
273
  #
119
- # Fractal Architecture requires composition. Child bags produce commands.
120
- # Parent bags route child results back to themselves. +Mapped+ wraps a
121
- # child bag's command and transforms its result message into a parent message.
122
- Mapped = Data.define(:inner_command, :mapper)
274
+ # Fractal Architecture requires composition. Child bags produce commands
275
+ # with their own tags. Parent bags need those results routed back with
276
+ # a parent prefix. Without transformation, update functions become
277
+ # monolithic "God Reducers" that know about every child's internals.
278
+ #
279
+ # This command wraps an inner command and transforms its result message.
280
+ # The parent bag delegates to the child, then intercepts the result and
281
+ # adds its routing prefix. Clean separation. No coupling.
282
+ #
283
+ # Use it to compose child bags that return their own commands.
284
+ Mapped = Data.define(:inner_command, :mapper) do
285
+ # Command identification for runtime dispatch.
286
+ def tea_command? = true
287
+
288
+ # Grace period delegates to inner command.
289
+ def tea_cancellation_grace_period
290
+ inner_command.respond_to?(:tea_cancellation_grace_period) ?
291
+ inner_command.tea_cancellation_grace_period : 0.1
292
+ end
293
+
294
+ # Executes the inner command, waits for result, and transforms it.
295
+ def call(out, token)
296
+ inner_queue = Queue.new
297
+ inner_outlet = Outlet.new(inner_queue)
298
+
299
+ # Dispatch inner command
300
+ if inner_command.respond_to?(:call)
301
+ inner_command.call(inner_outlet, token)
302
+ else
303
+ raise ArgumentError, "Inner command must respond to #call"
304
+ end
305
+
306
+ # Transform result and send
307
+ inner_message = inner_queue.pop
308
+ transformed = mapper.call(inner_message)
309
+ out.put(*transformed)
310
+ end
311
+ end
123
312
 
124
313
  # Creates a mapped command for Fractal Architecture composition.
125
314
  #
@@ -140,6 +329,39 @@ module RatatuiRuby
140
329
  def self.map(inner_command, &mapper)
141
330
  Mapped.new(inner_command:, mapper:)
142
331
  end
332
+
333
+ # Gives a callable unique identity for cancellation.
334
+ #
335
+ # Reusable procs and lambdas share identity. Dispatch them twice, and
336
+ # +Command.cancel+ would cancel both. Wrap them to get distinct handles.
337
+ #
338
+ # [callable] Proc, lambda, or any object responding to +call(out, token)+.
339
+ # If omitted, the block is used.
340
+ # [grace_period] Cleanup time override. Default: 2.0 seconds.
341
+ #
342
+ # === Example
343
+ #
344
+ # # With callable
345
+ # cmd = Command.custom(->(out, token) { out.put(:fetched, data) })
346
+ #
347
+ # # With block
348
+ # cmd = Command.custom(grace_period: 5.0) do |out, token|
349
+ # until token.cancelled?
350
+ # out.put(:tick, Time.now)
351
+ # sleep 1
352
+ # end
353
+ # end
354
+ def self.custom(callable = nil, grace_period: nil, &block)
355
+ Wrapped.new(callable: callable || block, grace_period:)
356
+ end
357
+
358
+ # :nodoc:
359
+ Wrapped = Data.define(:callable, :grace_period) do
360
+ include Custom
361
+ def tea_cancellation_grace_period = grace_period || super
362
+ def call(out, token) = callable.call(out, token)
363
+ end
364
+ private_constant :Wrapped
143
365
  end
144
366
  end
145
367
  end