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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +17 -5
  3. data/CHANGELOG.md +74 -0
  4. data/README.md +18 -1
  5. data/Rakefile +1 -1
  6. data/Steepfile +3 -3
  7. data/doc/concepts/application_architecture.md +182 -3
  8. data/doc/contributors/design/commands_and_outlets.md +204 -0
  9. data/doc/contributors/kit-no-outlet.md +237 -0
  10. data/examples/app_fractal_dashboard/README.md +60 -0
  11. data/examples/app_fractal_dashboard/app.rb +67 -0
  12. data/examples/app_fractal_dashboard/bags/custom_shell_input.rb +77 -0
  13. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +73 -0
  14. data/examples/app_fractal_dashboard/bags/custom_shell_output.rb +86 -0
  15. data/examples/app_fractal_dashboard/bags/disk_usage.rb +44 -0
  16. data/examples/app_fractal_dashboard/bags/network_panel.rb +45 -0
  17. data/examples/app_fractal_dashboard/bags/ping.rb +43 -0
  18. data/examples/app_fractal_dashboard/bags/stats_panel.rb +45 -0
  19. data/examples/app_fractal_dashboard/bags/system_info.rb +43 -0
  20. data/examples/app_fractal_dashboard/bags/uptime.rb +43 -0
  21. data/examples/app_fractal_dashboard/dashboard/base.rb +74 -0
  22. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
  23. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
  24. data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
  25. data/examples/verify_readme_usage/README.md +1 -1
  26. data/examples/verify_readme_usage/app.rb +1 -1
  27. data/examples/{widget_cmd_exec → widget_command_system}/app.rb +18 -18
  28. data/lib/ratatui_ruby/tea/command/cancellation_token.rb +135 -0
  29. data/lib/ratatui_ruby/tea/command/custom.rb +106 -0
  30. data/lib/ratatui_ruby/tea/command/outlet.rb +127 -0
  31. data/lib/ratatui_ruby/tea/command.rb +367 -0
  32. data/lib/ratatui_ruby/tea/router.rb +405 -0
  33. data/lib/ratatui_ruby/tea/runtime.rb +147 -43
  34. data/lib/ratatui_ruby/tea/shortcuts.rb +51 -0
  35. data/lib/ratatui_ruby/tea/version.rb +1 -1
  36. data/lib/ratatui_ruby/tea.rb +59 -1
  37. data/rbs_collection.lock.yaml +124 -0
  38. data/rbs_collection.yaml +15 -0
  39. data/sig/examples/verify_readme_usage/app.rbs +1 -1
  40. data/sig/examples/{widget_cmd_exec → widget_command_system}/app.rbs +1 -1
  41. data/sig/open3.rbs +17 -0
  42. data/sig/ratatui_ruby/tea/command.rbs +163 -0
  43. data/sig/ratatui_ruby/tea/router.rbs +155 -0
  44. data/sig/ratatui_ruby/tea/runtime.rbs +29 -11
  45. data/sig/ratatui_ruby/tea/shortcuts.rbs +18 -0
  46. data/sig/ratatui_ruby/tea/version.rbs +10 -0
  47. data/sig/ratatui_ruby/tea.rbs +19 -7
  48. data/tasks/steep.rake +11 -0
  49. metadata +37 -8
  50. data/lib/ratatui_ruby/tea/cmd.rb +0 -88
  51. data/sig/ratatui_ruby/tea/cmd.rbs +0 -32
  52. /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