clack 0.6.2 → 0.7.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,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # The chrome around every prompt frame: the guide rail (gray connector, cyan
6
+ # active bar, yellow error bar), the gutters in front of content lines, the
7
+ # header/footer templates, and the keyboard hint footer.
8
+ #
9
+ # Mixed into {Prompt}. All helpers are private; subclasses call them without
10
+ # a receiver from +build_frame+. {#bar}, {#active_bar}, and {#bar_end} always
11
+ # return the colored symbol; only {#gutter} and {#guide_line} know about
12
+ # +with_guide+, so a frame that builds its own lines from +bar+ still renders
13
+ # exactly as before when guides are on.
14
+ module Chrome
15
+ private
16
+
17
+ # Store the per-prompt chrome options. Called from Prompt#initialize.
18
+ def setup_chrome(with_guide:, show_instructions:, instructions:)
19
+ unless valid_instructions?(instructions)
20
+ raise ArgumentError, "instructions must be a String or an Array of Strings"
21
+ end
22
+
23
+ @with_guide = with_guide
24
+ @show_instructions = show_instructions
25
+ @instructions = instructions
26
+ end
27
+
28
+ # nil, a String, or an Array of Strings.
29
+ def valid_instructions?(value)
30
+ value.nil? || value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
31
+ end
32
+
33
+ # Resolved on every frame so a global change mid-prompt applies to the next redraw.
34
+ def guide? = Settings.with_guide?(@with_guide)
35
+
36
+ def show_instructions? = Settings.show_instructions?(@show_instructions)
37
+
38
+ # Content-line prefix: the symbol plus two spaces, or nothing without guides.
39
+ def gutter(symbol = bar) = guide? ? "#{symbol} " : ""
40
+
41
+ # A rail-only line (connector or closing corner), or nothing without guides.
42
+ def guide_line(symbol = bar) = guide? ? "#{symbol}\n" : ""
43
+
44
+ # The gray rail: the connector above the message and every final-frame line.
45
+ def bar = Colors.gray(Symbols::S_BAR)
46
+
47
+ # The rail next to the step being answered: cyan while it is open, yellow
48
+ # while it shows a validation error or warning, gray once it is done.
49
+ def active_bar
50
+ case @state
51
+ when :initial, :active then Colors.cyan(Symbols::S_BAR)
52
+ when :error, :warning then Colors.yellow(Symbols::S_BAR)
53
+ else bar
54
+ end
55
+ end
56
+
57
+ # The closing corner, colored like {#active_bar}.
58
+ def bar_end
59
+ case @state
60
+ when :initial, :active then Colors.cyan(Symbols::S_BAR_END)
61
+ when :error, :warning then Colors.yellow(Symbols::S_BAR_END)
62
+ else Colors.gray(Symbols::S_BAR_END)
63
+ end
64
+ end
65
+
66
+ def symbol_for_state
67
+ case @state
68
+ when :initial, :active then Colors.cyan(Symbols::S_STEP_ACTIVE)
69
+ when :submit then Colors.green(Symbols::S_STEP_SUBMIT)
70
+ when :cancel then Colors.red(Symbols::S_STEP_CANCEL)
71
+ when :error, :warning then Colors.yellow(Symbols::S_STEP_ERROR)
72
+ end
73
+ end
74
+
75
+ # The help: line sits inside the active rail, like a message continuation.
76
+ def help_line
77
+ return "" unless @help
78
+
79
+ "#{gutter(active_bar)}#{Colors.dim(@help)}\n"
80
+ end
81
+
82
+ # One keyboard hint: dim key token, normal description ("↑/↓ to navigate").
83
+ def key_hint(key, text) = "#{Colors.dim(key)} #{text}"
84
+
85
+ # Built-in hints for the footer. Subclasses return an Array<String> of
86
+ # key_hint fragments; nil means the prompt has no footer. {#frame_footer}
87
+ # renders the result, so a subclass that defines this hook must not also
88
+ # print its own hint row: the 0.6.2 built-ins wrote one from a private
89
+ # method of this name and closed with bar_end, and a copy of that pattern
90
+ # that calls frame_footer would show the hints twice.
91
+ def keyboard_hints = nil
92
+
93
+ # The hint line under the body rows: the prompt's own hints, or the
94
+ # caller's instructions: text, joined with the hint separator. Empty when
95
+ # hidden or when there is nothing to show. Not guarded on state on purpose:
96
+ # the transient frame rendered right before finalize keeps the line, with
97
+ # a gray gutter, so the redraw erases the same number of rows.
98
+ def instruction_footer
99
+ return "" unless show_instructions?
100
+
101
+ hints = Array(@instructions.nil? ? keyboard_hints : @instructions)
102
+ return "" if hints.empty?
103
+
104
+ "#{gutter(active_bar)}#{hints.join(Symbols::S_HINT_SEPARATOR)}\n"
105
+ end
106
+
107
+ # Common frame header: connector + symbol/message + help line.
108
+ # @return [String] header lines
109
+ def frame_header
110
+ "#{guide_line}#{symbol_for_state} #{@message}\n#{help_line}"
111
+ end
112
+
113
+ # Common frame footer: validation messages, or hint footer + closing corner.
114
+ # @return [String] footer lines
115
+ def frame_footer
116
+ vlns = validation_message_lines
117
+ return vlns.join if vlns.any?
118
+
119
+ "#{instruction_footer}#{guide_line(bar_end)}"
120
+ end
121
+
122
+ # Build validation message lines for error or warning states.
123
+ # Returns array of lines to append, or empty array if no validation message.
124
+ def validation_message_lines
125
+ case @state
126
+ when :error
127
+ ["#{gutter(Colors.yellow(Symbols::S_BAR_END))}#{Colors.yellow(@error_message)}\n"]
128
+ when :warning
129
+ [
130
+ "#{gutter(Colors.yellow(Symbols::S_BAR_END))}#{Colors.yellow(@warning_message)}\n",
131
+ "#{gutter}#{Colors.dim("Press Enter to confirm, or edit your input")}\n"
132
+ ]
133
+ else
134
+ []
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -11,20 +11,23 @@ module Clack
11
11
  # Enable explicitly:
12
12
  # Clack.update_settings(ci_mode: true)
13
13
  #
14
- # Or auto-detect (non-TTY stdin or CI environment variable):
14
+ # Or auto-detect (non-TTY input or CI environment variable):
15
15
  # Clack.update_settings(ci_mode: :auto)
16
16
  module CiMode
17
17
  class << self
18
18
  # Check if CI mode is currently active.
19
19
  #
20
+ # @param input [IO, nil] the prompt's input stream, consulted by +:auto+
21
+ # (default: $stdin; nil also means $stdin, matching KeyReader's
22
+ # console fallback)
20
23
  # @return [Boolean]
21
- def active?
24
+ def active?(input = $stdin)
22
25
  setting = Settings.config[:ci_mode]
23
26
  case setting
24
27
  when true
25
28
  true
26
29
  when :auto
27
- !Environment.tty?($stdin) || Environment.ci?
30
+ !Environment.tty?(input || $stdin) || Environment.ci?
28
31
  else
29
32
  false
30
33
  end
@@ -7,53 +7,113 @@ module Clack
7
7
  # Reads single keystrokes from the terminal in raw mode.
8
8
  # Handles escape sequences for arrow keys and other special keys.
9
9
  #
10
+ # Escape sequences come in two shapes: CSI (+ESC [+ ... final byte) and
11
+ # SS3 (+ESC O+ + one byte), the latter used by terminals in application
12
+ # cursor mode (tmux, and after vim or less leave keypad-transmit mode on).
13
+ # Both are assembled here and folded into one canonical code per key via
14
+ # {Settings.normalize_key}, so prompts only ever see +"\e[A"+ for Up.
15
+ #
10
16
  # The Escape detection window is tunable via the +CLACK_ESCAPE_TIMEOUT+
11
17
  # env var (see {Environment.escape_timeout}) for high-latency links.
12
18
  module KeyReader
19
+ # Escape-sequence introducers: CSI ("[") and SS3 ("O").
20
+ SEQUENCE_INTRODUCERS = ["[", "O"].freeze
21
+
22
+ # Upper bound on bytes assembled after the introducer, so a stream of
23
+ # garbage can never keep the reader inside one sequence forever.
24
+ MAX_SEQUENCE_LENGTH = 32
25
+
26
+ # Range of CSI final bytes (+@+ through +~+), per ECMA-48.
27
+ CSI_FINAL_BYTES = (0x40..0x7E)
28
+
13
29
  class << self
14
30
  # Read a single keystroke in raw mode.
15
31
  # When input is an IO backed by a console, uses raw mode.
16
32
  # When input is a StringIO or test double, reads directly.
17
33
  #
34
+ # End of input (EOF) and a disconnected terminal both return Ctrl+C so
35
+ # the prompt cancels instead of spinning on an exhausted stream.
36
+ #
18
37
  # @param input [IO, nil] input stream (defaults to IO.console)
19
- # @return [String, nil] the key code, or nil on EOF
38
+ # @return [String] the (normalized) key code
39
+ # @raise [NotATerminalError] if the input cannot be put into raw mode
20
40
  def read(input = nil)
21
41
  io = input || IO.console
22
- raise IOError, "No console available (not a TTY?)" unless io
23
-
24
- # StringIO / test doubles don't support raw mode
25
- return read_from(io) unless io.respond_to?(:raw)
42
+ raise NotATerminalError, not_a_terminal_message(io) unless io
26
43
 
27
- io.raw { |raw_io| read_from(raw_io) }
44
+ key = io.respond_to?(:raw) ? read_raw(io) : read_from(io)
45
+ Settings.normalize_key(key || Settings::KEY_CTRL_C)
46
+ rescue NotATerminalError
47
+ raise
28
48
  rescue Errno::EIO, Errno::EBADF, IOError
29
49
  # Terminal disconnected or closed - treat as cancel
30
- "\u0003" # Ctrl+C
50
+ Settings::KEY_CTRL_C
31
51
  end
32
52
 
33
53
  private
34
54
 
55
+ # EINVAL is what the Windows console layer (and some BSD device nodes)
56
+ # report for a handle that is not a console, where POSIX says ENOTTY.
57
+ def read_raw(io)
58
+ io.raw { |raw_io| read_from(raw_io) }
59
+ rescue Errno::ENOTTY, Errno::ENODEV, Errno::ENXIO, Errno::EINVAL
60
+ raise NotATerminalError, not_a_terminal_message(io)
61
+ end
62
+
63
+ def not_a_terminal_message(io)
64
+ label = (io.nil? || io.equal?($stdin)) ? "stdin" : "input"
65
+ "#{label} is not an interactive terminal. Run from a terminal, or enable CI mode " \
66
+ "with Clack.update_settings(ci_mode: :auto) to auto-submit defaults when input is piped."
67
+ end
68
+
35
69
  def read_from(io)
36
70
  char = io.getc
37
- return char if char.nil? # EOF
38
71
  return char unless char == "\e"
39
72
 
40
73
  escape_timeout = Environment.escape_timeout
74
+
75
+ # Check for escape sequence - wait briefly for follow-up
76
+ return char unless readable?(io, escape_timeout)
77
+
78
+ introducer = io.getc.to_s
79
+ return "\e#{introducer}" unless SEQUENCE_INTRODUCERS.include?(introducer)
80
+
41
81
  # Subsequent bytes in a sequence normally arrive almost instantly, so
42
82
  # the inter-byte wait stays much shorter, but it scales with the
43
83
  # escape timeout so high-latency links still assemble full sequences.
44
- sequence_timeout = escape_timeout / 5.0
45
-
46
- # Check for escape sequence - wait briefly for follow-up
47
- return char unless io.respond_to?(:wait_readable) && io.wait_readable(escape_timeout)
84
+ "\e#{introducer}#{read_sequence(io, introducer, escape_timeout / 5.0)}"
85
+ end
48
86
 
49
- seq = io.getc.to_s
50
- return "\e#{seq}" unless seq == "["
87
+ # Assemble the bytes after a CSI or SS3 introducer. SS3 carries exactly
88
+ # one byte; CSI runs until its final byte so a keystroke typed right
89
+ # after an arrow key is not swallowed into the sequence.
90
+ def read_sequence(io, introducer, timeout)
91
+ bytes = []
92
+ while bytes.length < MAX_SEQUENCE_LENGTH && readable?(io, timeout)
93
+ byte = io.getc.to_s
94
+ break if byte.empty?
51
95
 
52
- # Read CSI sequence until no more characters arrive
53
- while io.respond_to?(:wait_readable) && io.wait_readable(sequence_timeout)
54
- seq += io.getc.to_s
96
+ bytes << byte
97
+ break if introducer == "O" || csi_final?(bytes)
55
98
  end
56
- "\e[#{seq[1..]}"
99
+ bytes.join
100
+ end
101
+
102
+ # The Linux virtual console sends F1..F5 as +ESC [ [ A+ .. +ESC [ [ E+,
103
+ # so a "[" right after the CSI introducer is a second introducer, not
104
+ # a final byte, even though 0x5B sits inside CSI_FINAL_BYTES.
105
+ #
106
+ # Compares the raw byte rather than the codepoint: +ord+ raises on a
107
+ # byte that is invalid in the input's encoding (line noise, a
108
+ # mis-encoded terminal), and such a byte simply is not a final byte.
109
+ def csi_final?(bytes)
110
+ return false if bytes.length == 1 && bytes.first == "["
111
+
112
+ CSI_FINAL_BYTES.cover?(bytes.last.getbyte(0))
113
+ end
114
+
115
+ def readable?(io, timeout)
116
+ io.respond_to?(:wait_readable) && io.wait_readable(timeout)
57
117
  end
58
118
  end
59
119
  end
@@ -38,17 +38,54 @@ module Clack
38
38
  # Including classes must implement:
39
39
  # - +navigable_items+ [Array] returns the current list to navigate
40
40
  module OptionsHelper
41
- # Normalize options to a consistent hash format.
42
- # Accepts strings, symbols, or hashes with value/label/hint/disabled keys.
41
+ # Normalize options to a consistent list of {Option} value objects.
43
42
  #
44
- # @param options [Array] Raw options in various formats
45
- # @return [Array<Hash>] Normalized option hashes
46
- # @raise [ArgumentError] if options is empty
43
+ # Accepts an Array of strings, symbols, or hashes with value/label/hint/disabled
44
+ # keys, or a Hash of value => label (or value => {label:, hint:, disabled:}).
45
+ #
46
+ # @param options [Array, Hash] Raw options in any accepted shape
47
+ # @return [Array<Option>] Normalized options
48
+ # @raise [ArgumentError] if options is nil or empty, or a Hash spec sets :value
47
49
  def normalize_options(options)
48
50
  raise ArgumentError, "options cannot be empty" if options.nil? || options.empty?
49
51
 
50
- options.map { |opt| OptionsHelper.normalize_option(opt) }
52
+ OptionsHelper.normalize_list(options)
53
+ end
54
+
55
+ # Normalize a list of raw options without the empty check.
56
+ # Used by prompts that build their own list (e.g. GroupMultiselect groups).
57
+ #
58
+ # @param options [Array, Hash] Raw options in any accepted shape
59
+ # @return [Array<Option>] Normalized options
60
+ def self.normalize_list(options)
61
+ option_entries(options).map { |opt| normalize_option(opt) }
62
+ end
63
+
64
+ # Expand the Hash shorthand (value => label, or value => spec Hash) into
65
+ # the Array-of-entries shape that {.normalize_option} understands.
66
+ # Anything that is not a Hash is returned unchanged.
67
+ #
68
+ # @param options [Array, Hash] Raw options
69
+ # @return [Array] Option entries (hashes, strings, symbols, or other bare values)
70
+ # @raise [ArgumentError] if a spec Hash sets :value
71
+ def self.option_entries(options)
72
+ return options unless options.is_a?(Hash)
73
+
74
+ options.map { |value, spec| hash_entry(value, spec) }
75
+ end
76
+
77
+ # Build one option entry from a Hash-shorthand pair.
78
+ # A Hash spec is merged with the value; anything else is the label
79
+ # (nil means "no label", so normalize_option falls back to value.to_s).
80
+ def self.hash_entry(value, spec)
81
+ return {value: value, label: spec&.to_s} unless spec.is_a?(Hash)
82
+ if spec.key?(:value)
83
+ raise ArgumentError, "options entry #{value.inspect} must not set :value (the Hash key is the value)"
84
+ end
85
+
86
+ spec.merge(value: value)
51
87
  end
88
+ private_class_method :hash_entry
52
89
 
53
90
  # Normalize a single option to an Option value object.
54
91
  # @param opt [Hash, String, Symbol] Raw option
@@ -22,11 +22,13 @@ module Clack
22
22
  # @example Creating a custom prompt
23
23
  # class MyPrompt < Clack::Core::Prompt
24
24
  # def build_frame
25
- # "#{bar}\n#{symbol_for_state} #{@message}\n"
25
+ # "#{frame_header}#{gutter(active_bar)}#{something}\n#{frame_footer}"
26
26
  # end
27
27
  # end
28
28
  #
29
29
  class Prompt
30
+ include Chrome
31
+
30
32
  # Minimum terminal width for clean rendering.
31
33
  # Prompts warn (non-blocking) if the terminal is narrower.
32
34
  MIN_TERMINAL_WIDTH = 40
@@ -82,14 +84,25 @@ module Clack
82
84
 
83
85
  # @param message [String] the prompt message to display
84
86
  # @param help [String, nil] optional help text shown below the message
85
- # @param validate [Proc, nil] optional validation proc; returns error string, Warning, or nil
87
+ # @param validate [Proc, Regexp, Symbol, Array, Hash, nil, false] validator, resolved by
88
+ # {Validators.resolve}; a proc returns an error string, {Clack::Warning}, or nil.
89
+ # nil or false disables validation
86
90
  # @param transform [Symbol, Proc, nil] transformer (symbol shortcut or proc); applied after validation
91
+ # @param with_guide [Boolean, nil] show the guide rail; nil uses Clack.settings[:with_guide]
92
+ # @param show_instructions [Boolean, nil] show the keyboard hint footer; nil uses
93
+ # Clack.settings[:show_instructions]
94
+ # @param instructions [String, Array<String>, nil] replacement hint text, rendered verbatim on one
95
+ # line (newlines are not re-prefixed); nil uses the prompt's built-in hints
87
96
  # @param input [IO] input stream (default: $stdin)
88
97
  # @param output [IO] output stream (default: $stdout)
89
- def initialize(message:, help: nil, validate: nil, transform: nil, input: $stdin, output: $stdout)
98
+ # @raise [ArgumentError] if instructions is not nil, a String, or an Array of Strings
99
+ def initialize(message:, help: nil, validate: nil, transform: nil,
100
+ with_guide: nil, show_instructions: nil, instructions: nil,
101
+ input: $stdin, output: $stdout)
90
102
  @message = message
91
103
  @help = help
92
- @validate = validate
104
+ setup_chrome(with_guide:, show_instructions:, instructions:)
105
+ @validate = Validators.resolve(validate)
93
106
  @transform = Transformers.resolve(transform)
94
107
  @input = input
95
108
  @output = output
@@ -114,8 +127,9 @@ module Clack
114
127
  # submits or cancels. Returns the final value or {Clack::CANCEL}.
115
128
  #
116
129
  # @return [Object, Clack::CANCEL] the submitted value or CANCEL sentinel
130
+ # @raise [NotATerminalError] if input is an IO that is not a TTY and CI mode is off
117
131
  def run
118
- return run_ci_mode if CiMode.active?
132
+ return run_ci_mode if CiMode.active?(@input)
119
133
 
120
134
  Prompt.register(self)
121
135
  warn_narrow_terminal
@@ -138,6 +152,11 @@ module Clack
138
152
 
139
153
  finalize
140
154
  (terminal_state? && @state == :cancel) ? CANCEL : @value
155
+ rescue NotATerminalError
156
+ # The first read failed, so the frame drawn above is orphaned: erase
157
+ # it so the caller's error output is the only thing left on screen.
158
+ finalize("")
159
+ raise
141
160
  ensure
142
161
  Prompt.unregister(self)
143
162
  cleanup_terminal if terminal_setup
@@ -325,10 +344,12 @@ module Clack
325
344
  end
326
345
 
327
346
  # Render the final frame after submit/cancel.
328
- def finalize
347
+ # @param frame [String] what replaces the live frame (default: the final
348
+ # frame); pass +""+ to erase the live frame and print nothing
349
+ def finalize(frame = build_final_frame)
329
350
  restore_cursor
330
351
  @output.print Cursor.clear_down
331
- @output.print build_final_frame
352
+ @output.print frame
332
353
  end
333
354
 
334
355
  # Build the final frame shown after interaction ends.
@@ -338,9 +359,7 @@ module Clack
338
359
  #
339
360
  # @return [String] the final frame content
340
361
  def build_final_frame
341
- "#{bar}\n" \
342
- "#{symbol_for_state} #{@message}\n" \
343
- "#{bar} #{styled_final_display}\n"
362
+ "#{guide_line}#{symbol_for_state} #{@message}\n#{gutter}#{styled_final_display}\n"
344
363
  end
345
364
 
346
365
  # The text to display in the final frame after submit/cancel.
@@ -358,10 +377,16 @@ module Clack
358
377
 
359
378
  # Auto-submit with current defaults in CI/non-interactive mode.
360
379
  # Subclass submit overrides (e.g., Text applying default_value) run normally.
361
- # Validation and transforms are applied. Returns the value regardless of
362
- # validation outcome since there's no interactive way to fix input.
380
+ # Validation and transforms are applied. A warning is confirmed the way a
381
+ # second Enter would (so the transform still runs); if validation fails, a
382
+ # warning goes to stderr and CANCEL is returned, since there is no
383
+ # interactive way to fix the input.
363
384
  def run_ci_mode
364
385
  submit
386
+ if @state == :warning
387
+ confirm_warning
388
+ submit
389
+ end
365
390
  if @state == :error
366
391
  $stderr.print "#{Colors.yellow("!")} #{Colors.yellow("CI mode: validation failed for")} \"#{@message}\": #{@error_message}\n"
367
392
  return CANCEL
@@ -396,76 +421,26 @@ module Clack
396
421
  # Output unavailable - terminal may need manual reset
397
422
  end
398
423
 
424
+ # Move the cursor back to the first row of the previous frame. On a TTY
425
+ # the distance includes the rows the terminal used to soft-wrap lines
426
+ # wider than the pane (a long message, or the keyboard hint footer in a
427
+ # narrow split); counting newlines alone lands one row low per wrapped
428
+ # line and leaves the wrapped remainder on screen after every redraw.
399
429
  def restore_cursor
400
430
  return unless @prev_frame
401
431
 
402
- lines = @prev_frame.count("\n")
432
+ lines = if Environment.tty?(@output)
433
+ Utils.rendered_rows(@prev_frame, Environment.columns(@output))
434
+ else
435
+ @prev_frame.count("\n")
436
+ end
403
437
  @output.print Cursor.up(lines) if lines.positive?
404
438
  @output.print Cursor.column(1)
405
439
  end
406
440
 
407
- def bar
408
- Colors.gray(Symbols::S_BAR)
409
- end
410
-
411
- def active_bar
412
- %i[error warning].include?(@state) ? Colors.yellow(Symbols::S_BAR) : bar
413
- end
414
-
415
- def bar_end
416
- %i[error warning].include?(@state) ? Colors.yellow(Symbols::S_BAR_END) : Colors.gray(Symbols::S_BAR_END)
417
- end
418
-
419
- def help_line
420
- return "" unless @help
421
-
422
- "#{bar} #{Colors.dim(@help)}\n"
423
- end
424
-
425
441
  def cursor_block
426
442
  Colors.inverse(" ")
427
443
  end
428
-
429
- def symbol_for_state
430
- case @state
431
- when :initial, :active then Colors.cyan(Symbols::S_STEP_ACTIVE)
432
- when :submit then Colors.green(Symbols::S_STEP_SUBMIT)
433
- when :cancel then Colors.red(Symbols::S_STEP_CANCEL)
434
- when :error, :warning then Colors.yellow(Symbols::S_STEP_ERROR)
435
- end
436
- end
437
-
438
- # Common frame header: bar + symbol/message + help line.
439
- # @return [String] header lines
440
- def frame_header
441
- "#{bar}\n#{symbol_for_state} #{@message}\n#{help_line}"
442
- end
443
-
444
- # Common frame footer: validation messages or bar_end.
445
- # Replaces the repeated pattern of splicing validation lines.
446
- # @return [String] footer lines
447
- def frame_footer
448
- vlns = validation_message_lines
449
- return vlns.join if vlns.any?
450
-
451
- "#{bar_end}\n"
452
- end
453
-
454
- # Build validation message lines for error or warning states.
455
- # Returns array of lines to append, or empty array if no validation message.
456
- def validation_message_lines
457
- case @state
458
- when :error
459
- ["#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n"]
460
- when :warning
461
- [
462
- "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@warning_message)}\n",
463
- "#{bar} #{Colors.dim("Press Enter to confirm, or edit your input")}\n"
464
- ]
465
- else
466
- []
467
- end
468
- end
469
444
  end
470
445
  end
471
446
  end