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.
- checksums.yaml +4 -4
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +41 -0
- data/README.md +1 -1
- data/doc/concepts/application_architecture.md +182 -3
- 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.rb +145 -0
- data/lib/ratatui_ruby/tea/router.rb +337 -0
- data/lib/ratatui_ruby/tea/runtime.rb +99 -39
- 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/sig/ratatui_ruby/tea/command.rbs +47 -0
- data/sig/ratatui_ruby/tea/router.rbs +99 -0
- metadata +26 -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
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d10ee51cb79e58e9b24dbf78e696dcbc901ddedac44dce824c4cf8817e8b07a3
|
|
4
|
+
data.tar.gz: 1043f33f9e3804748ea3fdd5eef4783b7af3a81ed51b10ce88f3447953e1874e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -5,12 +5,191 @@
|
|
|
5
5
|
|
|
6
6
|
# Application Architecture
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Build robust TUI applications with Tea patterns.
|
|
9
9
|
|
|
10
10
|
## Core Concepts
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
_This section is incomplete. Check the source files._
|
|
13
13
|
|
|
14
14
|
## Thread and Ractor Safety
|
|
15
15
|
|
|
16
|
-
|
|
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
|