dry-cli-ui 0.1.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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/SPECIFICATION.md +322 -0
- data/lib/dry/cli/ui/console.rb +223 -0
- data/lib/dry/cli/ui/duration.rb +30 -0
- data/lib/dry/cli/ui/terminal.rb +114 -0
- data/lib/dry/cli/ui/theme.rb +53 -0
- data/lib/dry/cli/ui/version.rb +17 -0
- data/lib/dry/cli/ui/widgets/box.rb +89 -0
- data/lib/dry/cli/ui/widgets/outcome.rb +25 -0
- data/lib/dry/cli/ui/widgets/progress.rb +119 -0
- data/lib/dry/cli/ui/widgets/prompt.rb +141 -0
- data/lib/dry/cli/ui/widgets/spinner.rb +63 -0
- data/lib/dry/cli/ui/widgets/status.rb +21 -0
- data/lib/dry/cli/ui/widgets/table.rb +46 -0
- data/lib/dry/cli/ui/widgets/tasks.rb +291 -0
- data/lib/dry/cli/ui/widgets.rb +21 -0
- data/lib/dry/cli/ui.rb +58 -0
- data/lib/dry-cli-ui.rb +3 -0
- data/sig/dry/cli/ui.rbs +32 -0
- metadata +223 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b1cbc8893268017d5d41b730292d9f6a640230ff87aa2282afd4cfc0a0084487
|
|
4
|
+
data.tar.gz: 3781eb7e227b6466555153687b6cf1783a7f4acbc6dffc1b5cd86c5b209dad91
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c7a31f33372b1432c461f11319daad6503755f6c173a5bba9aee8508034de826c77ef662579471c9ce18b802098f1c426942f71e46e0d6a18c0b492fbd2a3fd9
|
|
7
|
+
data.tar.gz: 96c0528c1e32b927abf1d5f042a4879bb67e5d3cc9fa408a4d655ff020e9a01d17483a99da83aaa573d632486f8e2532a7f6c760105eb46c7b94b97083357491
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0]
|
|
4
|
+
|
|
5
|
+
- Initial release of `dry-cli-ui`, which replaces `dry-cli-autocomplete` in this repository.
|
|
6
|
+
- `include Dry::CLI::UI` gives a command `ui`: `debug`, `info`, `success`, `warn`, `error` and `fatal` boxes, `box`, `status`, `spinner`, `progress`, `tasks` (nested and concurrent task trees), `table`, `prompt` and `confirm`.
|
|
7
|
+
- Plain-text fallback when a stream is not a terminal or runs under `TERM=dumb`; `NO_COLOR` support.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konstantin Gredeskoul
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# dry-cli-ui
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kigster/dry-cli-ui/actions/workflows/main.yml) 
|
|
4
|
+
|
|
5
|
+
Runtime terminal UI for [dry-cli](https://github.com/dry-rb/dry-cli) commands: spinners, progress bars, boxes, status lines, task trees, tables and prompts.
|
|
6
|
+
|
|
7
|
+
> [!NOTE]
|
|
8
|
+
> The design, and the reasons behind it, are in [SPECIFICATION](SPECIFICATION.md).
|
|
9
|
+
|
|
10
|
+
A long-running command has more to say than `puts` can show well: what it is doing now, how far along it is, what went wrong. Include one module and the command gets a `ui` that says it, in colour and in place on a terminal, and as plain lines when the output is piped to a file or a CI log.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "dry-cli-ui"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Ruby 4.0 or later.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "dry/cli"
|
|
24
|
+
require "dry-cli-ui"
|
|
25
|
+
|
|
26
|
+
class Import < Dry::CLI::Command
|
|
27
|
+
include Dry::CLI::UI
|
|
28
|
+
|
|
29
|
+
def call(**)
|
|
30
|
+
ui.info "Importing tax rules..."
|
|
31
|
+
|
|
32
|
+
rules = ui.spinner("Loading tax rules") { load_rules }
|
|
33
|
+
|
|
34
|
+
ui.progress("Importing rules", total: rules.size) do |bar|
|
|
35
|
+
rules.each do |rule|
|
|
36
|
+
import(rule)
|
|
37
|
+
bar.advance
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
ui.success "Imported #{rules.size} rules"
|
|
42
|
+
rescue => e
|
|
43
|
+
ui.error("Import failed", e.message)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
When the output is piped, and the import fails part way:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Loading tax rules...
|
|
52
|
+
✓ Loading tax rules (0.3s)
|
|
53
|
+
Importing rules...
|
|
54
|
+
✗ Importing rules 1482/1900 (4.1s)
|
|
55
|
+
┌─ Error ──────────────────────────────────────────────────┐
|
|
56
|
+
│ │
|
|
57
|
+
│ Import failed │
|
|
58
|
+
│ │
|
|
59
|
+
│ Could not validate rule US.2026.IRC.199A: missing │
|
|
60
|
+
│ dependency taxable_income │
|
|
61
|
+
│ │
|
|
62
|
+
└──────────────────────────────────────────────────────────┘
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
On a terminal the spinner turns and the bar fills in place, with percent, count and ETA, and each is replaced by the same `✓` or `✗` line when its block ends.
|
|
66
|
+
|
|
67
|
+
Include the module once in a base class and every command has `ui`. Including it loads nothing: the TTY gems load the first time `ui` is used.
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
### Messages
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
ui.debug "Resolved 1900 rules from 14 files"
|
|
75
|
+
ui.info "Importing tax rules..."
|
|
76
|
+
ui.success "Imported 1900 rules"
|
|
77
|
+
ui.warn "3 rules have no effective date"
|
|
78
|
+
ui.error "Import failed", e.message
|
|
79
|
+
ui.fatal "Database unreachable"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each draws a box with a single white border and the level's name as a coloured title. Every argument is a paragraph, wrapped to fit. The box fills the terminal less a two-column margin, or takes a fixed width:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
ui.info "Short and narrow", width: 40
|
|
86
|
+
ui.box "Name: Alan Turing", "Role: Cryptanalyst", title: "Profile" # untitled without title:
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Status lines
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
ui.status "Connected to the database", level: :success # ✓ Connected to the database
|
|
93
|
+
ui.status "Disk nearly full", level: :warn # ⚠ Disk nearly full
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Spinners
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
rules = ui.spinner("Loading tax rules") { load_rules }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Returns the block's value. Leaves `✓ Loading tax rules (0.3s)` behind, or `✗` and the re-raised error when the block fails.
|
|
103
|
+
|
|
104
|
+
### Progress bars
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
ui.progress("Importing rules", total: rules.size) do |bar|
|
|
108
|
+
rules.each do |rule|
|
|
109
|
+
import(rule)
|
|
110
|
+
bar.advance # or bar.advance(10)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The bar shows percent, `current/total` and ETA, and ends with `✓ Importing rules 1900/1900 (4.2s)`.
|
|
116
|
+
|
|
117
|
+
### Task trees
|
|
118
|
+
|
|
119
|
+
The block declares the tasks; they run once it returns, so the tree is drawn complete before the first one starts.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
ui.tasks("Deploy") do |t|
|
|
123
|
+
t.task("Build assets") { build }
|
|
124
|
+
t.group("Migrate") do |g|
|
|
125
|
+
g.task("users") { migrate(:users) }
|
|
126
|
+
g.task("orders") { migrate(:orders) }
|
|
127
|
+
end
|
|
128
|
+
t.group("Warm caches", concurrent: true) do |g|
|
|
129
|
+
g.task("fonts") { warm(:fonts) }
|
|
130
|
+
g.task("images") { warm(:images) }
|
|
131
|
+
end
|
|
132
|
+
t.task("Restart") { restart }
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
On a terminal, once it finishes:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
Deploy
|
|
140
|
+
├─ ✓ Build assets (0.4s)
|
|
141
|
+
├─ ✓ Migrate (0.3s)
|
|
142
|
+
│ ├─ ✓ users (0.1s)
|
|
143
|
+
│ └─ ✓ orders (0.2s)
|
|
144
|
+
├─ ✓ Warm caches (0.5s)
|
|
145
|
+
│ ├─ ✓ fonts (0.5s)
|
|
146
|
+
│ └─ ✓ images (0.3s)
|
|
147
|
+
└─ ✓ Restart (0.3s)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
While it runs, the tree redraws in place and every running task has its own spinner. Piped, each line is printed once it is final, and a group's line appears as `▸` when it starts. `concurrent: true` runs a group's tasks at the same time, on a group or on `ui.tasks` itself. When a task fails, it is marked `✗`, tasks already running finish, the rest are marked skipped (`–`), and the error is re-raised.
|
|
151
|
+
|
|
152
|
+
### Tables
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
ui.table([["Alan Turing", 41], ["Ada Lovelace", 36]], header: %w[Name Age])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
┌──────────────┬─────┐
|
|
160
|
+
│ Name │ Age │
|
|
161
|
+
├──────────────┼─────┤
|
|
162
|
+
│ Alan Turing │ 41 │
|
|
163
|
+
│ Ada Lovelace │ 36 │
|
|
164
|
+
└──────────────┴─────┘
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Tables are never truncated or rotated to fit the screen.
|
|
168
|
+
|
|
169
|
+
### Prompts
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
name = ui.prompt("Name?", default: "Alan Turing")
|
|
173
|
+
env = ui.prompt("Environment?", choices: %w[staging production], default: "staging")
|
|
174
|
+
tier = ui.prompt("Tier?", choices: { "Free" => :free, "Pro" => :pro })
|
|
175
|
+
ui.confirm("Deploy to #{env}?", default: false)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
On a terminal these use arrow-key menus and line editing. Otherwise they read lines from standard input, so answers can be piped:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
printf 'production\ny\n' | mycli deploy
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
When the input runs out, a prompt returns its default, or raises `Dry::CLI::UI::NonInteractiveError` if it has none.
|
|
185
|
+
|
|
186
|
+
## Where output goes
|
|
187
|
+
|
|
188
|
+
| To `out` (results) | To `err` (everything else) |
|
|
189
|
+
| ----------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
190
|
+
| `info`, `success`, `box`, `table`, `status` at those levels | `debug`, `warn`, `error`, `fatal`, spinners, progress bars, task trees, prompts |
|
|
191
|
+
|
|
192
|
+
`mycli export > rules.csv` therefore writes only the command's results to the file, while its progress stays on the screen. `ui` writes to the streams dry-cli was called with, so `Dry::CLI.new(registry).call(out: io, err: io)` captures everything.
|
|
193
|
+
|
|
194
|
+
A stream that is not a terminal, or runs under `TERM=dumb`, gets no animation, no cursor movement and no escape codes. [`NO_COLOR`](https://no-color.org) turns colour off and leaves animation on.
|
|
195
|
+
|
|
196
|
+
## Configuration
|
|
197
|
+
|
|
198
|
+
Override `ui` to configure the console:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
class ApplicationCommand < Dry::CLI::Command
|
|
202
|
+
include Dry::CLI::UI
|
|
203
|
+
|
|
204
|
+
def ui
|
|
205
|
+
@ui ||= Dry::CLI::UI::Console.new(
|
|
206
|
+
out: out || $stdout,
|
|
207
|
+
err: err || $stderr,
|
|
208
|
+
box_width: 72, # boxes are 72 columns rather than the whole terminal
|
|
209
|
+
color: nil, # true or false to override detection
|
|
210
|
+
animate: nil # true or false to override detection
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Relationship to dry-cli-help
|
|
217
|
+
|
|
218
|
+
`dry-cli-help` is static presentation: what does this command do? `dry-cli-ui` is runtime presentation: what is this command doing? Use either, or both.
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
gem "dry-cli"
|
|
222
|
+
gem "dry-cli-help"
|
|
223
|
+
gem "dry-cli-ui"
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
bin/setup # bundle install
|
|
230
|
+
just test # the suite, with 100% line and branch coverage enforced
|
|
231
|
+
just lint # rubocop
|
|
232
|
+
just ci # both
|
|
233
|
+
just format # rubocop -a, then mdformat
|
|
234
|
+
bin/console # IRB with the gem loaded
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Specs render into a `StringIO`. The animated code paths run against `FakeTTY`, a `StringIO` that answers `tty?` with true, and elapsed times come from a fake clock.
|
|
238
|
+
|
|
239
|
+
## Contributing
|
|
240
|
+
|
|
241
|
+
Bug reports and pull requests are welcome at <https://github.com/kigster/dry-cli-ui>.
|
|
242
|
+
|
|
243
|
+
> [!WARNING]
|
|
244
|
+
> The `dry-` prefix and the `Dry::CLI::UI` namespace do not imply endorsement by `dry-rb`. This is an independent gem that extends theirs.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/SPECIFICATION.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# `dry-cli-ui`
|
|
2
|
+
|
|
3
|
+
Rich runtime terminal UI for [`dry-cli`](https://github.com/dry-rb/dry-cli) commands.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
`dry-cli-ui` gives ordinary `dry-cli` commands a high-level API for presenting their **runtime state**.
|
|
8
|
+
|
|
9
|
+
It is particularly useful for long-running commands where plain `puts` output does not adequately communicate progress, activity, warnings, failures, or completion.
|
|
10
|
+
|
|
11
|
+
It is not intended primarily as a framework for building full-screen terminal applications.
|
|
12
|
+
|
|
13
|
+
Instead, it adds rich terminal UI to normal CLI commands while preserving the familiar command-line experience and terminal scrollback.
|
|
14
|
+
|
|
15
|
+
## Responsibilities
|
|
16
|
+
|
|
17
|
+
- Spinners
|
|
18
|
+
- Progress bars
|
|
19
|
+
- Status messages
|
|
20
|
+
- Success messages
|
|
21
|
+
- Warning messages
|
|
22
|
+
- Error messages
|
|
23
|
+
- Styled boxes and panels
|
|
24
|
+
- Tables
|
|
25
|
+
- Task trees
|
|
26
|
+
- Nested operations
|
|
27
|
+
- Several operations running at once
|
|
28
|
+
- Elapsed time and ETA
|
|
29
|
+
- Interactive prompts
|
|
30
|
+
- Terminal-aware rendering
|
|
31
|
+
- Graceful fallback when ANSI/interactive output is unavailable
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
class Import < Dry::CLI::Command
|
|
37
|
+
include Dry::CLI::UI
|
|
38
|
+
|
|
39
|
+
def call(**)
|
|
40
|
+
ui.info "Importing tax rules..."
|
|
41
|
+
|
|
42
|
+
ui.spinner("Loading tax rules") do
|
|
43
|
+
load_rules
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
ui.progress("Importing rules", total: rules.size) do |bar|
|
|
47
|
+
rules.each do |rule|
|
|
48
|
+
import(rule)
|
|
49
|
+
bar.advance
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
ui.success "Imported #{rules.size} rules"
|
|
54
|
+
rescue => e
|
|
55
|
+
ui.error("Import failed", e.message)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Example output, piped, when the import fails part way:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
Loading tax rules...
|
|
64
|
+
✓ Loading tax rules (0.3s)
|
|
65
|
+
Importing rules...
|
|
66
|
+
✗ Importing rules 1482/1900 (4.1s)
|
|
67
|
+
┌─ Error ──────────────────────────────────────────────────┐
|
|
68
|
+
│ │
|
|
69
|
+
│ Import failed │
|
|
70
|
+
│ │
|
|
71
|
+
│ Could not validate rule US.2026.IRC.199A: missing │
|
|
72
|
+
│ dependency taxable_income │
|
|
73
|
+
│ │
|
|
74
|
+
└──────────────────────────────────────────────────────────┘
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
On a terminal the spinner turns and the bar fills in place (`Importing rules ███████░░░ 78% 1482/1900 ETA 4s`), and each is replaced by the same outcome line when its block ends.
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
Commands depend on a small semantic API rather than directly manipulating terminal primitives:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
ui.debug(...)
|
|
85
|
+
ui.info(...)
|
|
86
|
+
ui.success(...)
|
|
87
|
+
ui.warn(...)
|
|
88
|
+
ui.error(...)
|
|
89
|
+
ui.fatal(...)
|
|
90
|
+
|
|
91
|
+
ui.spinner(...)
|
|
92
|
+
ui.progress(...)
|
|
93
|
+
ui.status(...)
|
|
94
|
+
|
|
95
|
+
ui.box(...)
|
|
96
|
+
ui.table(...)
|
|
97
|
+
ui.tasks(...)
|
|
98
|
+
|
|
99
|
+
ui.prompt(...)
|
|
100
|
+
ui.confirm(...)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
This separates **what the command wants to communicate** from **how the terminal renders it**.
|
|
104
|
+
|
|
105
|
+
## Rendering
|
|
106
|
+
|
|
107
|
+
The implementation builds on existing Ruby terminal libraries rather than reimplementing terminal mechanics: `tty-box`, `tty-spinner`, `tty-progressbar`, `tty-table`, `tty-prompt`, `tty-cursor`, `tty-screen`, `pastel` and `strings`.
|
|
108
|
+
|
|
109
|
+
The public API does not expose these dependencies. No method returns or yields a TTY object, and no argument takes one.
|
|
110
|
+
|
|
111
|
+
That leaves open the possibility of introducing other renderers later, including richer inline TUI implementations, without changing application command code.
|
|
112
|
+
|
|
113
|
+
## Design Principle
|
|
114
|
+
|
|
115
|
+
`dry-cli-ui` owns what the user sees **while a command runs and when it finishes**.
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
dry-cli
|
|
119
|
+
│
|
|
120
|
+
└── dry-cli-ui
|
|
121
|
+
│
|
|
122
|
+
├── spinner
|
|
123
|
+
├── progress
|
|
124
|
+
├── status
|
|
125
|
+
├── debug/info/success/warn/error/fatal
|
|
126
|
+
├── boxes
|
|
127
|
+
├── tables
|
|
128
|
+
├── task trees
|
|
129
|
+
└── prompts
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Relationship to dry-cli-help
|
|
133
|
+
|
|
134
|
+
The two gems deliberately have separate responsibilities:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
dry-cli
|
|
138
|
+
│
|
|
139
|
+
├── dry-cli-help
|
|
140
|
+
│ Static presentation
|
|
141
|
+
│
|
|
142
|
+
│ "What does this command do?"
|
|
143
|
+
│
|
|
144
|
+
└── dry-cli-ui
|
|
145
|
+
Runtime presentation
|
|
146
|
+
|
|
147
|
+
"What is this command doing?"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A CLI application can use either gem independently or combine them:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
gem "dry-cli"
|
|
154
|
+
gem "dry-cli-help"
|
|
155
|
+
gem "dry-cli-ui"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Together they provide richer presentation without turning `dry-cli` itself into a large terminal UI framework.
|
|
159
|
+
|
|
160
|
+
## Boxes
|
|
161
|
+
|
|
162
|
+
`debug`, `info`, `success`, `warn`, `error` and `fatal` each draw a box:
|
|
163
|
+
|
|
164
|
+
- a single-line white border,
|
|
165
|
+
- the level's name as a bold, coloured title in the top border (`┌─ Error ───`),
|
|
166
|
+
- one blank row above and below the text and two columns either side,
|
|
167
|
+
- each argument as its own paragraph, wrapped to fit, separated by a blank line.
|
|
168
|
+
|
|
169
|
+
The width is one of:
|
|
170
|
+
|
|
171
|
+
1. a fixed number of columns, per console (`Console.new(box_width: 72)`) or per call (`ui.info("...", width: 72)`), never wider than the terminal;
|
|
172
|
+
1. the whole terminal less a two-column margin, which is the default.
|
|
173
|
+
|
|
174
|
+
A box is never narrower than 20 columns. `ui.box(*paragraphs, title:, level:)` draws the same frame without a level, or with a level's styling and a different title.
|
|
175
|
+
|
|
176
|
+
| Level | Title | Glyph | Colour | Stream |
|
|
177
|
+
| --------- | ------- | ----- | ------- | ------ |
|
|
178
|
+
| `debug` | Debug | `·` | grey | err |
|
|
179
|
+
| `info` | Info | `ℹ` | cyan | out |
|
|
180
|
+
| `success` | Success | `✓` | green | out |
|
|
181
|
+
| `warn` | Warning | `⚠` | yellow | err |
|
|
182
|
+
| `error` | Error | `✗` | red | err |
|
|
183
|
+
| `fatal` | Fatal | `✖` | magenta | err |
|
|
184
|
+
|
|
185
|
+
`success` and `fatal` were added to the original five (`debug`, `info`, `warn`, `error`, `fatal`) because the example above uses `success`.
|
|
186
|
+
|
|
187
|
+
## Design decisions
|
|
188
|
+
|
|
189
|
+
### Architecture
|
|
190
|
+
|
|
191
|
+
```mermaid
|
|
192
|
+
flowchart LR
|
|
193
|
+
Command["Dry::CLI::Command<br/>include Dry::CLI::UI"] -->|"#ui"| Console
|
|
194
|
+
Console --> OutTerm["Terminal (out)"]
|
|
195
|
+
Console --> ErrTerm["Terminal (err)"]
|
|
196
|
+
Console --> Widgets
|
|
197
|
+
subgraph Widgets
|
|
198
|
+
Box
|
|
199
|
+
Status
|
|
200
|
+
Spinner
|
|
201
|
+
Progress
|
|
202
|
+
Tasks
|
|
203
|
+
Table
|
|
204
|
+
Prompt
|
|
205
|
+
end
|
|
206
|
+
Widgets --> TTY["TTY toolkit, Pastel, Strings"]
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
| File | Role |
|
|
210
|
+
| ----------------------------- | -------------------------------------------------------------------------- |
|
|
211
|
+
| `lib/dry/cli/ui.rb` | The mixin. Defines `#ui` and autoloads everything else. |
|
|
212
|
+
| `lib/dry/cli/ui/console.rb` | The public API. Routes each call to a widget and a stream. |
|
|
213
|
+
| `lib/dry/cli/ui/terminal.rb` | One stream and what it can do: TTY, animation, colour, width, height. |
|
|
214
|
+
| `lib/dry/cli/ui/theme.rb` | Levels (title, glyph, colour, stream) and operation states. |
|
|
215
|
+
| `lib/dry/cli/ui/duration.rb` | The monotonic clock and `0.4s` / `1m 02s` / `1h 02m` formatting. |
|
|
216
|
+
| `lib/dry/cli/ui/widgets/*.rb` | One renderer per widget, each owning its rich form and its plain fallback. |
|
|
217
|
+
|
|
218
|
+
Only files under `widgets/` and `terminal.rb` touch a TTY class. A future renderer replaces widgets, not `Console`.
|
|
219
|
+
|
|
220
|
+
### Including costs nothing at boot
|
|
221
|
+
|
|
222
|
+
`include Dry::CLI::UI` loads the mixin and nothing else. `Console`, the widgets and every TTY gem are autoloaded on first use, so a command that never calls `ui` never loads them. A spec pins this by checking `$LOADED_FEATURES` in a fresh process.
|
|
223
|
+
|
|
224
|
+
### Streams
|
|
225
|
+
|
|
226
|
+
Results go to `out`; everything about the command's own progress goes to `err`. Piping a command therefore captures its results and nothing else.
|
|
227
|
+
|
|
228
|
+
| `out` | `err` |
|
|
229
|
+
| --------------------------------- | -------------------------------------------------------------------------- |
|
|
230
|
+
| `info`, `success`, `box`, `table` | `debug`, `warn`, `error`, `fatal`, `spinner`, `progress`, `tasks`, prompts |
|
|
231
|
+
| `status` at `info` or `success` | `status` at `debug`, `warn`, `error` or `fatal` |
|
|
232
|
+
|
|
233
|
+
`#ui` uses the command's own `out` and `err` when dry-cli has set them (`Dry::CLI#call(out:, err:)`), and `$stdout` and `$stderr` otherwise. Every write flushes, so the two streams stay in order when both are piped to the same place.
|
|
234
|
+
|
|
235
|
+
### Terminal detection and fallback
|
|
236
|
+
|
|
237
|
+
Each stream is judged on its own:
|
|
238
|
+
|
|
239
|
+
| Condition | Animation and cursor movement | Colour |
|
|
240
|
+
| ---------------------------------- | ----------------------------- | ------ |
|
|
241
|
+
| TTY | yes | yes |
|
|
242
|
+
| TTY with `NO_COLOR` set, non-empty | yes | no |
|
|
243
|
+
| TTY with `TERM=dumb` | no | no |
|
|
244
|
+
| not a TTY | no | no |
|
|
245
|
+
|
|
246
|
+
`Console.new(color:, animate:, width:)` overrides detection. Without animation:
|
|
247
|
+
|
|
248
|
+
| Widget | Plain output |
|
|
249
|
+
| --------------------- | -------------------------------------------------------------------------------------------------- |
|
|
250
|
+
| spinner | `Label...` before the block, `✓ Label (1.2s)` or `✗ Label (1.2s)` after it |
|
|
251
|
+
| progress | `Label...` before, `✓ Label 1900/1900 (4.2s)` after, with the count reached |
|
|
252
|
+
| tasks | each line printed once final: a group when it starts, a task when it ends, skipped ones at the end |
|
|
253
|
+
| prompts | the question on `err`, one line read from input |
|
|
254
|
+
| boxes, tables, status | unchanged apart from colour |
|
|
255
|
+
|
|
256
|
+
### Spinners and progress bars
|
|
257
|
+
|
|
258
|
+
Both run a block, return what it returns, and re-raise what it raises after marking the outcome `✗`. The elapsed time comes from a monotonic clock. `ui.progress` yields a handle with `advance(step = 1)`, `current` and `total`; progress is clamped to `0..total`, and `total: 0` is allowed.
|
|
259
|
+
|
|
260
|
+
### Task trees
|
|
261
|
+
|
|
262
|
+
The block declares the tree; nothing runs until it returns. Knowing the whole shape first is what lets the tree draw `├─` and `└─` correctly before the first task starts.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
ui.tasks("Deploy") do |t|
|
|
266
|
+
t.task("Build assets") { build }
|
|
267
|
+
t.group("Migrate") do |g|
|
|
268
|
+
g.task("users") { migrate(:users) }
|
|
269
|
+
g.task("orders") { migrate(:orders) }
|
|
270
|
+
end
|
|
271
|
+
t.group("Warm caches", concurrent: true) do |g|
|
|
272
|
+
g.task("fonts") { warm(:fonts) }
|
|
273
|
+
g.task("images") { warm(:images) }
|
|
274
|
+
end
|
|
275
|
+
t.task("Restart") { restart }
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- Tasks run in order. A group declared `concurrent: true`, or `ui.tasks(concurrent: true)` at the top level, runs its tasks at the same time on `concurrent-ruby` futures.
|
|
280
|
+
- States are pending `○`, running `▸`, done `✓`, failed `✗` and skipped `–`. On an animated terminal a running task shows a turning spinner instead of `▸`, so a concurrent group is a multi-spinner.
|
|
281
|
+
- When a task raises, it and its enclosing groups are marked failed, tasks already running beside it finish, tasks not yet started are marked skipped, and the first error is re-raised.
|
|
282
|
+
- The live tree is redrawn in place with cursor movement, which cannot reach above the top of the screen. A tree with as many rows as the screen, or more, is printed line by line instead.
|
|
283
|
+
- Task blocks should not write to the terminal while a live tree is drawn; the next redraw overwrites their output.
|
|
284
|
+
|
|
285
|
+
### Tables
|
|
286
|
+
|
|
287
|
+
`ui.table(rows, header:)` renders with box-drawing borders and a bold header. Tables are data, so they are never narrowed, truncated or rotated to fit the screen. TTY::Table otherwise measures the screen, prints a warning on STDERR, and turns a wide table on its side.
|
|
288
|
+
|
|
289
|
+
### Prompts
|
|
290
|
+
|
|
291
|
+
`ui.prompt(question, default:, choices:)` asks for a line of text, or for one of `choices` (an Array of names, or a Hash of names to the values returned). `ui.confirm(question, default: false)` asks yes or no.
|
|
292
|
+
|
|
293
|
+
With an interactive input and output they use `tty-prompt`'s line editing and arrow-key menus. Otherwise they read lines, so answers can be piped in:
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
printf 'production\ny\n' | mycli deploy
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
An empty answer takes the default. An exhausted input takes the default too, and a question with no default raises `Dry::CLI::UI::NonInteractiveError` rather than inventing an answer. An answer that is not a valid choice, or not yes or no, asks again.
|
|
300
|
+
|
|
301
|
+
### A TTY::Box defect worked around
|
|
302
|
+
|
|
303
|
+
With a fixed width, TTY::Box 0.7 wraps text but sizes the box from the unwrapped lines, so everything past the first rows is silently dropped. `Widgets::Box` wraps the text with `Strings::Wrap` first, leaving TTY::Box nothing to wrap.
|
|
304
|
+
|
|
305
|
+
## Acceptance criteria
|
|
306
|
+
|
|
307
|
+
- [x] `include Dry::CLI::UI` gives a command `#ui`; including it loads no TTY gem.
|
|
308
|
+
- [x] `#ui` writes to the streams dry-cli was called with.
|
|
309
|
+
- [x] `debug`, `info`, `success`, `warn`, `error` and `fatal` draw white single-line boxes titled by level, wrapped, as wide as configured or the terminal less a margin, and never lose text.
|
|
310
|
+
- [x] `spinner`, `progress` and `tasks` return their block's value, re-raise its error, and leave an outcome line with the elapsed time; progress shows percent, count and ETA.
|
|
311
|
+
- [x] Task trees nest, run groups concurrently when asked, and mark failed and skipped tasks.
|
|
312
|
+
- [x] Tables render rows and a header without truncation.
|
|
313
|
+
- [x] Prompts work interactively and from piped input, and never block on an exhausted input.
|
|
314
|
+
- [x] Output that is not a TTY, or runs under `TERM=dumb`, contains no escape sequences; `NO_COLOR` removes colour.
|
|
315
|
+
- [x] The public API exposes no TTY object.
|
|
316
|
+
- [x] 100% line and branch coverage, enforced by the suite.
|
|
317
|
+
|
|
318
|
+
## Out of scope
|
|
319
|
+
|
|
320
|
+
- Full-screen applications, alternate screen buffers, and a public cursor-positioning API. TTY::Cursor and TTY::Screen are used internally only.
|
|
321
|
+
- Keyboard input beyond prompts.
|
|
322
|
+
- Renderers other than the TTY toolkit. The widget boundary allows one later.
|