inquirex-tty 0.10.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module TTY
5
+ # Raised out of a tty-prompt widget when the user presses a navigation
6
+ # key, so whichever widget is open (ask, select, multi_select, yes?)
7
+ # unwinds without returning an answer. tty-prompt offers no hook that
8
+ # aborts a question, and a subclass per widget would mean overriding the
9
+ # read loop of each one; one exception covers them all.
10
+ class Navigation < StandardError
11
+ # @return [Symbol] :back, :skip or :help
12
+ attr_reader :action
13
+
14
+ # @param action [Symbol] :back, :skip or :help
15
+ def initialize(action)
16
+ @action = action
17
+ super("navigation: #{action}")
18
+ end
19
+ end
20
+
21
+ # The keys that navigate a TTY run, as a tty-reader listener that turns
22
+ # them into {Navigation} exceptions while a collecting prompt is open.
23
+ #
24
+ # Backspace and "?" are also editing keys, so in a text answer they
25
+ # navigate only while the answer is empty: Backspace on an empty line
26
+ # goes back, Backspace after text deletes a character, and "what?" types
27
+ # a question mark. Lists have no text, so there they always navigate.
28
+ # Ctrl-B goes back from anywhere: tty-reader maps byte 0x02 to :ctrl_b in
29
+ # every terminal and tty-prompt binds nothing to it.
30
+ #
31
+ # tty-reader reports a key after it has edited the line, so the listener
32
+ # cannot ask "was the line empty?" directly. It keeps its own count of the
33
+ # characters typed instead, from how much each event's line grew or
34
+ # shrank. A pre-filled answer is a tty-prompt +default:+, shown beside
35
+ # the question rather than typed into the line, so every line starts empty.
36
+ #
37
+ # @example Wrap a prompt call
38
+ # prompt.subscribe(KeyMap.new(can_back: engine.can_back?))
39
+ class KeyMap
40
+ # tty-reader key names that edit text and go back on an empty answer.
41
+ BACKSPACE = %i[backspace ctrl_h].freeze
42
+
43
+ # Every navigation key, for the help box.
44
+ BINDINGS = [
45
+ ["Enter", "submit the answer (an empty answer keeps the one shown)"],
46
+ ["Backspace", "go back to the previous question (on an empty answer)"],
47
+ ["Ctrl-B", "go back to the previous question"],
48
+ ["Ctrl-S", "skip the question (optional questions only)"],
49
+ ["↑ ↓ Space", "move and toggle in a list"],
50
+ ["?", "show this help (on an empty answer)"],
51
+ ["Ctrl-C", "abort without saving"]
52
+ ].freeze
53
+
54
+ # @param can_back [Boolean] whether there is a question to go back to;
55
+ # when false the back keys do nothing
56
+ def initialize(can_back: true)
57
+ @can_back = can_back
58
+ @length = 0
59
+ @line = nil
60
+ end
61
+
62
+ # The one-line reminder drawn above each collecting prompt.
63
+ #
64
+ # @param can_back [Boolean]
65
+ # @return [String]
66
+ def self.hint(can_back:)
67
+ [("⌫ back" if can_back), "^S skip", "? keys", "^C abort"].compact.join(" · ")
68
+ end
69
+
70
+ # tty-reader calls this for every key, after any key-specific handler.
71
+ #
72
+ # @param event [TTY::Reader::KeyEvent]
73
+ # @return [void]
74
+ # @raise [Navigation] when the key navigates
75
+ def keypress(event)
76
+ name = event.key.name
77
+ raise Navigation, :skip if name == :ctrl_s
78
+ raise Navigation, :back if @can_back && (name == :ctrl_b || (BACKSPACE.include?(name) && @length.zero?))
79
+ raise Navigation, :help if event.value == "?" && @length.zero?
80
+
81
+ track(event)
82
+ end
83
+
84
+ private
85
+
86
+ # Updates the typed-character count after an editing key.
87
+ #
88
+ # A line widget passes its whole line (prompt plus text) with each
89
+ # event; list widgets pass an empty string, and the only text they hold
90
+ # is the number typed into an enum_select.
91
+ #
92
+ # @param event [TTY::Reader::KeyEvent]
93
+ # @return [void]
94
+ def track(event)
95
+ return reset if %i[return enter].include?(event.key.name)
96
+ return track_keys(event) if event.line.to_s.empty?
97
+
98
+ delta = @line ? event.line.length - @line.length : first_delta(event)
99
+ @length = [@length + delta, 0].max
100
+ @line = event.line
101
+ end
102
+
103
+ # The first event of a line has nothing to diff against; the line
104
+ # started empty, so only a printable character changed it.
105
+ def first_delta(event) = printable?(event.value) ? 1 : 0
106
+
107
+ def track_keys(event)
108
+ if BACKSPACE.include?(event.key.name) || event.key.name == :delete
109
+ @length = [@length - 1, 0].max
110
+ elsif event.key.name == :num
111
+ @length += event.value.length
112
+ end
113
+ end
114
+
115
+ # Enter submits; a widget that reads several lines (multiline text)
116
+ # starts the next one empty.
117
+ def reset
118
+ @length = 0
119
+ @line = nil
120
+ end
121
+
122
+ def printable?(value)
123
+ value.to_s.length == 1 && value.match?(/[[:print:]]/)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -23,6 +23,11 @@ module Inquirex
23
23
  # (fallback) → prompt.ask
24
24
  #
25
25
  # Header display verb uses TTY::Font for large ASCII-art section titles.
26
+ #
27
+ # {#collect} drives one collecting step against an engine and binds the
28
+ # navigation keys ({KeyMap}): back steps the engine back and the caller's
29
+ # loop re-asks the previous question with its answer as the default; skip
30
+ # declines an optional question; "?" shows the keymap.
26
31
  class Renderer
27
32
  include UIHelper
28
33
 
@@ -62,6 +67,52 @@ module Inquirex
62
67
  sep(:magenta, "─")
63
68
  end
64
69
 
70
+ # Asks the engine's current collecting step and answers it, unless the
71
+ # user presses a navigation key ({KeyMap}) instead:
72
+ #
73
+ # - back: the engine steps back and nothing is answered; the caller's
74
+ # loop then asks the previous question again, pre-filled with the
75
+ # answer it had. Back is offered only when there is somewhere to go
76
+ # back to: on the first question the keys do nothing.
77
+ # - skip: an optional question is skipped (Engine#skip); a required one
78
+ # is refused with a notice and asked again.
79
+ # - help: the keymap is shown, then the loop asks the question again.
80
+ #
81
+ # @example Drive a flow's collecting steps
82
+ # until engine.finished?
83
+ # engine.current_step.display? ? (renderer.render(engine.current_step); engine.advance) : renderer.collect(engine)
84
+ # end
85
+ #
86
+ # @param engine [Inquirex::Engine] positioned on a collecting step
87
+ # @return [Symbol] +:answered+, +:back+, +:skipped+, +:help+, or +:stay+
88
+ # when a required question refused a skip
89
+ def collect(engine)
90
+ step_id = engine.current_step_id
91
+ can_back = engine.can_back?
92
+ puts pastel.dim(KeyMap.hint(can_back:))
93
+
94
+ answer = with_keys(can_back) do
95
+ render(engine.current_step,
96
+ suggestion: engine.suggestion_for(step_id),
97
+ default: engine.previous_answer(step_id))
98
+ end
99
+ engine.answer(answer)
100
+ :answered
101
+ rescue Navigation => e
102
+ navigate(engine, e.action)
103
+ end
104
+
105
+ # Shows every navigation key in a box and waits for a key.
106
+ #
107
+ # @return [void]
108
+ def show_keymap
109
+ width = KeyMap::BINDINGS.map { |key, _| key.length }.max
110
+ lines = KeyMap::BINDINGS.map { |key, action| "#{key.ljust(width)} #{action}" }
111
+ puts
112
+ puts box(lines.join("\n"), title: "Keys", bg: :blue, fg: :white, align: :left)
113
+ prompt.keypress(pastel.dim("Press any key to return to the question..."))
114
+ end
115
+
65
116
  # Renders a node. Returns the collected answer, or +nil+ for display verbs.
66
117
  #
67
118
  # @example Collect an answer for the current step, pre-checking LLM suggestions
@@ -74,9 +125,12 @@ module Inquirex
74
125
  # @param suggestion [Array, nil] prefill suggestion for the step (option
75
126
  # form values, e.g. from Engine#suggestion_for) — multi-select prompts
76
127
  # render these choices pre-checked so the user confirms or extends
128
+ # @param default [Object, nil] the answer to pre-fill, e.g. the one the
129
+ # user gave before going back (Engine#previous_answer)
77
130
  # @return [Object, nil]
78
- def render(node, suggestion: nil)
131
+ def render(node, suggestion: nil, default: nil)
79
132
  @current_suggestion = suggestion
133
+ @current_default = default
80
134
  if node.display?
81
135
  render_display_verb(node)
82
136
  nil
@@ -85,10 +139,124 @@ module Inquirex
85
139
  end
86
140
  ensure
87
141
  @current_suggestion = nil
142
+ @current_default = nil
88
143
  end
89
144
 
90
145
  private
91
146
 
147
+ # Runs the block with the navigation keys live. The {KeyMap} listens
148
+ # only while the block runs, so a "Press any key" on a display step
149
+ # treats every key as any key.
150
+ #
151
+ # tty-prompt subscribes each question to the reader and unsubscribes it
152
+ # only when the question returns normally, and tty-reader's
153
+ # subscribe-with-a-block does the same. A question unwound by
154
+ # {Navigation} would stay subscribed and go on reacting to every later
155
+ # keypress, so every listener added while the block ran, the KeyMap
156
+ # included, is removed here.
157
+ #
158
+ # @param can_back [Boolean]
159
+ # @return [Object] the block's value
160
+ # @raise [Navigation] when a navigation key is pressed
161
+ def with_keys(can_back)
162
+ return yield unless prompt.respond_to?(:subscribe)
163
+
164
+ listeners = reader_registrations&.to_a
165
+ key_map = KeyMap.new(can_back:)
166
+ prompt.subscribe(key_map)
167
+ yield
168
+ ensure
169
+ if key_map
170
+ drop_listeners_since(listeners)
171
+ prompt.unsubscribe(key_map)
172
+ end
173
+ end
174
+
175
+ # Acts on a navigation key pressed during {#collect}.
176
+ #
177
+ # @param engine [Inquirex::Engine]
178
+ # @param action [Symbol] :back, :skip or :help
179
+ # @return [Symbol] what {#collect} returns
180
+ def navigate(engine, action)
181
+ case action
182
+ when :back then go_back(engine)
183
+ when :skip then skip(engine)
184
+ else
185
+ show_keymap
186
+ :help
187
+ end
188
+ end
189
+
190
+ # @param engine [Inquirex::Engine]
191
+ # @return [Symbol] :skipped, or :stay when the question is required
192
+ def skip(engine)
193
+ if engine.current_step.required?
194
+ puts pastel.yellow(" ⚠ This question is required and cannot be skipped.")
195
+ return :stay
196
+ end
197
+
198
+ puts pastel.dim("↷ skipped #{engine.current_step_id}")
199
+ engine.skip
200
+ :skipped
201
+ end
202
+
203
+ # @param before [Array, nil] the reader's registrations before the prompt opened
204
+ # @return [void]
205
+ def drop_listeners_since(before)
206
+ live = reader_registrations
207
+ return unless before && live
208
+
209
+ (live.to_a - before).each { |stale| live.delete(stale) }
210
+ end
211
+
212
+ # tty-reader keeps its listeners in Wisper's registration set, which it
213
+ # exposes only privately (its own #unsubscribe reads it the same way).
214
+ #
215
+ # @return [Set, nil]
216
+ def reader_registrations
217
+ reader = prompt.respond_to?(:reader) ? prompt.reader : nil
218
+ return nil unless reader.respond_to?(:local_registrations, true)
219
+
220
+ reader.send(:local_registrations)
221
+ end
222
+
223
+ # Steps the engine back to the previous question. An LLM step between
224
+ # them is stepped over too: it asks the user nothing, and landing on it
225
+ # would rerun the extraction and carry the user straight forward again.
226
+ #
227
+ # @param engine [Inquirex::Engine]
228
+ # @return [Symbol] :back
229
+ def go_back(engine)
230
+ engine.back
231
+ engine.back while llm_step?(engine.current_step) && engine.can_back?
232
+ puts pastel.dim("↩ back to #{engine.current_step_id}")
233
+ :back
234
+ end
235
+
236
+ # @param node [Inquirex::Node, nil]
237
+ # @return [Boolean]
238
+ def llm_step?(node) = node.respond_to?(:llm_verb?) && node.llm_verb?
239
+
240
+ # tty-prompt's `default:` option for the value being pre-filled.
241
+ #
242
+ # @param value [Object, nil] defaults to the value passed to #render
243
+ # @return [Hash] {} or { default: value }
244
+ def default_option(value = @current_default)
245
+ value.nil? ? {} : { default: value }
246
+ end
247
+
248
+ # select and enum_select preselect by choice NAME, the label the user
249
+ # sees, while answers are option values; translate back.
250
+ #
251
+ # @param node [Inquirex::Node]
252
+ # @return [Hash] {} or { default: label }
253
+ def choice_default(node)
254
+ return {} if @current_default.nil?
255
+
256
+ labels = node.respond_to?(:option_labels) ? node.option_labels : nil
257
+ { default: labels&.fetch(@current_default.to_s, nil) || @current_default.to_s }
258
+ end
259
+
92
260
  # Dispatches display verbs to their styled renderers.
93
261
  # :header uses TTY::Font for a large ASCII-art title.
94
262
  # :btw and :warning use TTY::Box info/warn boxes.
@@ -164,13 +332,14 @@ module Inquirex
164
332
 
165
333
  # Single-line text.
166
334
  def render_text_input(node)
167
- prompt.ask(node.question)
335
+ prompt.ask(node.question, **default_option)
168
336
  end
169
337
 
170
338
  # Multi-line text (empty line to submit).
171
339
  def render_multiline(node)
172
340
  puts pastel.bold(node.question)
173
341
  puts pastel.dim("Enter your response. Press Enter on a blank line to submit.")
342
+ puts pastel.dim("Previous answer:\n#{@current_default}") unless @current_default.nil?
174
343
  sep(:cyan, "─")
175
344
  collect_multiline_text
176
345
  end
@@ -210,9 +379,9 @@ module Inquirex
210
379
  # @param convert [Symbol] :int or :float
211
380
  # @return [Numeric, nil]
212
381
  def ask_bounded(node, convert:)
213
- return prompt.ask(node.question, convert:) unless node.bounded?
382
+ return prompt.ask(node.question, convert:, **default_option) unless node.bounded?
214
383
 
215
- prompt.ask(node.question, convert:, in: bounds_range(node)) do |q|
384
+ prompt.ask(node.question, convert:, in: bounds_range(node), **default_option) do |q|
216
385
  q.messages[:range?] = "Enter a value #{describe_bounds(node)}."
217
386
  end
218
387
  end
@@ -239,16 +408,17 @@ module Inquirex
239
408
 
240
409
  # Boolean — tty-prompt yes?.
241
410
  def render_yes_no(node)
242
- prompt.yes?(node.question)
411
+ prompt.yes?(node.question, **default_option)
243
412
  end
244
413
 
245
414
  # Single-choice scrollable menu.
246
415
  def render_select(node)
247
- prompt.select(node.question, select_options(node))
416
+ prompt.select(node.question, select_options(node), **choice_default(node))
248
417
  end
249
418
 
250
419
  # Multiple-choice list (space to toggle, min 1 selection). Suggested
251
- # choices (an LLM extraction the user should confirm) render pre-checked.
420
+ # choices (an LLM extraction the user should confirm) render pre-checked,
421
+ # and after going back, the choices the user had made.
252
422
  def render_multi_select(node)
253
423
  prompt.multi_select(node.question, select_options(node), min: 1, **multi_select_defaults(node))
254
424
  end
@@ -261,7 +431,7 @@ module Inquirex
261
431
  # @param node [Inquirex::Node]
262
432
  # @return [Hash] {} or { default: Array<String> }
263
433
  def multi_select_defaults(node)
264
- suggested = Array(@current_suggestion)
434
+ suggested = Array(@current_default.nil? ? @current_suggestion : @current_default)
265
435
  return {} if suggested.empty?
266
436
 
267
437
  labels = node.respond_to?(:option_labels) ? node.option_labels : nil
@@ -271,7 +441,7 @@ module Inquirex
271
441
 
272
442
  # Numbered choice menu.
273
443
  def render_enum_select(node)
274
- prompt.enum_select(node.question, select_options(node))
444
+ prompt.enum_select(node.question, select_options(node), **choice_default(node))
275
445
  end
276
446
 
277
447
  # Hidden / masked input.
@@ -288,7 +458,7 @@ module Inquirex
288
458
  opts[:max] = hint_opts[:max] if hint_opts[:max]
289
459
  opts[:step] = hint_opts[:step] if hint_opts[:step]
290
460
  end
291
- prompt.slider(node.question, **opts)
461
+ prompt.slider(node.question, **opts, **default_option)
292
462
  end
293
463
 
294
464
  # Aliases: date, email, phone degrade to plain text_input in TTY.
@@ -39,13 +39,14 @@ module Inquirex
39
39
  # @param title [String, nil] optional title on the top border
40
40
  # @param bg [Symbol] background color name
41
41
  # @param fg [Symbol] foreground color name
42
+ # @param align [Symbol] :center, :left or :right
42
43
  # @return [void]
43
- def box(text, title: nil, bg: :green, fg: :white) # rubocop:disable Naming/MethodParameterName
44
+ def box(text, title: nil, bg: :green, fg: :white, align: :center) # rubocop:disable Naming/MethodParameterName
44
45
  w = [width, 80].min
45
46
  args = {
46
47
  width: w,
47
48
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
48
- align: :center,
49
+ align: align,
49
50
  style: { fg: fg, bg: bg,
50
51
  border: { type: :thin, fg: fg, bg: bg } }
51
52
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Inquirex
4
4
  module TTY
5
- VERSION = "0.10.0"
5
+ VERSION = "0.12.1"
6
6
  end
7
7
  end
data/lib/inquirex/tty.rb CHANGED
@@ -40,6 +40,8 @@ require_relative "tty/version"
40
40
  require_relative "tty/ui_helper"
41
41
  require_relative "tty/flow_loader"
42
42
  require_relative "tty/output_path"
43
+ require_relative "tty/answers_file"
44
+ require_relative "tty/key_map"
43
45
  require_relative "tty/renderer"
44
46
  require_relative "tty/commands/run"
45
47
  require_relative "tty/commands/validate"