ratatui_ruby-tea 0.2.0 → 0.3.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +8 -0
  3. data/CHANGELOG.md +41 -0
  4. data/README.md +1 -1
  5. data/doc/concepts/application_architecture.md +182 -3
  6. data/examples/app_fractal_dashboard/README.md +60 -0
  7. data/examples/app_fractal_dashboard/app.rb +67 -0
  8. data/examples/app_fractal_dashboard/bags/custom_shell_input.rb +77 -0
  9. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +73 -0
  10. data/examples/app_fractal_dashboard/bags/custom_shell_output.rb +86 -0
  11. data/examples/app_fractal_dashboard/bags/disk_usage.rb +44 -0
  12. data/examples/app_fractal_dashboard/bags/network_panel.rb +45 -0
  13. data/examples/app_fractal_dashboard/bags/ping.rb +43 -0
  14. data/examples/app_fractal_dashboard/bags/stats_panel.rb +45 -0
  15. data/examples/app_fractal_dashboard/bags/system_info.rb +43 -0
  16. data/examples/app_fractal_dashboard/bags/uptime.rb +43 -0
  17. data/examples/app_fractal_dashboard/dashboard/base.rb +74 -0
  18. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
  19. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
  20. data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
  21. data/examples/verify_readme_usage/README.md +1 -1
  22. data/examples/verify_readme_usage/app.rb +1 -1
  23. data/examples/{widget_cmd_exec → widget_command_system}/app.rb +18 -18
  24. data/lib/ratatui_ruby/tea/command.rb +145 -0
  25. data/lib/ratatui_ruby/tea/router.rb +337 -0
  26. data/lib/ratatui_ruby/tea/runtime.rb +99 -39
  27. data/lib/ratatui_ruby/tea/shortcuts.rb +51 -0
  28. data/lib/ratatui_ruby/tea/version.rb +1 -1
  29. data/lib/ratatui_ruby/tea.rb +59 -1
  30. data/sig/ratatui_ruby/tea/command.rbs +47 -0
  31. data/sig/ratatui_ruby/tea/router.rbs +99 -0
  32. metadata +26 -8
  33. data/lib/ratatui_ruby/tea/cmd.rb +0 -88
  34. data/sig/ratatui_ruby/tea/cmd.rbs +0 -32
  35. /data/examples/{widget_cmd_exec → widget_command_system}/README.md +0 -0
  36. /data/sig/examples/{widget_cmd_exec → widget_command_system}/app.rbs +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1bf31fb6ff8bacdcd6f9c54e794e0c769f5c52afde2c5e4d78cbee48be36b12
4
- data.tar.gz: 1bd65539c4eace89fc1d58d857d81fb9cc03187ea7ae770d4b5c4a051ae407a0
3
+ metadata.gz: d10ee51cb79e58e9b24dbf78e696dcbc901ddedac44dce824c4cf8817e8b07a3
4
+ data.tar.gz: 1043f33f9e3804748ea3fdd5eef4783b7af3a81ed51b10ce88f3447953e1874e
5
5
  SHA512:
6
- metadata.gz: 413f42942963ef06433b683b2cf3820db1a8cd679fcf263aaccc4cbd3780fa01b7bcc66842aeaad7606377380530215a9b6a07a145d6df56ecf34a5053c998cb
7
- data.tar.gz: 8edaf06fbf149723ca70b2f27157c8c2d22536f060c94facfb6d904223b48dc21613daa0a130237548a40d705074ec22f90403e27855b1b578bad0e770f27040
6
+ metadata.gz: e25205b29e1654964c88455f9a9fed89ee3464aacbecc458e128baae8bf0a788196e9c1fbdee9cea3549b0e9f60cf1bef5c48c870baa57af453759ad4625453b
7
+ data.tar.gz: 121c207a7c82109eafbcdbe7ebae838cccb33f54a0f6ff96c75832294e07496284c26c89fdf7bf0f871883959951414ab52582d209c52b1fb00249a0b009ab9f
data/AGENTS.md CHANGED
@@ -31,6 +31,13 @@ Description: Part of the RatatuiRuby ecosystem.
31
31
  - **Setup:** `bin/setup` must handle Bundler dependencies.
32
32
  - **Git:** ALWAYS set `PAGER=cat` with `git`. **THIS IS CRITICAL!**
33
33
 
34
+ ### Tea-Specific Vocabulary
35
+
36
+ - **BANNED WORD: "component"** — Reserved for Kit.
37
+ - **Avoid "widget" for Tea units** — "Widget" refers to Engine/Ratatui render primitives. In Tea, call them **bags**.
38
+ - **Bag:** A module containing `Model`, `INITIAL`, `UPDATE`, and `VIEW` constants. Bags compose: parent bags delegate to child bags.
39
+ - Use "model", "update", "view" for the MVU pattern. Use "message" (not "msg") and "command" (not "cmd").
40
+
34
41
  ### Ruby Standards
35
42
 
36
43
  - Use `Data.define` for all value objects (Ruby 3.2+).
@@ -50,6 +57,7 @@ Description: Part of the RatatuiRuby ecosystem.
50
57
 
51
58
  Before considering a task complete:
52
59
 
60
+ 0. **Production Ready:** RBS types are complete and accurate (no `untyped`), errors are handled with good DX, documentation follows guidelines, high code quality (no "pre-existing debt" excuses).
53
61
  1. **Default Rake Task Passes:** Run `bundle exec agent_rake` (no args). Confirm it passes with ZERO errors.
54
62
  2. **Documentation Updated:** If public APIs changed, update relevant docs.
55
63
  3. **Changelog Updated:** If public APIs changed, update CHANGELOG.md's **Unreleased** section.
data/CHANGELOG.md CHANGED
@@ -21,6 +21,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
21
 
22
22
  ### Removed
23
23
 
24
+ ## [0.3.0] - 2026-01-08
25
+
26
+ ### Added
27
+
28
+ - **Router DSL**: New `Tea::Router` module provides declarative routing for Fractal Architecture:
29
+ - `route :prefix, to: ChildBag` — declares a child bag route
30
+ - `keymap { key "q", -> { Command.exit } }` — declares keyboard handlers
31
+ - `keymap { key "x", handler, when: -> (m) { m.ready? } }` — guards (also: `if:`, `only:`, `guard:`, `unless:`, `except:`, `skip:`)
32
+ - `keymap { only when: guard do ... end }` — nested guard blocks apply to all keys within (also: `skip when: ...`)
33
+ - `mousemap { click -> (x, y) { ... } }` — declares mouse handlers
34
+ - `action :name, handler` — declares reusable actions for key/mouse handlers
35
+ - `from_router` — generates an UPDATE lambda from routes and handlers
36
+
37
+ - **Composition Helpers**: New helper methods for Fractal Architecture reduce boilerplate:
38
+ - `Tea.route(command, :prefix)` — wraps a command to route results to a child bag
39
+ - `Tea.delegate(message, :prefix, child_update, child_model)` — dispatches prefixed messages to child bags
40
+
41
+ - **Command Mapping**: `Command.map(inner_command, &mapper)` wraps a child command and transforms its result message. Essential for parent bags routing child command results.
42
+
43
+ - **Shortcuts Module**: `require "ratatui_ruby/tea/shortcuts"` and `include Tea::Shortcuts` for short aliases:
44
+ - `Cmd.exit` — alias for `Command.exit`
45
+ - `Cmd.sh(command, tag)` — alias for `Command.system`
46
+ - `Cmd.map(command, &block)` — alias for `Command.map`
47
+
48
+ - **Sync Event Integration**: Runtime now handles `Event::Sync` from `RatatuiRuby::SyntheticEvents`. When a Sync event is received, the runtime waits for all pending async threads and processes their results before continuing. Use `inject_sync` in tests for deterministic async verification.
49
+
50
+ - **Streaming Command Output**: `Command.system` now accepts a `stream:` keyword argument. When `stream: true`, the runtime sends incremental messages (`[:tag, :stdout, line]`, `[:tag, :stderr, line]`) as output arrives, followed by `[:tag, :complete, {status:}]` when the command finishes. Invalid commands send `[:tag, :error, {message:}]`. Default behavior (`stream: false`) remains unchanged.
51
+
52
+ - **Custom Shell Modal Example**: Added `examples/app_fractal_dashboard/bags/custom_shell_modal.rb` demonstrating a 3-bag fractal architecture for a modal that runs arbitrary shell commands with streaming output. Features interleaved stdout/stderr, exit status indication, and Ractor-safe implementation using `tui.overlay` for opaque rendering.
53
+
54
+ ### Changed
55
+
56
+ - **Command Module Rename (Breaking)**: The `Cmd` module is now `Command` with Rubyish naming:
57
+ - `Cmd::Quit` → `Command::Exit` (use `Command.exit` factory)
58
+ - `Cmd::Exec` → `Command::System` (use `Command.system(cmd, tag)` factory)
59
+
60
+ ### Fixed
61
+
62
+ ### Removed
63
+
24
64
  ## [0.2.0] - 2026-01-08
25
65
 
26
66
  ### Added
@@ -43,6 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
83
  - **First Release**: Empty release of `ratatui_ruby-tea`, a Ruby implementation of The Elm Architecture (TEA) for `ratatui_ruby`. Scaffolding generated by `ratatui_ruby-devtools`.
44
84
 
45
85
  [Unreleased]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/HEAD
86
+ [0.3.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.3.0
46
87
  [0.2.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.2.0
47
88
  [0.2.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.2.0
48
89
  [0.1.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.1.0
data/README.md CHANGED
@@ -106,7 +106,7 @@ end
106
106
 
107
107
  UPDATE = -> (msg, model) do
108
108
  if msg.q? || msg.ctrl_c?
109
- RatatuiRuby::Tea::Cmd.quit
109
+ RatatuiRuby::Tea::Command.exit
110
110
  else
111
111
  model
112
112
  end
@@ -5,12 +5,191 @@
5
5
 
6
6
  # Application Architecture
7
7
 
8
- Architect robust TUI applications using core library patterns and API best practices.
8
+ Build robust TUI applications with Tea patterns.
9
9
 
10
10
  ## Core Concepts
11
11
 
12
- _Because this gem is in pre-release, it lacks documentation. Please check the source files._
12
+ _This section is incomplete. Check the source files._
13
13
 
14
14
  ## Thread and Ractor Safety
15
15
 
16
- _Because this gem is in pre-release, it lacks documentation. Please check the source files._
16
+ ### The Strategic Context
17
+
18
+ Ruby 4.0 introduces [Ractors](https://docs.ruby-lang.org/en/4.0/Ractor.html)—
19
+ true parallel actors that forbid shared mutable state. Code that passes
20
+ mutable objects between threads crashes in a Ractor world.
21
+
22
+ Tea prepares you today. The runtime enforces Ractor-shareability on every
23
+ Model and Message *now*, using standard threads. Pass a mutable object,
24
+ and it raises an error immediately. Write Ractor-safe code today; upgrade
25
+ to Ruby 4.0 without changes tomorrow.
26
+
27
+ Enforce immutability rules before you strictly need them, and the migration
28
+ is invisible.
29
+
30
+ ### The Problem
31
+
32
+ Ruby's Ractor model prevents data races by forbidding shared mutable state.
33
+ Mutable objects cause runtime errors:
34
+
35
+ ```
36
+ RatatuiRuby::Error::Invariant: Model is not Ractor-shareable.
37
+ ```
38
+
39
+ ### The Solution
40
+
41
+ Use `Ractor.make_shareable`. It recursively freezes everything:
42
+
43
+ ```ruby
44
+ Ractor.make_shareable(model.with(text: "#{model.text}#{char}"))
45
+ ```
46
+
47
+ For constants, wrap INITIAL:
48
+
49
+ ```ruby
50
+ INITIAL = Ractor.make_shareable(
51
+ Model.new(text: "", running: false, chunks: [])
52
+ )
53
+ ```
54
+
55
+ For collection updates:
56
+
57
+ ```ruby
58
+ new_chunks = Ractor.make_shareable([*model.chunks, new_item])
59
+ ```
60
+
61
+ ### The Lightweight Alternative
62
+
63
+ When you know exactly what's mutable, `.freeze` is shorter:
64
+
65
+ ```ruby
66
+ [model.with(text: "#{model.text}#{char}".freeze), nil]
67
+ ```
68
+
69
+ ### Foot-Guns
70
+
71
+ #### frozen_string_literal Only Affects Literals
72
+
73
+ The magic comment freezes strings that appear directly in source code.
74
+ Computed strings are mutable.
75
+
76
+ ```ruby
77
+ # frozen_string_literal: true
78
+
79
+ "literal" # frozen ✓
80
+ "#{var}" # mutable ✗
81
+ str.chop # mutable ✗
82
+ str + other # mutable ✗
83
+ ```
84
+
85
+ #### Data.define Needs Shareable Values
86
+
87
+ `Data.define` creates frozen instances. The instance is Ractor-shareable
88
+ only when all its values are shareable.
89
+
90
+ ### Quick Reference
91
+
92
+ | Pattern | Code |
93
+ |---------|------|
94
+ | Make anything shareable | `Ractor.make_shareable(obj)` |
95
+ | Freeze a string | `str.freeze` |
96
+ | INITIAL constant | `Ractor.make_shareable(Model.new(...))` |
97
+ | Array update | `Ractor.make_shareable([*old, new])` |
98
+
99
+ ### Debugging
100
+
101
+ See this error?
102
+
103
+ ```
104
+ RatatuiRuby::Error::Invariant: Model is not Ractor-shareable.
105
+ ```
106
+
107
+ Wrap the returned model with `Ractor.make_shareable`.
108
+
109
+ ## Modals and Command Result Routing
110
+
111
+ Modals capture keyboard input. They overlay the main UI and intercept keypresses until dismissed. But async commands keep running in the background. When their results arrive, they look like any other message.
112
+
113
+ It's tempting to intercept *all* messages when the modal is active. This swallows those command results.
114
+
115
+ ### The Scenario
116
+
117
+ 1. User presses "u" to fetch uptime. The runtime dispatches an async command.
118
+ 2. User presses "c" to open a modal dialog.
119
+ 3. The uptime command completes. It sends `[:network, :uptime, { stdout:, ... }]`.
120
+ 4. The modal intercept sees the modal is active. It routes that message to the modal.
121
+ 5. The modal ignores it.
122
+ 6. The uptime panel never updates.
123
+
124
+ ### The Fix
125
+
126
+ Route command results before modal interception. Modals intercept user input, not async results.
127
+
128
+ <!-- SPDX-SnippetBegin -->
129
+ <!--
130
+ SPDX-FileCopyrightText: 2026 Kerrick Long
131
+ SPDX-License-Identifier: MIT-0
132
+ -->
133
+ ```ruby
134
+ # Wrong: modal intercepts everything
135
+ UPDATE = lambda do |message, model|
136
+ if Modal.active?(model.modal)
137
+ # Swallows command results
138
+ return Modal::UPDATE.call(message, model.modal)
139
+ end
140
+
141
+ case message
142
+ in [:network, *rest]
143
+ # Never reached while modal is open
144
+ end
145
+ end
146
+
147
+ # Correct: route command results first
148
+ UPDATE = lambda do |message, model|
149
+ # 1. Route async command results (always)
150
+ case message
151
+ in [:network, *rest]
152
+ return [model.with(network: new_network), command]
153
+ in [:stats, *rest]
154
+ return [model.with(stats: new_stats), command]
155
+ else
156
+ nil
157
+ end
158
+
159
+ # 2. Modal intercepts user input
160
+ if Modal.active?(model.modal)
161
+ return Modal::UPDATE.call(message, model.modal)
162
+ end
163
+
164
+ # 3. Handle other input
165
+ case message
166
+ in _ if message.q? then Command.exit
167
+ end
168
+ end
169
+ ```
170
+ <!-- SPDX-SnippetEnd -->
171
+
172
+ ### The Router DSL
173
+
174
+ `Tea::Router` handles this correctly. Routes declared with `route :prefix, to: ChildModule` process before keymap handlers. Command results flow through even when guards block keyboard input.
175
+
176
+ <!-- SPDX-SnippetBegin -->
177
+ <!--
178
+ SPDX-FileCopyrightText: 2026 Kerrick Long
179
+ SPDX-License-Identifier: MIT-0
180
+ -->
181
+ ```ruby
182
+ module Dashboard
183
+ include Tea::Router
184
+
185
+ route :stats, to: StatsPanel
186
+ route :network, to: NetworkPanel
187
+
188
+ keymap do
189
+ only when: MODAL_INACTIVE do
190
+ key "u", -> { Uptime.fetch_command }
191
+ end
192
+ end
193
+ end
194
+ ```
195
+ <!-- SPDX-SnippetEnd -->
@@ -0,0 +1,60 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # Cmd.map Fractal Dashboard
7
+
8
+ Demonstrates **Fractal Architecture** using `Cmd.map` for component composition.
9
+
10
+ ## Problem
11
+
12
+ Without composition, a complex app needs one giant `case` statement handling every possible message from every child—the "God Reducer" anti-pattern. This doesn't scale.
13
+
14
+ ## Solution
15
+
16
+ `Cmd.map` wraps child commands so their results route through parents:
17
+
18
+ ```ruby
19
+ # Child produces [:system_info, {stdout:, ...}]
20
+ child_cmd = SystemInfoWidget.fetch_cmd
21
+
22
+ # Parent wraps to produce [:stats, :system_info, {...}]
23
+ parent_cmd = Cmd.map(child_cmd) { |m| [:stats, *m] }
24
+ ```
25
+
26
+ Each layer handles only its own messages. Parents pattern-match on the first element to route to the correct child.
27
+
28
+ ## Architecture
29
+
30
+ ```
31
+ Dashboard (root)
32
+ ├── StatsPanel
33
+ │ ├── SystemInfoWidget → Cmd.exec("uname -a", :system_info)
34
+ │ └── DiskUsageWidget → Cmd.exec("df -h", :disk_usage)
35
+ └── NetworkPanel
36
+ ├── PingWidget → Cmd.exec("ping -c 1 localhost", :ping)
37
+ └── UptimeWidget → Cmd.exec("uptime", :uptime)
38
+ ```
39
+
40
+ ## Hotkeys
41
+
42
+ | Key | Action |
43
+ |-----|--------|
44
+ | `s` | Fetch system info |
45
+ | `d` | Fetch disk usage |
46
+ | `p` | Ping localhost |
47
+ | `u` | Fetch uptime |
48
+ | `q` | Quit |
49
+
50
+ ## Key Concepts
51
+
52
+ 1. **Widget isolation**: Each widget has its own `Model`, `UPDATE`, and `fetch_cmd`. It knows nothing about parents.
53
+ 2. **Message routing**: Parents prefix child messages (`:stats`, `:network`) and pattern-match to route.
54
+ 3. **Recursive dispatch**: `Cmd.map` delegates inner command execution to the runtime, then transforms the result.
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ ruby examples/widget_cmd_map/app.rb
60
+ ```
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: MIT-0
6
+ #++
7
+
8
+ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
9
+
10
+ require "ratatui_ruby"
11
+ require "ratatui_ruby/tea"
12
+
13
+ # Demonstrates three approaches to UPDATE routing in Fractal Architecture.
14
+ #
15
+ # == Usage
16
+ #
17
+ # ruby app.rb # Defaults to 'manual'
18
+ # ruby app.rb manual # Verbose pattern matching
19
+ # ruby app.rb helpers # Tea.route and Tea.delegate helpers
20
+ # ruby app.rb router # Tea::Router DSL
21
+ #
22
+ # All three share the same bags, Model, INITIAL, and VIEW. Only the UPDATE
23
+ # implementation differs. Compare the three update_*.rb files to see the
24
+ # progression from verbose to declarative.
25
+ #
26
+ # == Architecture
27
+ #
28
+ # app.rb ← Entry point (you are here)
29
+ # dashboard/
30
+ # ├── base.rb ← Shared: Model, INITIAL, VIEW
31
+ # ├── update_manual.rb
32
+ # ├── update_helpers.rb
33
+ # └── update_router.rb
34
+ # bags/
35
+ # ├── system_info.rb
36
+ # ├── disk_usage.rb
37
+ # ├── ping.rb
38
+ # ├── uptime.rb
39
+ # ├── stats_panel.rb
40
+ # └── network_panel.rb
41
+
42
+ VALID_MODES = %w[manual helpers router].freeze
43
+
44
+ mode = ARGV[0] || "manual"
45
+ unless VALID_MODES.include?(mode)
46
+ warn "Usage: ruby app.rb [#{VALID_MODES.join('|')}]"
47
+ exit 1
48
+ end
49
+
50
+ dashboard = case mode
51
+ when "manual"
52
+ require_relative "dashboard/update_manual"
53
+ DashboardManual
54
+ when "helpers"
55
+ require_relative "dashboard/update_helpers"
56
+ DashboardHelpers
57
+ when "router"
58
+ require_relative "dashboard/update_router"
59
+ DashboardRouter
60
+ end
61
+
62
+ puts "Running with #{mode} UPDATE..."
63
+ RatatuiRuby::Tea.run(
64
+ model: dashboard::INITIAL,
65
+ view: dashboard::VIEW,
66
+ update: dashboard::UPDATE
67
+ )
@@ -0,0 +1,77 @@
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
+ # Text input bag for custom shell command modal.
9
+ #
10
+ # Handles text entry. Sets cancelled: or submitted: in model for parent to detect.
11
+ module CustomShellInput
12
+ Model = Data.define(:text, :cancelled, :submitted)
13
+ INITIAL = Ractor.make_shareable(Model.new(text: "", cancelled: false, submitted: false))
14
+
15
+ VIEW = lambda do |model, tui|
16
+ content = if model.text.empty?
17
+ tui.paragraph(text: tui.text_span(content: "Type a command...", style: { fg: :dark_gray }))
18
+ else
19
+ tui.paragraph(text: model.text)
20
+ end
21
+
22
+ tui.layout(
23
+ direction: :vertical,
24
+ constraints: [
25
+ tui.constraint_length(1),
26
+ tui.constraint_length(3),
27
+ tui.constraint_min(0),
28
+ ],
29
+ children: [
30
+ nil,
31
+ tui.center(
32
+ width_percent: 80,
33
+ child: tui.overlay(
34
+ layers: [
35
+ tui.clear,
36
+ tui.block(
37
+ title: "Run Command",
38
+ titles: [
39
+ { content: "ESC: Cancel", position: :bottom, alignment: :left },
40
+ { content: "ENTER: Run", position: :bottom, alignment: :right },
41
+ ],
42
+ borders: [:all],
43
+ children: [content]
44
+ ),
45
+ ]
46
+ )
47
+ ),
48
+ nil,
49
+ ]
50
+ )
51
+ end
52
+
53
+ UPDATE = lambda do |message, model|
54
+ case message
55
+ in _ if message.respond_to?(:esc?) && message.esc?
56
+ [model.with(cancelled: true), nil]
57
+
58
+ in _ if message.respond_to?(:enter?) && message.enter?
59
+ return [model.with(cancelled: true), nil] if model.text.strip.empty?
60
+ [model.with(submitted: true), nil]
61
+
62
+ in _ if message.respond_to?(:backspace?) && message.backspace?
63
+ [model.with(text: model.text.chop.freeze), nil]
64
+
65
+ in RatatuiRuby::Event::Paste
66
+ # Handle continuation backslashes: "foo \\\nbar" → "foo bar"
67
+ normalized = message.content.gsub(/\\\r?\n/, "").gsub(/[\r\n]/, " ")
68
+ [model.with(text: "#{model.text}#{normalized}".freeze), nil]
69
+
70
+ in RatatuiRuby::Event::Key if message.text? && message.code.length == 1
71
+ [model.with(text: "#{model.text}#{message.code}".freeze), nil]
72
+
73
+ else
74
+ [model, nil]
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,73 @@
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_relative "custom_shell_input"
9
+ require_relative "custom_shell_output"
10
+
11
+ # Parent coordinator bag for custom shell modal.
12
+ #
13
+ # Routes to active child (input or output). Checks child model state for transitions.
14
+ module CustomShellModal
15
+ Command = RatatuiRuby::Tea::Command
16
+
17
+ Model = Data.define(:mode, :input, :output)
18
+ INITIAL = Ractor.make_shareable(Model.new(mode: :none, input: CustomShellInput::INITIAL, output: CustomShellOutput::INITIAL))
19
+
20
+ VIEW = lambda do |model, tui|
21
+ case model.mode
22
+ when :none then nil
23
+ when :input then CustomShellInput::VIEW.call(model.input, tui)
24
+ when :output then CustomShellOutput::VIEW.call(model.output, tui)
25
+ end
26
+ end
27
+
28
+ UPDATE = lambda do |message, model|
29
+ case model.mode
30
+ when :input
31
+ new_input, cmd = CustomShellInput::UPDATE.call(message, model.input)
32
+
33
+ if new_input.cancelled
34
+ [INITIAL, nil]
35
+ elsif new_input.submitted
36
+ shell_cmd = new_input.text
37
+ new_output = CustomShellOutput::INITIAL.with(command: shell_cmd, running: true)
38
+ [
39
+ model.with(mode: :output, input: CustomShellInput::INITIAL, output: new_output),
40
+ Command.system(shell_cmd, :shell_output, stream: true),
41
+ ]
42
+ else
43
+ [model.with(input: new_input), cmd]
44
+ end
45
+
46
+ when :output
47
+ # Route streaming messages (strip :shell_output prefix)
48
+ routed = case message
49
+ in [:shell_output, *rest] then rest
50
+ else message
51
+ end
52
+
53
+ new_output, cmd = CustomShellOutput::UPDATE.call(routed, model.output)
54
+
55
+ if new_output.dismissed
56
+ [INITIAL, nil]
57
+ else
58
+ [model.with(output: new_output), cmd]
59
+ end
60
+
61
+ else
62
+ [model, nil]
63
+ end
64
+ end
65
+
66
+ def self.open
67
+ INITIAL.with(mode: :input, input: CustomShellInput::INITIAL)
68
+ end
69
+
70
+ def self.active?(model)
71
+ model.mode != :none
72
+ end
73
+ end
@@ -0,0 +1,86 @@
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
+ # Streaming output bag for custom shell command modal.
9
+ #
10
+ # Displays interleaved stdout/stderr. Border color reflects exit status.
11
+ # Sets dismissed: in model for parent to detect.
12
+ module CustomShellOutput
13
+ Chunk = Data.define(:stream, :text)
14
+ Model = Data.define(:command, :chunks, :running, :exit_status, :dismissed)
15
+ INITIAL = Ractor.make_shareable(Model.new(command: "", chunks: [].freeze, running: false, exit_status: nil, dismissed: false))
16
+
17
+ VIEW = lambda do |model, tui|
18
+ # Build styled spans from chunks
19
+ spans = if model.chunks.empty? && model.running
20
+ [tui.text_span(content: "Running...", style: tui.style(fg: :dark_gray))]
21
+ else
22
+ model.chunks.map do |chunk|
23
+ style = (chunk.stream == :stderr) ? tui.style(fg: :yellow) : nil
24
+ tui.text_span(content: chunk.text, style:)
25
+ end
26
+ end
27
+
28
+ # Border color: green if exited 0, red if exited non-zero, default if running
29
+ border_style = case model.exit_status
30
+ when nil then nil
31
+ when 0 then tui.style(fg: :green)
32
+ else tui.style(fg: :red)
33
+ end
34
+
35
+ left_title = model.running ? "ESC: Cancel" : "ESC: Dismiss"
36
+ display_cmd = (model.command.length > 60) ? "#{model.command[0..57]}..." : model.command
37
+
38
+ tui.center(
39
+ width_percent: 80,
40
+ height_percent: 80,
41
+ child: tui.overlay(
42
+ layers: [
43
+ tui.clear,
44
+ tui.block(
45
+ title: display_cmd,
46
+ titles: [
47
+ { content: left_title, position: :bottom, alignment: :left },
48
+ { content: "ENTER: Dismiss", position: :bottom, alignment: :right },
49
+ ],
50
+ borders: [:all],
51
+ border_style:,
52
+ children: [tui.paragraph(text: spans)]
53
+ ),
54
+ ]
55
+ )
56
+ )
57
+ end
58
+
59
+ UPDATE = lambda do |message, model|
60
+ case message
61
+ in [:stdout, chunk]
62
+ new_chunks = Ractor.make_shareable([*model.chunks, Chunk.new(stream: :stdout, text: chunk)].freeze)
63
+ [model.with(chunks: new_chunks), nil]
64
+
65
+ in [:stderr, chunk]
66
+ new_chunks = Ractor.make_shareable([*model.chunks, Chunk.new(stream: :stderr, text: chunk)].freeze)
67
+ [model.with(chunks: new_chunks), nil]
68
+
69
+ in [:complete, { status: }]
70
+ [model.with(running: false, exit_status: status), nil]
71
+
72
+ in [:error, { message: error_msg }]
73
+ new_chunks = Ractor.make_shareable([*model.chunks, Chunk.new(stream: :stderr, text: "Error: #{error_msg}\n")].freeze)
74
+ [model.with(chunks: new_chunks, running: false, exit_status: 1), nil]
75
+
76
+ in _ if message.respond_to?(:esc?) && message.esc?
77
+ [model.with(dismissed: true), nil]
78
+
79
+ in _ if message.respond_to?(:enter?) && message.enter?
80
+ [model.with(dismissed: true), nil]
81
+
82
+ else
83
+ [model, nil]
84
+ end
85
+ end
86
+ end