ratatui_ruby-tea 0.2.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.
- checksums.yaml +4 -4
- data/AGENTS.md +17 -5
- data/CHANGELOG.md +74 -0
- data/README.md +18 -1
- data/Rakefile +1 -1
- data/Steepfile +3 -3
- data/doc/concepts/application_architecture.md +182 -3
- data/doc/contributors/design/commands_and_outlets.md +204 -0
- data/doc/contributors/kit-no-outlet.md +237 -0
- data/examples/app_fractal_dashboard/README.md +60 -0
- data/examples/app_fractal_dashboard/app.rb +67 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_input.rb +77 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +73 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_output.rb +86 -0
- data/examples/app_fractal_dashboard/bags/disk_usage.rb +44 -0
- data/examples/app_fractal_dashboard/bags/network_panel.rb +45 -0
- data/examples/app_fractal_dashboard/bags/ping.rb +43 -0
- data/examples/app_fractal_dashboard/bags/stats_panel.rb +45 -0
- data/examples/app_fractal_dashboard/bags/system_info.rb +43 -0
- data/examples/app_fractal_dashboard/bags/uptime.rb +43 -0
- data/examples/app_fractal_dashboard/dashboard/base.rb +74 -0
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
- data/examples/verify_readme_usage/README.md +1 -1
- data/examples/verify_readme_usage/app.rb +1 -1
- data/examples/{widget_cmd_exec → widget_command_system}/app.rb +18 -18
- data/lib/ratatui_ruby/tea/command/cancellation_token.rb +135 -0
- data/lib/ratatui_ruby/tea/command/custom.rb +106 -0
- data/lib/ratatui_ruby/tea/command/outlet.rb +127 -0
- data/lib/ratatui_ruby/tea/command.rb +367 -0
- data/lib/ratatui_ruby/tea/router.rb +405 -0
- data/lib/ratatui_ruby/tea/runtime.rb +147 -43
- data/lib/ratatui_ruby/tea/shortcuts.rb +51 -0
- data/lib/ratatui_ruby/tea/version.rb +1 -1
- data/lib/ratatui_ruby/tea.rb +59 -1
- data/rbs_collection.lock.yaml +124 -0
- data/rbs_collection.yaml +15 -0
- data/sig/examples/verify_readme_usage/app.rbs +1 -1
- data/sig/examples/{widget_cmd_exec → widget_command_system}/app.rbs +1 -1
- data/sig/open3.rbs +17 -0
- data/sig/ratatui_ruby/tea/command.rbs +163 -0
- data/sig/ratatui_ruby/tea/router.rbs +155 -0
- data/sig/ratatui_ruby/tea/runtime.rbs +29 -11
- data/sig/ratatui_ruby/tea/shortcuts.rbs +18 -0
- data/sig/ratatui_ruby/tea/version.rbs +10 -0
- data/sig/ratatui_ruby/tea.rbs +19 -7
- data/tasks/steep.rake +11 -0
- metadata +37 -8
- data/lib/ratatui_ruby/tea/cmd.rb +0 -88
- data/sig/ratatui_ruby/tea/cmd.rbs +0 -32
- /data/examples/{widget_cmd_exec → widget_command_system}/README.md +0 -0
|
@@ -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
|