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.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ module Widgets
9
+ # Questions, choices and yes/no confirmations.
10
+ #
11
+ # When both the input and the output are interactive terminals, this
12
+ # uses arrow-key menus and line editing. Otherwise it prints the
13
+ # question and reads a line, so answers can be piped in:
14
+ #
15
+ # printf 'production\ny\n' | mycli deploy
16
+ #
17
+ # An exhausted input returns the default. A question without a
18
+ # default raises {NonInteractiveError} rather than inventing an answer.
19
+ class Prompt
20
+ # @param input [IO] where answers come from
21
+ # @param terminal [Terminal] where questions go
22
+ # @param backend [TTY::Prompt, nil] the interactive implementation; built on first use when nil
23
+ def initialize(input:, terminal:, backend: nil)
24
+ @input = input
25
+ @terminal = terminal
26
+ @backend = backend
27
+ end
28
+
29
+ # Asks for a free-form answer, or for one of a list of choices.
30
+ #
31
+ # @param question [String]
32
+ # @param default [Object, nil] returned for an empty answer; for choices, the name of one
33
+ # @param choices [Array<String>, Hash{String => Object}, nil]
34
+ # names to pick from, or names mapped to the values to return
35
+ # @return [Object, nil] the answer, or the chosen value
36
+ # @raise [NonInteractiveError] when the input is exhausted and there is no default
37
+ def ask(question, default: nil, choices: nil)
38
+ if interactive?
39
+ options = default.nil? ? {} : { default: default }
40
+ choices ? backend.select(question, choices, **options) : backend.ask(question, **options)
41
+ else
42
+ choices ? plain_select(question, pairs(choices), default) : plain_ask(question, default)
43
+ end
44
+ end
45
+
46
+ # Asks a yes/no question.
47
+ #
48
+ # @param question [String]
49
+ # @param default [Boolean] returned for an empty answer or an exhausted input
50
+ # @return [Boolean]
51
+ def confirm(question, default: false)
52
+ return backend.yes?(question, default: default) if interactive?
53
+
54
+ loop do
55
+ terminal.print("#{question} #{default ? '(Y/n)' : '(y/N)'} ")
56
+ case read&.downcase
57
+ in nil | "" then return default
58
+ in "y" | "yes" then return true
59
+ in "n" | "no" then return false
60
+ else terminal.puts("Please answer y or n.")
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ # @return [IO]
68
+ attr_reader :input
69
+
70
+ # @return [Terminal]
71
+ attr_reader :terminal
72
+
73
+ # @return [TTY::Prompt]
74
+ def backend
75
+ @backend ||= TTY::Prompt.new(
76
+ input: input,
77
+ output: terminal.io,
78
+ enable_color: terminal.color?,
79
+ interrupt: :signal
80
+ )
81
+ end
82
+
83
+ # @return [Boolean]
84
+ def interactive?
85
+ input.respond_to?(:tty?) && input.tty? && terminal.animated?
86
+ end
87
+
88
+ # @return [String, nil] the next line without surrounding whitespace, or nil at the end of input
89
+ def read
90
+ input.gets&.strip
91
+ end
92
+
93
+ # @param question [String]
94
+ # @param default [Object, nil]
95
+ # @return [Object, nil]
96
+ def plain_ask(question, default)
97
+ terminal.print("#{question}#{" [#{default}]" unless default.nil?} ")
98
+ answer = read
99
+ raise NonInteractiveError, "no answer for #{question.inspect} and no default" if answer.nil? && default.nil?
100
+
101
+ answer.nil? || answer.empty? ? default : answer
102
+ end
103
+
104
+ # @param question [String]
105
+ # @param options [Array<Array(String, Object)>]
106
+ # @param default [Object, nil] the name of the default choice
107
+ # @return [Object]
108
+ def plain_select(question, options, default)
109
+ terminal.puts(question)
110
+ options.each_with_index { |(name, _), index| terminal.puts(" #{index + 1}) #{name}") }
111
+ loop do
112
+ terminal.print("Choose 1-#{options.size}#{" [#{default}]" unless default.nil?}: ")
113
+ line = read
114
+ answer = line.nil? || line.empty? ? default&.to_s : line
115
+ found = choice(options, answer) if answer
116
+ return found.last if found
117
+ raise NonInteractiveError, "no valid choice for #{question.inspect}" if line.nil?
118
+
119
+ terminal.puts("Choose a number from 1 to #{options.size}, or type a choice.")
120
+ end
121
+ end
122
+
123
+ # @param options [Array<Array(String, Object)>]
124
+ # @param answer [String] a 1-based position or a name
125
+ # @return [Array(String, Object), nil]
126
+ def choice(options, answer)
127
+ return options[answer.to_i - 1] if answer.match?(/\A\d+\z/) && answer.to_i.between?(1, options.size)
128
+
129
+ options.find { |name, _| name.to_s == answer }
130
+ end
131
+
132
+ # @param choices [Array<String>, Hash{String => Object}]
133
+ # @return [Array<Array(String, Object)>]
134
+ def pairs(choices)
135
+ choices.is_a?(Hash) ? choices.to_a : choices.map { |name| [name, name] }
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-spinner"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ module Widgets
9
+ # Shows that a block is running, then how it ended and how long it took.
10
+ #
11
+ # On an animated terminal the spinner turns until the block returns
12
+ # and is replaced by the outcome line. Anywhere else it prints
13
+ # `Label...` before the block and the outcome line after it.
14
+ class Spinner
15
+ # @param terminal [Terminal]
16
+ # @param clock [#call] returns monotonic seconds
17
+ def initialize(terminal, clock:)
18
+ @terminal = terminal
19
+ @clock = clock
20
+ end
21
+
22
+ # Runs the block under a spinner.
23
+ #
24
+ # @param label [String]
25
+ # @yield the work to show progress for
26
+ # @return [Object] whatever the block returns
27
+ # @raise [Exception] whatever the block raises, after marking the spinner failed
28
+ def run(label)
29
+ started = clock.call
30
+ spinner = start(label)
31
+ ok = false
32
+ result = yield
33
+ ok = true
34
+ result
35
+ ensure
36
+ spinner&.stop
37
+ terminal.puts(Outcome.line(terminal, ok ? :done : :failed, label, clock.call - started))
38
+ end
39
+
40
+ private
41
+
42
+ # @return [Terminal]
43
+ attr_reader :terminal
44
+
45
+ # @return [#call]
46
+ attr_reader :clock
47
+
48
+ # @param label [String]
49
+ # @return [TTY::Spinner, nil] the running spinner, or nil when not animating
50
+ def start(label)
51
+ unless terminal.animated?
52
+ terminal.puts("#{label}...")
53
+ return
54
+ end
55
+
56
+ TTY::Spinner.new(":spinner #{label}", output: terminal.io, format: :dots, hide_cursor: true, clear: true)
57
+ .tap(&:auto_spin)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ module Widgets
7
+ # A single line with a coloured glyph: `✓ Connected to the database`.
8
+ # The light-weight sibling of {Box}.
9
+ module Status
10
+ # @param terminal [Terminal]
11
+ # @param level [Theme::Level]
12
+ # @param text [String]
13
+ # @return [String] the line, without a newline
14
+ def self.line(terminal, level, text)
15
+ "#{terminal.pastel.decorate(level.glyph, level.color)} #{text}"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-table"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ module Widgets
9
+ # Rows and an optional bold header, framed with box-drawing lines.
10
+ #
11
+ # Tables are data, so they are never narrowed to fit the terminal: a
12
+ # column is not truncated and a table is not rotated. A table wider
13
+ # than the screen wraps the way any long line does.
14
+ class Table
15
+ # Wider than any table anyone will render. TTY::Table otherwise
16
+ # measures the screen, warns on STDERR, and turns a wide table on
17
+ # its side.
18
+ UNLIMITED = 1_000_000
19
+
20
+ # @param terminal [Terminal]
21
+ def initialize(terminal)
22
+ @terminal = terminal
23
+ end
24
+
25
+ # @param rows [Array<Array<#to_s>>]
26
+ # @param header [Array<#to_s>, nil]
27
+ # @return [String] the table ending in a newline, or "" when there are no rows
28
+ def render(rows, header: nil)
29
+ return "" if rows.empty?
30
+
31
+ table = TTY::Table.new(
32
+ header: header&.map { |cell| terminal.pastel.bold(cell.to_s) },
33
+ rows: rows.map { |row| row.map(&:to_s) }
34
+ )
35
+ "#{table.render(:unicode, padding: [0, 1], width: UNLIMITED)}\n"
36
+ end
37
+
38
+ private
39
+
40
+ # @return [Terminal]
41
+ attr_reader :terminal
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ module Widgets
9
+ # Runs a tree of named tasks and shows the state of each: pending,
10
+ # running, done, failed or skipped, with elapsed times.
11
+ #
12
+ # The block only declares the tree; nothing runs until it returns, so
13
+ # the whole shape is known before the first task starts. Tasks run in
14
+ # order, except inside a group declared `concurrent: true`, whose
15
+ # tasks all run at once.
16
+ #
17
+ # On an animated terminal the tree is drawn once and redrawn in place,
18
+ # with a spinner beside every running task. Otherwise, or when the
19
+ # tree is taller than the screen, each line is printed once it has
20
+ # something final to say.
21
+ #
22
+ # When a task raises, it and every group above it are marked failed,
23
+ # tasks already running beside it finish, everything that had not
24
+ # started is marked skipped, and the error is re-raised.
25
+ #
26
+ # @example
27
+ # ui.tasks("Deploy") do |t|
28
+ # t.task("Build assets") { build }
29
+ # t.group("Migrate", concurrent: true) do |g|
30
+ # g.task("users") { migrate(:users) }
31
+ # g.task("orders") { migrate(:orders) }
32
+ # end
33
+ # end
34
+ class Tasks
35
+ # Frames a running task cycles through on an animated terminal.
36
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
37
+
38
+ # Seconds between spinner frames.
39
+ INTERVAL = 0.1
40
+
41
+ # One task, or a group of them.
42
+ class Node
43
+ # @param name [String]
44
+ # @param job [Proc, nil] the work; nil for a group
45
+ # @param concurrent [Boolean] whether a group runs its tasks at once
46
+ def initialize(name, job = nil, concurrent: false)
47
+ @name = name
48
+ @job = job
49
+ @concurrent = concurrent
50
+ @children = []
51
+ @state = :pending
52
+ @seconds = nil
53
+ end
54
+
55
+ # @return [String]
56
+ attr_reader :name
57
+
58
+ # @return [Proc, nil]
59
+ attr_reader :job
60
+
61
+ # @return [Boolean]
62
+ attr_reader :concurrent
63
+ alias concurrent? concurrent
64
+
65
+ # @return [Array<Node>]
66
+ attr_reader :children
67
+
68
+ # @return [Symbol] one of the keys of {Theme::STATES}
69
+ attr_accessor :state
70
+
71
+ # @return [Float, nil] how long the node ran, once it has finished
72
+ attr_accessor :seconds
73
+
74
+ # @return [Boolean] whether this node holds tasks rather than work
75
+ def group? = job.nil?
76
+ end
77
+
78
+ # What the declaration block is given.
79
+ class Builder
80
+ # @param nodes [Array<Node>] the list new nodes are appended to
81
+ def initialize(nodes)
82
+ @nodes = nodes
83
+ end
84
+
85
+ # Declares a task.
86
+ #
87
+ # @param name [String]
88
+ # @yield the work
89
+ # @return [self]
90
+ # @raise [ArgumentError] without a block
91
+ def task(name, &job)
92
+ raise ArgumentError, "task #{name.inspect} needs a block" unless job
93
+
94
+ nodes << Node.new(name, job)
95
+ self
96
+ end
97
+
98
+ # Declares a group of tasks.
99
+ #
100
+ # @param name [String]
101
+ # @param concurrent [Boolean] run the group's tasks at the same time
102
+ # @yieldparam group [Builder] declares the tasks inside the group
103
+ # @return [self]
104
+ # @raise [ArgumentError] without a block
105
+ def group(name, concurrent: false)
106
+ raise ArgumentError, "group #{name.inspect} needs a block" unless block_given?
107
+
108
+ node = Node.new(name, concurrent: concurrent)
109
+ nodes << node
110
+ yield Builder.new(node.children)
111
+ self
112
+ end
113
+
114
+ private
115
+
116
+ # @return [Array<Node>]
117
+ attr_reader :nodes
118
+ end
119
+
120
+ # @param terminal [Terminal]
121
+ # @param clock [#call] returns monotonic seconds
122
+ def initialize(terminal, clock:)
123
+ @terminal = terminal
124
+ @clock = clock
125
+ @roots = []
126
+ @live = nil
127
+ @lock = Mutex.new
128
+ @frame = 0
129
+ end
130
+
131
+ # Declares the tree with the block, then runs it.
132
+ #
133
+ # @param title [String, nil] a heading printed above the tree
134
+ # @param concurrent [Boolean] run the top-level tasks at the same time
135
+ # @yieldparam tasks [Builder]
136
+ # @return [nil]
137
+ def run(title = nil, concurrent: false)
138
+ yield Builder.new(roots)
139
+ terminal.puts(terminal.pastel.bold(title)) if title
140
+ ticker = start_ticker
141
+ begin
142
+ run_all(roots, concurrent)
143
+ ensure
144
+ ticker&.shutdown
145
+ ticker&.wait_for_termination(1)
146
+ skip_pending
147
+ end
148
+ nil
149
+ end
150
+
151
+ private
152
+
153
+ # @return [Terminal]
154
+ attr_reader :terminal
155
+
156
+ # @return [#call]
157
+ attr_reader :clock
158
+
159
+ # @return [Array<Node>]
160
+ attr_reader :roots
161
+
162
+ # @return [Mutex] held while the tree changes or is drawn
163
+ attr_reader :lock
164
+
165
+ # @return [Integer] the spinner frame to draw next
166
+ attr_accessor :frame
167
+
168
+ # @param nodes [Array<Node>]
169
+ # @param concurrent [Boolean]
170
+ # @return [void]
171
+ def run_all(nodes, concurrent)
172
+ return nodes.each { |node| execute(node) } unless concurrent
173
+
174
+ futures = nodes.map { |node| Concurrent::Promises.future(node) { |each| execute(each) } }
175
+ futures.each(&:wait)
176
+ failed = futures.find(&:rejected?)
177
+ raise failed.reason if failed
178
+ end
179
+
180
+ # @param node [Node]
181
+ # @return [void]
182
+ def execute(node)
183
+ started = clock.call
184
+ change(node, :running)
185
+ ok = false
186
+ node.group? ? run_all(node.children, node.concurrent?) : node.job.call
187
+ ok = true
188
+ ensure
189
+ change(node, ok ? :done : :failed, seconds: clock.call - started)
190
+ end
191
+
192
+ # @return [void]
193
+ def skip_pending
194
+ rows.each_key { |node| change(node, :skipped) if node.state == :pending }
195
+ end
196
+
197
+ # @param node [Node]
198
+ # @param state [Symbol]
199
+ # @param seconds [Float, nil]
200
+ # @return [void]
201
+ def change(node, state, seconds: nil)
202
+ lock.synchronize do
203
+ node.state = state
204
+ node.seconds = seconds
205
+ if live?
206
+ redraw
207
+ elsif settled?(node)
208
+ terminal.puts(line(node))
209
+ end
210
+ end
211
+ end
212
+
213
+ # Whether a node has reached the state plain output reports: a group
214
+ # once it starts, so its children appear beneath it, and a task once
215
+ # it ends.
216
+ #
217
+ # @param node [Node]
218
+ # @return [Boolean]
219
+ def settled?(node)
220
+ return true if node.state == :skipped
221
+
222
+ node.group? ? node.state == :running : node.state != :running
223
+ end
224
+
225
+ # Whether to redraw in place. Needs cursor movement, and a tree that
226
+ # fits on screen: the cursor cannot move above the top row.
227
+ #
228
+ # @return [Boolean]
229
+ def live?
230
+ @live = terminal.animated? && rows.size < terminal.height if @live.nil?
231
+ @live
232
+ end
233
+
234
+ # Draws the tree and starts turning the spinners, when live.
235
+ #
236
+ # @return [Concurrent::TimerTask, nil]
237
+ def start_ticker
238
+ return unless live?
239
+
240
+ rows.each_key { |node| terminal.puts(line(node)) }
241
+ Concurrent::TimerTask.new(execution_interval: INTERVAL) { tick }.tap(&:execute)
242
+ end
243
+
244
+ # @return [void]
245
+ def tick
246
+ lock.synchronize do
247
+ self.frame += 1
248
+ redraw
249
+ end
250
+ end
251
+
252
+ # @return [void]
253
+ def redraw
254
+ terminal.print(terminal.cursor.up(rows.size))
255
+ rows.each_key { |node| terminal.print("#{terminal.cursor.clear_line}#{line(node)}\n") }
256
+ end
257
+
258
+ # @param node [Node]
259
+ # @return [String]
260
+ def line(node)
261
+ pastel = terminal.pastel
262
+ glyph, color = Theme::STATES.fetch(node.state)
263
+ glyph = FRAMES[frame % FRAMES.size] if node.state == :running && live?
264
+ elapsed = " #{pastel.bright_black("(#{Duration.format(node.seconds)})")}" if node.seconds
265
+ "#{pastel.bright_black(rows.fetch(node))}#{pastel.decorate(glyph, color)} #{node.name}#{elapsed}"
266
+ end
267
+
268
+ # Every node in display order, with the tree branch drawn before it.
269
+ #
270
+ # @return [Hash{Node => String}]
271
+ def rows
272
+ @rows ||= layout(roots, "", {})
273
+ end
274
+
275
+ # @param nodes [Array<Node>]
276
+ # @param prefix [String]
277
+ # @param acc [Hash{Node => String}]
278
+ # @return [Hash{Node => String}]
279
+ def layout(nodes, prefix, acc)
280
+ nodes.each_with_index do |node, index|
281
+ last = index == nodes.size - 1
282
+ acc[node] = "#{prefix}#{last ? '└─ ' : '├─ '}"
283
+ layout(node.children, "#{prefix}#{last ? ' ' : '│ '}", acc)
284
+ end
285
+ acc
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ # The renderers behind {Console}. Each one takes a {Terminal} and owns
7
+ # both its rich form and its plain fallback. Nothing outside this
8
+ # namespace touches a TTY toolkit class.
9
+ module Widgets
10
+ autoload :Box, File.expand_path("widgets/box", __dir__)
11
+ autoload :Outcome, File.expand_path("widgets/outcome", __dir__)
12
+ autoload :Progress, File.expand_path("widgets/progress", __dir__)
13
+ autoload :Prompt, File.expand_path("widgets/prompt", __dir__)
14
+ autoload :Spinner, File.expand_path("widgets/spinner", __dir__)
15
+ autoload :Status, File.expand_path("widgets/status", __dir__)
16
+ autoload :Table, File.expand_path("widgets/table", __dir__)
17
+ autoload :Tasks, File.expand_path("widgets/tasks", __dir__)
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/dry/cli/ui.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui/version"
4
+
5
+ # The dry-rb namespace, which dry-cli lives in. This gem is not part of dry-rb.
6
+ module Dry
7
+ class CLI
8
+ # Runtime presentation for Dry::CLI commands: boxes, status lines,
9
+ # spinners, progress bars, task trees, tables and prompts.
10
+ #
11
+ # Include it in a command, or in the base class every command inherits
12
+ # from, and call {#ui}. Everything else loads the first time it is used,
13
+ # so including the module costs a command nothing at boot.
14
+ #
15
+ # @example
16
+ # class Import < Dry::CLI::Command
17
+ # include Dry::CLI::UI
18
+ #
19
+ # def call(**)
20
+ # ui.spinner("Loading YAML") { load_rules }
21
+ # ui.success "Imported #{rules.size} rules"
22
+ # rescue => e
23
+ # ui.error("Import failed", e.message)
24
+ # end
25
+ # end
26
+ module UI
27
+ # Base class for every error this gem raises.
28
+ class Error < StandardError; end
29
+
30
+ # Raised when a prompt needs an answer, no default was given, and the
31
+ # input stream is exhausted.
32
+ class NonInteractiveError < Error; end
33
+
34
+ autoload :Console, File.expand_path("ui/console", __dir__)
35
+ autoload :Duration, File.expand_path("ui/duration", __dir__)
36
+ autoload :Terminal, File.expand_path("ui/terminal", __dir__)
37
+ autoload :Theme, File.expand_path("ui/theme", __dir__)
38
+ autoload :Widgets, File.expand_path("ui/widgets", __dir__)
39
+
40
+ # The console this command presents through. Writes to the command's own
41
+ # `out` and `err` when dry-cli has set them, and to `$stdout` and
42
+ # `$stderr` otherwise.
43
+ #
44
+ # Override it to configure the console:
45
+ #
46
+ # @example
47
+ # def ui = @ui ||= Dry::CLI::UI::Console.new(box_width: 72)
48
+ #
49
+ # @return [Dry::CLI::UI::Console]
50
+ def ui
51
+ @ui ||= Console.new(
52
+ out: (respond_to?(:out, true) && __send__(:out)) || $stdout,
53
+ err: (respond_to?(:err, true) && __send__(:err)) || $stderr
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
data/lib/dry-cli-ui.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dry/cli/ui"
@@ -0,0 +1,32 @@
1
+ module Dry
2
+ class CLI
3
+ module UI
4
+ VERSION: String
5
+
6
+ class Error < StandardError
7
+ end
8
+
9
+ class NonInteractiveError < Error
10
+ end
11
+
12
+ def ui: () -> Console
13
+
14
+ class Console
15
+ def debug: (*_ToS paragraphs, ?width: Integer?) -> nil
16
+ def info: (*_ToS paragraphs, ?width: Integer?) -> nil
17
+ def success: (*_ToS paragraphs, ?width: Integer?) -> nil
18
+ def warn: (*_ToS paragraphs, ?width: Integer?) -> nil
19
+ def error: (*_ToS paragraphs, ?width: Integer?) -> nil
20
+ def fatal: (*_ToS paragraphs, ?width: Integer?) -> nil
21
+ def box: (*_ToS paragraphs, ?title: String?, ?level: Symbol?, ?width: Integer?) -> nil
22
+ def status: (*_ToS words, ?level: Symbol) -> nil
23
+ def spinner: [T] (String label) { () -> T } -> T
24
+ def progress: [T] (String label, total: Integer) { (untyped progress) -> T } -> T
25
+ def tasks: (?String? title, ?concurrent: bool) { (untyped tasks) -> void } -> nil
26
+ def table: (Array[Array[_ToS]] rows, ?header: Array[_ToS]?) -> nil
27
+ def prompt: (String question, ?default: untyped, ?choices: (Array[String] | Hash[String, untyped])?) -> untyped
28
+ def confirm: (String question, ?default: bool) -> bool
29
+ end
30
+ end
31
+ end
32
+ end