├── lib ├── prompts │ ├── version.rb │ ├── text_prompt.rb │ ├── pause_prompt.rb │ ├── paragraph.rb │ ├── confirm_prompt.rb │ ├── select_prompt.rb │ ├── content.rb │ ├── text_utils.rb │ ├── form.rb │ ├── box.rb │ └── prompt.rb └── prompts.rb ├── .standard.yml ├── sig └── prompts.rbs ├── .gitignore ├── bin ├── setup └── console ├── test ├── test_helper.rb └── test_prompts.rb ├── Rakefile ├── Gemfile ├── .github └── workflows │ └── main.yml ├── CHANGELOG.md ├── LICENSE.txt ├── prompts.gemspec ├── Gemfile.lock ├── CODE_OF_CONDUCT.md └── README.md /lib/prompts/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | VERSION = "0.3.1" 5 | end 6 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/standardrb/standard 3 | ruby_version: 3.0 4 | -------------------------------------------------------------------------------- /sig/prompts.rbs: -------------------------------------------------------------------------------- 1 | module Prompts 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "prompts" 5 | 6 | require "minitest/autorun" 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "minitest/test_task" 5 | 6 | Minitest::TestTask.create 7 | 8 | require "standard/rake" 9 | 10 | task default: %i[test standard] 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in prompts.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "minitest", "~> 5.16" 11 | 12 | gem "standard", "~> 1.3" 13 | -------------------------------------------------------------------------------- /lib/prompts/text_prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | class TextPrompt < Prompt 5 | def initialize(...) 6 | super 7 | 8 | @instructions = "Press Enter to submit" 9 | @hint ||= "Type your response and press Enter ⏎" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/test_prompts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestPrompts < Minitest::Test 6 | def test_that_it_has_a_version_number 7 | refute_nil ::Prompts::VERSION 8 | end 9 | 10 | def test_it_does_something_useful 11 | assert true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/prompts/pause_prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | class PausePrompt < Prompt 5 | def initialize(...) 6 | super 7 | 8 | @prompt = "Press Enter ⏎ to continue..." 9 | end 10 | 11 | def resolve_choice_from(response) 12 | true 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "prompts" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /lib/prompts/paragraph.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | class Paragraph 5 | include TextUtils 6 | 7 | LINE_PADDING = 3 8 | 9 | def initialize(text, width: 60) 10 | @text = text 11 | @width = width - (LINE_PADDING + 1) 12 | @line_padding = SPACE * LINE_PADDING 13 | end 14 | 15 | def lines 16 | wrap_text(@text, width: @width, line_prefix: @line_padding, alignment: :none) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.2.1' 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Run the default task 27 | run: bundle exec rake 28 | -------------------------------------------------------------------------------- /lib/prompts/confirm_prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | class ConfirmPrompt < Prompt 5 | def initialize(...) 6 | super 7 | 8 | @prompt = if @default == false 9 | "Choose [y/N]:" 10 | elsif @default == true 11 | "Choose [Y/n]:" 12 | else 13 | "Choose [y/n]:" 14 | end 15 | @default_boolean = @default 16 | @default = nil 17 | @instructions = "Press Enter to submit" 18 | @validations << ->(choice) { "Invalid choice." if !["y", "n", "Y", "N", ""].include?(choice) } 19 | end 20 | 21 | private 22 | 23 | def resolve_choice_from(response) 24 | case response 25 | when "y", "Y" then true 26 | when "n", "N" then false 27 | when "" then @default_boolean 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.3.1] - 2024-10-08 4 | 5 | - Keep past frames in the terminal window ([@fractaledmind](https://github.com/fractaledmind/prompts/pull/10)) 6 | - Allow users to specify a new for form fields ([@fractaledmind](https://github.com/fractaledmind/prompts/pull/11)) 7 | 8 | ## [0.3.0] - 2024-10-08 9 | 10 | - Update `fmt` gem and use new syntax ([@KonnorRogers](https://github.com/fractaledmind/prompts/pull/9)) 11 | 12 | ## [0.2.1] - 2024-08-22 13 | 14 | - Add `Form.submit` method 15 | - All passing keyword arguments to `Form` prompt methods 16 | - Return the key of the options hash when using `SelectPrompt` 17 | 18 | ## [0.2.0] - 2024-08-20 19 | 20 | - Add `TextPrompt` 21 | - Add `SelectPrompt` 22 | - Add `ConfirmPrompt` 23 | - Add `PausePrompt` 24 | - Add `Form` 25 | 26 | ## [0.1.0] - 2024-08-07 27 | 28 | - Initial release 29 | -------------------------------------------------------------------------------- /lib/prompts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "io/console" 4 | require "reline" 5 | require "rainbow" # this needs to come before require "fmt" 6 | require "fmt" 7 | 8 | require_relative "prompts/version" 9 | require_relative "prompts/prompt" 10 | require_relative "prompts/text_utils" 11 | require_relative "prompts/content" 12 | require_relative "prompts/paragraph" 13 | require_relative "prompts/box" 14 | require_relative "prompts/pause_prompt" 15 | require_relative "prompts/confirm_prompt" 16 | require_relative "prompts/text_prompt" 17 | require_relative "prompts/select_prompt" 18 | require_relative "prompts/form" 19 | 20 | module Prompts 21 | EMPTY = "" 22 | SPACE = " " 23 | MAX_WIDTH = 80 24 | OUTPUT = $stdout 25 | 26 | class Error < StandardError; end 27 | 28 | class << self 29 | def Form(&block) 30 | form = Prompts::Form.new 31 | yield(form) 32 | form.start 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Stephen Margheim 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. 22 | -------------------------------------------------------------------------------- /lib/prompts/select_prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | class SelectPrompt < Prompt 5 | def self.ask(options: nil, **kwargs) 6 | instance = new(options: options, **kwargs) 7 | yield instance if block_given? 8 | instance.ask 9 | end 10 | 11 | def initialize(options: nil, **kwargs) 12 | super(**kwargs) 13 | 14 | @options = options.is_a?(Array) ? options.to_h { |item| [item, item] } : options 15 | @default = if (index = @options.keys.index(@default)) 16 | index + 1 17 | end 18 | @instructions = "Enter the number of your choice" 19 | @hint ||= "Type your response and press Enter ⏎" 20 | @validations << ->(choice) { "Invalid choice." if !choice.to_i.between?(1, @options.size) } 21 | end 22 | 23 | # standard:disable Style/TrivialAccessors 24 | def options(options) 25 | @options = options 26 | end 27 | # standard:enable Style/TrivialAccessors 28 | 29 | def prepare_content 30 | super 31 | @options.each_with_index do |(key, value), index| 32 | @content.paragraph Fmt("%{prefix}|>faint|>bold %{option}", prefix: "#{index + 1}.", option: value) 33 | end 34 | @content 35 | end 36 | 37 | private 38 | 39 | def resolve_choice_from(response) 40 | choice = response.to_i 41 | key, _value = @options.to_a[choice - 1] 42 | key 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/prompts/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | module Prompts 4 | class Content 5 | attr_reader :slots 6 | 7 | def initialize(width: MAX_WIDTH) 8 | @slots = [] 9 | @frame_stack = [] 10 | @width = width 11 | end 12 | 13 | def paragraph(text) 14 | paragraph = Paragraph.new(text, width: @width) 15 | @slots.concat paragraph.lines 16 | self 17 | end 18 | 19 | def gap 20 | @slots << SPACE 21 | self 22 | end 23 | 24 | def box(padded: false, border_color: nil, &block) 25 | box = Box.new(width: @width, padded: padded, border_color: border_color) 26 | yield(box) 27 | @slots.concat box.lines 28 | self 29 | end 30 | 31 | def render 32 | clear_screen 33 | render_frame 34 | end 35 | 36 | def reset! 37 | @slots = @frame_stack.first.dup 38 | end 39 | 40 | def prepend(*lines) 41 | @slots.unshift(*lines) 42 | end 43 | 44 | private 45 | 46 | def clear_screen 47 | jump_cursor_to_top 48 | erase_down 49 | end 50 | 51 | def render_frame 52 | @frame_stack << @slots.dup 53 | OUTPUT.puts SPACE 54 | 55 | return if @slots.empty? 56 | 57 | OUTPUT.puts @slots.join("\n") 58 | OUTPUT.puts SPACE 59 | @slots.clear 60 | end 61 | 62 | def jump_cursor_to_top 63 | OUTPUT.print "\033[H" 64 | end 65 | 66 | def erase_down 67 | OUTPUT.print "\e[2J\e[H" 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /prompts.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/prompts/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "prompts" 7 | spec.version = Prompts::VERSION 8 | spec.authors = ["Stephen Margheim"] 9 | spec.email = ["stephen.margheim@gmail.com"] 10 | 11 | spec.summary = "Beautiful and user-friendly forms for your command-line Ruby applications." 12 | spec.description = "Inspired by the Laravel Prompts project" 13 | spec.homepage = "https://github.com/fractaledmind/prompts" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.0.0" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/fractaledmind/prompts" 19 | spec.metadata["changelog_uri"] = "https://github.com/fractaledmind/prompts/CHANGELOG.md" 20 | 21 | # Specify which files should be added to the gem when it is released. 22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 23 | gemspec = File.basename(__FILE__) 24 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 25 | ls.readlines("\x0", chomp: true).reject do |f| 26 | (f == gemspec) || 27 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 28 | end 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | spec.add_dependency "unicode-display_width" 36 | spec.add_dependency "unicode-emoji" 37 | spec.add_dependency "reline" 38 | spec.add_dependency "fmt", ">= 0.3.0" 39 | spec.add_dependency "rainbow" 40 | 41 | # For more information and examples about making a new gem, check out our 42 | # guide at: https://bundler.io/guides/creating_gem.html 43 | end 44 | -------------------------------------------------------------------------------- /lib/prompts/text_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "unicode/display_width" 4 | require "unicode/emoji" 5 | 6 | module Prompts 7 | module TextUtils 8 | ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]/ 9 | 10 | def wrap_text(text, width:, line_prefix: EMPTY, line_suffix: EMPTY, alignment: :left) 11 | words = text.scan(Regexp.union(/\S+/, ANSI_REGEX)) 12 | lines = [] 13 | line = +EMPTY 14 | line_width = 0 15 | prefix_width = Unicode::DisplayWidth.of(strip_ansi(line_prefix), 1, {}, emoji: true) 16 | suffix_width = Unicode::DisplayWidth.of(strip_ansi(line_suffix), 1, {}, emoji: true) 17 | available_width = width - prefix_width - suffix_width 18 | 19 | words.each do |word| 20 | word_width = Unicode::DisplayWidth.of(strip_ansi(word), 1, {}, emoji: true) 21 | 22 | if (line_width + word_width) > available_width 23 | lines << format_line(line.rstrip, available_width, alignment, line_prefix, line_suffix) 24 | line = +EMPTY 25 | line_width = 0 26 | end 27 | 28 | line << word + SPACE 29 | line_width += word_width + 1 30 | end 31 | 32 | lines << format_line(line.rstrip, available_width, alignment, line_prefix, line_suffix) 33 | lines 34 | end 35 | 36 | def format_line(line, available_width, alignment, prefix, suffix) 37 | line_width = Unicode::DisplayWidth.of(strip_ansi(line), 1, {}, emoji: true) 38 | padding = [available_width - line_width, 0].max 39 | 40 | case alignment 41 | when :none 42 | prefix + line + suffix 43 | when :left 44 | prefix + line + (SPACE * padding) + suffix 45 | when :right 46 | prefix + (SPACE * padding) + line + suffix 47 | when :center 48 | left_padding = padding / 2 49 | right_padding = padding - left_padding 50 | prefix + (SPACE * left_padding) + line + (SPACE * right_padding) + suffix 51 | end 52 | end 53 | 54 | def strip_ansi(text) 55 | text.gsub(ANSI_REGEX, EMPTY) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | prompts (0.3.1) 5 | fmt (>= 0.3.0) 6 | rainbow 7 | reline 8 | unicode-display_width 9 | unicode-emoji 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | ast (2.4.2) 15 | fmt (0.3.1) 16 | ast 17 | io-console (0.7.2) 18 | json (2.7.2) 19 | language_server-protocol (3.17.0.3) 20 | lint_roller (1.1.0) 21 | minitest (5.24.1) 22 | parallel (1.25.1) 23 | parser (3.3.3.0) 24 | ast (~> 2.4.1) 25 | racc 26 | racc (1.8.0) 27 | rainbow (3.1.1) 28 | rake (13.2.1) 29 | regexp_parser (2.9.2) 30 | reline (0.5.9) 31 | io-console (~> 0.5) 32 | rexml (3.3.1) 33 | strscan 34 | rubocop (1.64.1) 35 | json (~> 2.3) 36 | language_server-protocol (>= 3.17.0) 37 | parallel (~> 1.10) 38 | parser (>= 3.3.0.2) 39 | rainbow (>= 2.2.2, < 4.0) 40 | regexp_parser (>= 1.8, < 3.0) 41 | rexml (>= 3.2.5, < 4.0) 42 | rubocop-ast (>= 1.31.1, < 2.0) 43 | ruby-progressbar (~> 1.7) 44 | unicode-display_width (>= 2.4.0, < 3.0) 45 | rubocop-ast (1.31.3) 46 | parser (>= 3.3.1.0) 47 | rubocop-performance (1.21.1) 48 | rubocop (>= 1.48.1, < 2.0) 49 | rubocop-ast (>= 1.31.1, < 2.0) 50 | ruby-progressbar (1.13.0) 51 | standard (1.39.1) 52 | language_server-protocol (~> 3.17.0.2) 53 | lint_roller (~> 1.0) 54 | rubocop (~> 1.64.0) 55 | standard-custom (~> 1.0.0) 56 | standard-performance (~> 1.4) 57 | standard-custom (1.0.2) 58 | lint_roller (~> 1.0) 59 | rubocop (~> 1.50) 60 | standard-performance (1.4.0) 61 | lint_roller (~> 1.1) 62 | rubocop-performance (~> 1.21.0) 63 | strscan (3.1.0) 64 | unicode-display_width (2.5.0) 65 | unicode-emoji (3.4.0) 66 | unicode-version (~> 1.0) 67 | unicode-version (1.4.0) 68 | 69 | PLATFORMS 70 | arm64-darwin-21 71 | ruby 72 | 73 | DEPENDENCIES 74 | minitest (~> 5.16) 75 | prompts! 76 | rake (~> 13.0) 77 | standard (~> 1.3) 78 | 79 | BUNDLED WITH 80 | 2.5.17 81 | -------------------------------------------------------------------------------- /lib/prompts/form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | class Form 5 | def self.submit(&block) 6 | instance = new 7 | yield instance if block 8 | instance.submit 9 | end 10 | 11 | def initialize 12 | @content = Prompts::Content.new 13 | @index = 0 14 | @prompts = {} 15 | @results = {} 16 | end 17 | 18 | def content(&block) 19 | yield @content 20 | @content 21 | end 22 | 23 | def text(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil, name: nil, &block) 24 | prompt = TextPrompt.new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate) 25 | yield(prompt) if block 26 | prepend_form_content_to_prompt(prompt) 27 | key = name || (@index += 1) 28 | @prompts[key] = prompt 29 | end 30 | 31 | def select(label: nil, options: nil, prompt: "> ", hint: nil, default: nil, validate: nil, name: nil, &block) 32 | prompt = SelectPrompt.new(label: label, options: options, prompt: prompt, hint: hint, default: default, validate: validate) 33 | yield(prompt) if block 34 | prepend_form_content_to_prompt(prompt) 35 | key = name || (@index += 1) 36 | @prompts[key] = prompt 37 | end 38 | 39 | def pause(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil, name: nil, &block) 40 | prompt = PausePrompt.new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate) 41 | yield(prompt) if block 42 | prepend_form_content_to_prompt(prompt) 43 | key = name || (@index += 1) 44 | @prompts[key] = prompt 45 | end 46 | 47 | def confirm(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil, name: nil, &block) 48 | prompt = ConfirmPrompt.new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate) 49 | yield(prompt) if block 50 | prepend_form_content_to_prompt(prompt) 51 | key = name || (@index += 1) 52 | @prompts[key] = prompt 53 | end 54 | 55 | def submit 56 | @prompts.each do |key, prompt| 57 | @results[key] = prompt.ask 58 | end 59 | @results 60 | end 61 | 62 | private 63 | 64 | def prepend_form_content_to_prompt(prompt) 65 | prompt.prepare_content 66 | prompt.prepend_content([SPACE]) 67 | prompt.prepend_content(*@content.slots) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/prompts/box.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Prompts 4 | class Box 5 | include TextUtils 6 | 7 | SOLID_BORDER = {top_left: "┌", top_right: "┐", bottom_left: "└", bottom_right: "┘", horizontal: "─", vertical: "│"}.freeze 8 | DOUBLE_BORDER = {top_left: "╔", top_right: "╗", bottom_left: "╚", bottom_right: "╝", horizontal: "═", vertical: "║"}.freeze 9 | HEAVY_BORDER = {top_left: "┏", top_right: "┓", bottom_left: "┗", bottom_right: "┛", horizontal: "━", vertical: "┃"}.freeze 10 | ROUNDED_BORDER = {top_left: "╭", top_right: "╮", bottom_left: "╰", bottom_right: "╯", horizontal: "─", vertical: "│"}.freeze 11 | 12 | def initialize(width: MAX_WIDTH, padded: false, border_color: nil, border_style: :rounded) 13 | @width = width 14 | @padded = padded 15 | @border_color = border_color 16 | @line_padding = SPACE * 1 17 | @border_parts = case border_style 18 | when :solid then SOLID_BORDER 19 | when :double then DOUBLE_BORDER 20 | when :heavy then HEAVY_BORDER 21 | else ROUNDED_BORDER 22 | end 23 | @content = [] 24 | end 25 | 26 | def centered(text) 27 | @content.concat align(text, :center) 28 | end 29 | 30 | def left(text) 31 | @content.concat align(text, :left) 32 | end 33 | 34 | def right(text) 35 | @content.concat align(text, :right) 36 | end 37 | 38 | def gap 39 | @content.concat align(EMPTY, :center) 40 | end 41 | 42 | def lines 43 | [].tap do |output| 44 | output << top_border 45 | align(EMPTY, :center).each { |line| output << @line_padding + line } if @padded 46 | @content.each do |line| 47 | output << @line_padding + line 48 | end 49 | align(EMPTY, :center).each { |line| output << @line_padding + line } if @padded 50 | output << bottom_border 51 | end 52 | end 53 | 54 | private 55 | 56 | def top_border 57 | border = @border_parts[:top_left] + @border_parts[:horizontal] * (@width - 2) + @border_parts[:top_right] 58 | Fmt("#{@line_padding}%{border}|>#{@border_color}", border: border) 59 | end 60 | 61 | def bottom_border 62 | border = @border_parts[:bottom_left] + @border_parts[:horizontal] * (@width - 2) + @border_parts[:bottom_right] 63 | Fmt("#{@line_padding}%{border}|>#{@border_color}", border: border) 64 | end 65 | 66 | def align(text, alignment, between: @border_parts[:vertical]) 67 | formatted_boundary = Fmt("%{boundary}|>#{@border_color}", boundary: between) 68 | wrap_text(text, width: @width, line_prefix: formatted_boundary + SPACE, line_suffix: SPACE + formatted_boundary, alignment: alignment) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/prompts/prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "reline" 4 | 5 | module Prompts 6 | class Prompt 7 | def self.ask(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil) 8 | instance = new(label: label, prompt: prompt, hint: hint, default: default, required: required, validate: validate) 9 | yield instance if block_given? 10 | instance.ask 11 | end 12 | 13 | def initialize(label: nil, prompt: "> ", hint: nil, default: nil, required: false, validate: nil) 14 | @label = label 15 | @prompt = prompt 16 | @hint = hint 17 | @default = default 18 | @required = required 19 | @validate = validate 20 | 21 | @content = nil 22 | @error = nil 23 | @attempts = 0 24 | @instructions = nil 25 | @validations = [] 26 | @choice = nil 27 | @content_prepared = false 28 | end 29 | 30 | def content(&block) 31 | @content ||= Prompts::Content.new 32 | yield @content 33 | @content 34 | end 35 | 36 | # standard:disable Style/TrivialAccessors 37 | def label(label) 38 | @label = label 39 | end 40 | 41 | def hint(hint) 42 | @hint = hint 43 | end 44 | 45 | def default(default) 46 | @default = default 47 | end 48 | # standard:enable Style/TrivialAccessors 49 | 50 | def ask 51 | prepare_content if !@content_prepared 52 | prepare_default if @default 53 | prepare_validations 54 | 55 | loop do 56 | @content.render 57 | *initial_prompt_lines, last_prompt_line = formatted_prompt 58 | puts initial_prompt_lines.join("\n") if initial_prompt_lines.any? 59 | response = Reline.readline(last_prompt_line, _history = false).chomp 60 | @choice = resolve_choice_from(response) 61 | 62 | if (@error = ensure_validity(response)) 63 | @content.reset! 64 | @content.paragraph Fmt("%{error}|>red|>bold", error: @error + " Try again (×#{@attempts})...") 65 | @attempts += 1 66 | next 67 | else 68 | break @choice 69 | end 70 | end 71 | 72 | @choice 73 | rescue Interrupt 74 | exit 0 75 | end 76 | 77 | def prepend_content(*lines) 78 | @content.prepend(*lines) 79 | end 80 | 81 | def prepare_content 82 | @content ||= Prompts::Content.new 83 | @content.paragraph formatted_label if @label 84 | @content.paragraph formatted_hint if @hint 85 | @content.paragraph formatted_error if @error 86 | @content_prepared = true 87 | @content 88 | end 89 | 90 | private 91 | 92 | def prepare_default 93 | Reline.pre_input_hook = -> do 94 | Reline.insert_text @default.to_s 95 | # Remove the hook right away. 96 | Reline.pre_input_hook = nil 97 | end 98 | end 99 | 100 | def prepare_validations 101 | if @required 102 | error_message = @required.is_a?(String) ? @required : "Value cannot be empty." 103 | @validations << ->(input) { error_message if input.empty? } 104 | end 105 | 106 | if @validate 107 | @validations << @validate 108 | end 109 | end 110 | 111 | def resolve_choice_from(response) 112 | response 113 | end 114 | 115 | def formatted_prompt 116 | prompt_with_space = @prompt.end_with?(SPACE) ? @prompt : @prompt + SPACE 117 | ansi_prompt = Fmt("%{prompt}|>faint|>bold", prompt: prompt_with_space) 118 | @formatted_prompt ||= Paragraph.new(ansi_prompt, width: MAX_WIDTH).lines 119 | end 120 | 121 | def formatted_label 122 | Fmt("%{label}|>cyan|>bold %{instructions}|>faint|>italic", label: @label, instructions: @instructions ? "(#{@instructions})" : "") 123 | end 124 | 125 | def formatted_hint 126 | Fmt("%{hint}|>faint|>bold", hint: @hint) 127 | end 128 | 129 | def formatted_error 130 | Fmt("%{error}|>red|>bold", error: @error + " Try again (×#{@attempts})...") 131 | end 132 | 133 | def ensure_validity(response) 134 | @validations.each do |validation| 135 | result = validation.call(response) 136 | return result if result 137 | end 138 | nil 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prompts 2 | 3 | Prompts helps you to add beautiful and user-friendly forms to your command-line applications, with browser-like features including label text, help text, validation, and inline errors. 4 | 5 | It was originally inspired by the [Laravel Prompts](https://laravel.com/docs/11.x/prompts) package. 6 | 7 | ## Installation 8 | 9 | Install the gem and add to the application's Gemfile by executing: 10 | 11 | ```bash 12 | bundle add prompts 13 | ``` 14 | 15 | If bundler is not being used to manage dependencies, install the gem by executing: 16 | 17 | ```bash 18 | gem install prompts 19 | ``` 20 | 21 | ## Philosophy 22 | 23 | Prompts aims to provide beautiful and user-friendly forms for your command-line applications while keeping both the API and the implementation simple. This means Prompts is built with constraints in mind. 24 | 25 | In order to minimize complexity, we build on top of the excellent [`reline`](https://github.com/ruby/reline) gem to provide a full-featured text input. Similarly, the text input is **always** rendered at the bottom of the screen. And inputing text is the **only** way to interact with the form. 26 | 27 | In this way, this new library is similar to [Charm's Huh library](https://github.com/charmbracelet/huh) when used in "accessible" mode. But, with some UX improvements added to this interaction. Instead of simply appending new fields to the screen, Prompts' forms act like wizards 🧙♂️. Each field gets its own screen, and on each render loop, the screen is reset and repainted. 28 | 29 | Finally, to keep internals simple, Prompts expects users to build and provide their own ANSI-formatted strings. However, we do make available the [`fmt`](https://github.com/hopsoft/fmt) gem as the recommended way to generate well formatted ANSI strings. 30 | 31 | ## Available Prompts 32 | 33 | ### Text 34 | 35 | A `Text` prompt will prompt the user with the given question, accept their input, and then return it: 36 | 37 | ```ruby 38 | name = Prompts::TextPrompt.ask(label: "What is your name?") 39 | ``` 40 | 41 | which generates a terminal screen like this (this representation doesn't show color): 42 |
43 | 44 | What is your name? (Press Enter to submit) 45 | Type your response and press Enter ⏎ 46 | 47 | > | 48 |49 | 50 | You may also include a default value and an informational hint: 51 | 52 | ```ruby 53 | name = Prompts::TextPrompt.ask( 54 | label: "What is your name?", 55 | default: "John Doe", 56 | hint: "This will be displayed on your profile." 57 | ) 58 | ``` 59 | 60 | which generates a terminal screen like this (this representation doesn't show color): 61 |
62 | 63 | What is your name? (Press Enter to submit) 64 | This will be displayed on your profile. 65 | 66 | > John Doe| 67 |68 | 69 | #### Required values 70 | 71 | If you require a value to be entered, you may pass the `required` argument: 72 | 73 | ```ruby 74 | name = Prompts::TextPrompt.ask( 75 | label: "What is your name?", 76 | required: true 77 | ) 78 | ``` 79 | 80 | If you would like to customize the validation message, you may also pass a string: 81 | 82 | ```ruby 83 | name = Prompts::TextPrompt.ask( 84 | label: "What is your name?", 85 | required: "Your name is required." 86 | ) 87 | ``` 88 | 89 | #### Additional Validation 90 | 91 | Finally, if you would like to perform additional validation logic, you may pass a block/proc to the validate argument: 92 | 93 | ```ruby 94 | name = Prompts::TextPrompt.ask( 95 | label: "What is your name?", 96 | validate: ->(value) do 97 | if value.length < 3 98 | "The name must be at least 3 characters." 99 | elsif value.length > 255 100 | "The name must not exceed 255 characters." 101 | end 102 | end 103 | ) 104 | ``` 105 | 106 | The block will receive the value that has been entered and may return an error message, or `nil` if the validation passes. 107 | 108 | ### Select 109 | 110 | If you need the user to select from a predefined set of choices, you may use the `Select` prompt: 111 | 112 | ```ruby 113 | role = Prompts::SelectPrompt.ask( 114 | label: "What role should the user have?", 115 | options: ["Member", "Contributor", "Owner"] 116 | ) 117 | ``` 118 | 119 | which generates a terminal screen like this (this representation doesn't show color): 120 |
121 | 122 | What role should the user have? (Enter the number of your choice) 123 | Type your response and press Enter ⏎ 124 | 1. Member 125 | 2. Contributor 126 | 3. Owner 127 | 128 | > | 129 |130 | 131 | You may also include a default value and an informational hint: 132 | 133 | ```ruby 134 | role = Prompts::SelectPrompt.ask( 135 | label: "What role should the user have?", 136 | options: ["Member", "Contributor", "Owner"], 137 | default: "Owner", 138 | hint: "The role may be changed at any time." 139 | ) 140 | ``` 141 | 142 | which generates a terminal screen like this (this representation doesn't show color): 143 |
144 | 145 | What role should the user have? (Enter the number of your choice) 146 | The role may be changed at any time. 147 | 1. Member 148 | 2. Contributor 149 | 3. Owner 150 | 151 | > 3| 152 |153 | 154 | You may also pass a hash to the `options` argument to have the selected key returned instead of its value: 155 | 156 | ```ruby 157 | role = Prompts::SelectPrompt.ask( 158 | label: "What role should the user have?", 159 | options: { 160 | member: "Member", 161 | contributor: "Contributor", 162 | owner: "Owner", 163 | }, 164 | default: "owner" 165 | ) 166 | ``` 167 | 168 | #### Additional Validation 169 | 170 | Unlike other prompt classes, the `SelectPrompt` doesn't accept the `required` argument because it is not possible to select nothing. However, you may pass a block/proc to the `validate` argument if you need to present an option but prevent it from being selected: 171 | 172 | ```ruby 173 | role = Prompts::SelectPrompt.ask( 174 | label: "What role should the user have?", 175 | options: { 176 | member: "Member", 177 | contributor: "Contributor", 178 | owner: "Owner", 179 | }, 180 | validate: ->(value) do 181 | if value == "owner" && User.where(role: "owner").exists? 182 | "An owner already exists." 183 | end 184 | end 185 | ) 186 | ``` 187 | 188 | If the `options` argument is a hash, then the block will receive the selected key, otherwise it will receive the selected value. The block may return an error message, or `nil` if the validation passes. 189 | 190 | ### Confirm 191 | 192 | If you need to ask the user for a "yes or no" confirmation, you may use the `ConfirmPrompt`. Users may press `y` or `n` (or `Y` or `N`) to select their response. This function will return either `true` or `false`. 193 | 194 | ```ruby 195 | confirmed = Prompts::ConfirmPrompt.ask(label: "Do you accept the terms?") 196 | ``` 197 | 198 | which generates a terminal screen like this (this representation doesn't show color): 199 |
200 | 201 | Do you accept the terms? (Press Enter to submit) 202 | 203 | Choose [y/n]: | 204 |205 | 206 | You may also include a default value and an informational hint: 207 | 208 | ```ruby 209 | confirmed = Prompts::ConfirmPrompt.ask( 210 | label: "Do you accept the terms?", 211 | default: false, 212 | hint: "The terms must be accepted to continue.", 213 | ) 214 | ``` 215 | 216 | which generates a terminal screen like this (this representation doesn't show color): 217 |
218 | 219 | Do you accept the terms? (Press Enter to submit) 220 | The terms must be accepted to continue. 221 | 222 | Choose [y/N]: | 223 |224 | 225 | #### Requiring "Yes" 226 | 227 | If necessary, you may require your users to select "Yes" by passing the `required` argument: 228 | 229 | ```ruby 230 | confirmed = Prompts::ConfirmPrompt.ask( 231 | label: "Do you accept the terms?", 232 | required: true 233 | ) 234 | ``` 235 | 236 | If you would like to customize the validation message, you may also pass a string: 237 | 238 | ```ruby 239 | confirmed = Prompts::ConfirmPrompt.ask( 240 | label: "Do you accept the terms?", 241 | required: "You must accept the terms to continue." 242 | ) 243 | ``` 244 | 245 | ### Pause 246 | 247 | The `PausePrompt` may be used to display informational text to the user and wait for them to confirm their desire to proceed by pressing the Enter / Return key: 248 | 249 | ```ruby 250 | Prompts::PausePrompt.ask 251 | ``` 252 | 253 | which generates a terminal screen like this (this representation doesn't show color): 254 |
255 | 256 | Press Enter ⏎ to continue... | 257 |258 | 259 | ## Forms 260 | 261 | Often, you will have multiple prompts that will be displayed in sequence to collect information before performing additional actions. You may use the `Prompts::Form` class to create a grouped set of prompts for the user to complete: 262 | 263 | ```ruby 264 | responses = Prompts::Form.submit do |form| 265 | form.text( 266 | label: "What is your name?", 267 | required: true 268 | ) 269 | form.select( 270 | label: "What role should the user have?", 271 | options: { 272 | member: "Member", 273 | contributor: "Contributor", 274 | owner: "Owner", 275 | } 276 | ) 277 | form.confirm( 278 | label: 'Do you accept the terms?' 279 | ) 280 | end 281 | ``` 282 | 283 | The `submit` method will return a numerically indexed hash containing all of the responses from the form's prompts. However, you may provide a name for each prompt via the `name` argument. When a name is provided, the named prompt's response may be accessed via that name: 284 | 285 | ```ruby 286 | responses = Prompts::Form.submit do |form| 287 | form.text( 288 | label: "What is your name?", 289 | required: true, 290 | name: :name 291 | ) 292 | form.select( 293 | label: "What role should the user have?", 294 | options: { 295 | member: "Member", 296 | contributor: "Contributor", 297 | owner: "Owner", 298 | }, 299 | name: :role 300 | ) 301 | form.confirm( 302 | label: 'Do you accept the terms?', 303 | name: :terms 304 | ) 305 | end 306 | 307 | User.create( 308 | name: responses[:name], 309 | role: responses[:role], 310 | terms: responses[:terms] 311 | ) 312 | ``` 313 | 314 | ## Development 315 | 316 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 317 | 318 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 319 | 320 | ## Contributing 321 | 322 | Bug reports and pull requests are welcome on GitHub at https://github.com/fractaledmind/prompts. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/fractaledmind/prompts/blob/main/CODE_OF_CONDUCT.md). 323 | 324 | ## License 325 | 326 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 327 | 328 | ## Code of Conduct 329 | 330 | Everyone interacting in the Prompts project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/fractaledmind/prompts/blob/main/CODE_OF_CONDUCT.md). 331 | --------------------------------------------------------------------------------