soapstone 0.1.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.standard.yml +3 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +43 -0
  8. data/Rakefile +20 -0
  9. data/bin/sgn +6 -0
  10. data/lib/soapstone/.DS_Store +0 -0
  11. data/lib/soapstone/config/configuration.rb +31 -0
  12. data/lib/soapstone/config/load.rb +40 -0
  13. data/lib/soapstone/config/wizard.rb +40 -0
  14. data/lib/soapstone/core/ai/anthropic_provider.rb +33 -0
  15. data/lib/soapstone/core/ai/client.rb +36 -0
  16. data/lib/soapstone/core/ai/open_ai_provider.rb +39 -0
  17. data/lib/soapstone/core/ai/prompt_builder.rb +91 -0
  18. data/lib/soapstone/core/commit_message_generator.rb +20 -0
  19. data/lib/soapstone/core/context/git_branch.rb +42 -0
  20. data/lib/soapstone/core/context/linear.rb +57 -0
  21. data/lib/soapstone/core/fetch_linear_issue.rb +72 -0
  22. data/lib/soapstone/core/git_command.rb +19 -0
  23. data/lib/soapstone/core/message_presenter.rb +91 -0
  24. data/lib/soapstone/operations/commit.rb +85 -0
  25. data/lib/soapstone/ui/components/box.rb +29 -0
  26. data/lib/soapstone/ui/components/prompt.rb +17 -0
  27. data/lib/soapstone/ui/menus/ai_settings.rb +131 -0
  28. data/lib/soapstone/ui/menus/git_settings.rb +55 -0
  29. data/lib/soapstone/ui/menus/linear_settings.rb +59 -0
  30. data/lib/soapstone/ui/prompts/ask.rb +24 -0
  31. data/lib/soapstone/ui/prompts/select.rb +50 -0
  32. data/lib/soapstone/ui/prompts/select_answer.rb +10 -0
  33. data/lib/soapstone/ui/prompts/yes_no.rb +25 -0
  34. data/lib/soapstone/ui/screens/utils.rb +5 -0
  35. data/lib/soapstone/ui/screens/welcome.rb +103 -0
  36. data/lib/soapstone/ui/screens/wizard.rb +40 -0
  37. data/lib/soapstone/version.rb +5 -0
  38. data/lib/soapstone.rb +49 -0
  39. data/sig/soapstone.rbs +4 -0
  40. data/soapstone-0.1.0.gem +0 -0
  41. metadata +269 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Soapstone::FetchLinearIssue
4
+ LINEAR_URL = "https://api.linear.app/graphql"
5
+
6
+ def self.call
7
+ new.call
8
+ end
9
+
10
+ def initialize
11
+ @config = Soapstone::Config::Load.call
12
+ @git_context = Soapstone::Context::GitBranch.call
13
+ @linear_context = Soapstone::Context::Linear.call(@git_context.current_branch)
14
+ end
15
+
16
+ def call
17
+ return unless linear_context.enabled?
18
+ return unless linear_context.pattern && !linear_context.pattern.empty?
19
+ return {} if git_context.default_branch?
20
+ return unless linear_context.matches_pattern?
21
+
22
+ response = Faraday.post(LINEAR_URL, payload, headers)
23
+ body = JSON.parse(response.body)
24
+ body.dig("data", "issue") || {}
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :config, :git_context, :linear_context
30
+
31
+ def headers
32
+ api_key = config.get("linear", "api_key")
33
+
34
+ {
35
+ "Content-Type" => "application/json",
36
+ "Authorization" => api_key.to_s.strip
37
+ }
38
+ end
39
+
40
+ def issue_key
41
+ match = git_context.current_branch.match(compiled_regex)
42
+
43
+ if match
44
+ match[1] || match[0] # Use capture group if available, otherwise full match
45
+ else
46
+ raise "No Linear ticket found in branch name '#{git_context.current_branch}' using pattern '#{linear_context.pattern}'"
47
+ end
48
+ rescue RegexpError => e
49
+ raise "Invalid regex pattern '#{linear_context.pattern}': #{e.message}"
50
+ end
51
+
52
+ def compiled_regex
53
+ @compiled_regex ||= Regexp.new(linear_context.pattern, Regexp::IGNORECASE)
54
+ end
55
+
56
+ def payload
57
+ {
58
+ query: <<~GRAPHQL,
59
+ query Issue($id: String!) {
60
+ issue(id: $id) {
61
+ identifier
62
+ title
63
+ description
64
+ priority
65
+ state { name }
66
+ }
67
+ }
68
+ GRAPHQL
69
+ variables: {id: issue_key}
70
+ }.to_json
71
+ end
72
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Soapstone::GitCommand
4
+ def self.in_git_repository?
5
+ system("git rev-parse --git-dir > /dev/null 2>&1")
6
+ end
7
+
8
+ def self.staged_files
9
+ `git diff --cached --name-status`.strip
10
+ end
11
+
12
+ def self.staged_diff
13
+ `git diff --cached`.strip
14
+ end
15
+
16
+ def self.current_branch
17
+ `git rev-parse --abbrev-ref HEAD`.strip
18
+ end
19
+ end
@@ -0,0 +1,91 @@
1
+ class Soapstone::MessagePresenter
2
+ def self.call(message)
3
+ new(message).call
4
+ end
5
+
6
+ def initialize(message)
7
+ @message = message
8
+ end
9
+
10
+ def call
11
+ message
12
+ .then(&method(:wrap_message))
13
+ .then(&method(:format_lists))
14
+ .then(&method(:enhance_typography))
15
+ .then(&method(:format_subject_line))
16
+ end
17
+
18
+ def wrap_message(text)
19
+ subject, body = split_subject_and_body(text)
20
+
21
+ if subject.nil? # single line message or no clear body structure
22
+ Strings.wrap(body, 72)
23
+ elsif body.strip.empty?
24
+ subject
25
+ else
26
+ "#{subject}\n\n#{wrap_body(body)}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :message
33
+
34
+ def split_subject_and_body(text)
35
+ lines = text.split("\n", -1) # -1 to keep trailing empty strings
36
+ blank_index = lines.find_index(&:empty?)
37
+
38
+ return [nil, text] unless blank_index
39
+
40
+ subject = lines[0...blank_index].join("\n")
41
+ body = lines[(blank_index + 1)..]&.join("\n") || ""
42
+
43
+ [subject, body]
44
+ end
45
+
46
+ def wrap_body(body_text)
47
+ body_text.split("\n").map do |paragraph|
48
+ if paragraph.match?(/^\s*(\d+\.\s|[-*]\s)/)
49
+ wrap_list_item(paragraph, 72)
50
+ else
51
+ cleaned = paragraph.gsub(/\s+/, " ").strip
52
+ Strings.wrap(cleaned, 72)
53
+ end
54
+ end.join("\n")
55
+ end
56
+
57
+ def wrap_list_item(text, width)
58
+ marker_match = text.match(/^(\s*)([-*]\s|\d+\.\s)(.*)/) # Match both bullet and numbered list items, even if indented
59
+ return Strings.wrap(text, width) unless marker_match
60
+
61
+ leading_spaces = marker_match[1]
62
+ marker = marker_match[2]
63
+ content = marker_match[3]
64
+
65
+ total_indent = leading_spaces.length + marker.length
66
+ indent = " " * total_indent
67
+
68
+ wrapped_content = Strings.wrap(content, width - total_indent)
69
+ lines = wrapped_content.split("\n")
70
+
71
+ ["#{leading_spaces}#{marker}#{lines.first}", *lines[1..].map { |line| "#{indent}#{line}" }].join("\n")
72
+ end
73
+
74
+ def format_lists(text)
75
+ text.gsub(/^\* (.+)$/) { "- #{$1}" }
76
+ end
77
+
78
+ def enhance_typography(text)
79
+ text.gsub("...", "…") # Smart ellipses
80
+ .gsub("--", "—") # Em dashes
81
+ .gsub(/"([^"]+)"/, '"\1"') # Smart quotes
82
+ end
83
+
84
+ def format_subject_line(text)
85
+ lines = text.split("\n")
86
+ return text if lines.first.nil? || lines.first.empty?
87
+
88
+ subject = Strings.truncate(lines.first, 50, trailing: "…").sub(/\.$/, "")
89
+ [subject, *lines[1..]].join("\n")
90
+ end
91
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ class Soapstone::Operations::Commit
6
+ def self.start
7
+ new.start
8
+ end
9
+
10
+ def initialize(output: $stdout)
11
+ @output = output
12
+ @message_generator = Soapstone::CommitMessageGenerator.new
13
+ end
14
+
15
+ def start
16
+ display_header
17
+
18
+ ensure_in_git_repository
19
+ ensure_staged_files
20
+ check_branch_and_warn
21
+
22
+ generate_commit_message
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :message_generator, :output
28
+
29
+ def display_header
30
+ output.puts "🎨 Soapstone - Beautiful Commit Messages"
31
+ end
32
+
33
+ def ensure_in_git_repository
34
+ return if Soapstone::GitCommand.in_git_repository?
35
+
36
+ output.puts "❌ Error: Not in a git repository"
37
+ exit_with_code(1)
38
+ end
39
+
40
+ def ensure_staged_files
41
+ return if Soapstone::GitCommand.staged_files.present?
42
+
43
+ output.puts "⚠️ No staged changes found."
44
+ output.puts "Run 'git add <files>' to stage changes before creating a commit message."
45
+ exit_with_code(1)
46
+ end
47
+
48
+ def check_branch_and_warn
49
+ git_context = Soapstone::Context::GitBranch.call
50
+ linear_context = Soapstone::Context::Linear.call(git_context.current_branch)
51
+
52
+ return unless linear_context.enabled?
53
+
54
+ if git_context.default_branch?
55
+ warn_default_branch(git_context.default_branch)
56
+ elsif !linear_context.matches_pattern?
57
+ warn_non_matching_branch(git_context.current_branch, linear_context.pattern)
58
+ end
59
+ end
60
+
61
+ def generate_commit_message
62
+ prompt = Soapstone::AI::PromptBuilder.build_prompt
63
+ message = message_generator.call(prompt)
64
+ formatted_message = Soapstone::MessagePresenter.call(message)
65
+ output.puts "💡 Suggested commit message: \n\n#{formatted_message}"
66
+ end
67
+
68
+ def exit_with_code(code)
69
+ raise SystemExit, code
70
+ end
71
+
72
+ def warn_default_branch(default_branch)
73
+ output.puts "⚠️ Warning: You are committing directly to your default branch (#{default_branch})."
74
+ output.puts " Linear integration will be skipped for this commit."
75
+ output.puts
76
+ end
77
+
78
+ def warn_non_matching_branch(current_branch, linear_pattern)
79
+ output.puts "⚠️ Warning: This branch name doesn't match your Linear ticket pattern."
80
+ output.puts " Branch: #{current_branch}"
81
+ output.puts " Pattern: #{linear_pattern}"
82
+ output.puts " Linear integration will be skipped for this commit."
83
+ output.puts
84
+ end
85
+ end
@@ -0,0 +1,29 @@
1
+ class Soapstone::UI::Components::Box
2
+ BLANK_LINE = ""
3
+ LINE_BREAK = "\n"
4
+
5
+ def self.call(title, &block)
6
+ new(title).call(&block)
7
+ end
8
+
9
+ def initialize(title)
10
+ @title = title
11
+ end
12
+
13
+ def call(&block)
14
+ TTY::Box.frame(
15
+ top: 2,
16
+ left: 2,
17
+ width: 80,
18
+ padding: 2,
19
+ border: {type: :thick, bottom: true},
20
+ title: {top_left: " #{title} "}
21
+ ) do
22
+ yield if block_given?
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :title
29
+ end
@@ -0,0 +1,17 @@
1
+ class Soapstone::UI::Components::Prompt
2
+ def self.call(options: {})
3
+ new(options: options).call
4
+ end
5
+
6
+ def initialize(options: {})
7
+ @options = options
8
+ end
9
+
10
+ def call
11
+ TTY::Prompt.new(help_color: :dim, enable_color: true, interrupt: :exit, **options)
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :options
17
+ end
@@ -0,0 +1,131 @@
1
+ class Soapstone::UI::Menus::AISettings
2
+ PROVIDER_OPTIONS = {
3
+ "open_ai" => "OpenAI",
4
+ "anthropic" => "Anthropic"
5
+ }.freeze
6
+
7
+ def self.call(config:)
8
+ new(config: config).call
9
+ end
10
+
11
+ def initialize(config:)
12
+ @config = config
13
+ @pastel = Pastel.new
14
+ end
15
+
16
+ def call
17
+ Soapstone::UI::Screens::Utils.clear
18
+ enabled = ask_for_ai_enabled
19
+
20
+ return unless enabled
21
+
22
+ setup_ai_providers
23
+ set_default_ai_provider
24
+ show_success_message
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :pastel, :config
30
+
31
+ def ask_for_ai_enabled
32
+ enabled = Soapstone::UI::Prompts::YesNo.call(
33
+ question: "Do you want to enable AI?",
34
+ help: "This will enable AI for the project.",
35
+ default: config.get(:ai, :enabled) || false
36
+ )
37
+ config.set(value: enabled, path: %w[ai enabled])
38
+ enabled
39
+ end
40
+
41
+ def setup_ai_providers
42
+ loop do
43
+ provider = ask_which_ai_provider
44
+ setup_ai_provider(provider)
45
+ break unless ask_if_other_ai_providers_enabled
46
+ end
47
+ end
48
+
49
+ def set_default_ai_provider
50
+ return unless config.get(:ai, :enabled)
51
+
52
+ if config.get(:ai, :api_keys).one?
53
+ config.set(value: config.get(:ai, :api_keys).keys.first, path: %w[ai default_provider])
54
+ else
55
+ ask_for_default_ai_provider
56
+ end
57
+ end
58
+
59
+ def ask_for_default_ai_provider
60
+ default_provider = Soapstone::UI::Prompts::Select.call(
61
+ question: "Which AI provider do you want to use by default?",
62
+ help: "I recommend using Anthropic. You can change this later.",
63
+ answers: default_provider_options,
64
+ default: config.get(:ai, :default_provider) || :anthropic
65
+ )
66
+ config.set(value: default_provider, path: %w[ai default_provider])
67
+ end
68
+
69
+ def ask_which_ai_provider
70
+ Soapstone::UI::Prompts::Select.call(
71
+ question: "Which AI provider do you want to use?",
72
+ help: "This is the AI provider you want to use.",
73
+ answers: [
74
+ Soapstone::UI::Prompts::SelectAnswer.new(name: "OpenAI", value: :open_ai),
75
+ Soapstone::UI::Prompts::SelectAnswer.new(name: "Anthropic", value: :anthropic)
76
+ ],
77
+ default: config.get(:ai, :provider) || :anthropic
78
+ )
79
+ end
80
+
81
+ def setup_ai_provider(provider)
82
+ case provider
83
+ when :open_ai
84
+ ask_for_openai_api_key
85
+ when :anthropic
86
+ ask_for_anthropic_api_key
87
+ end
88
+ end
89
+
90
+ def ask_for_openai_api_key
91
+ api_key = Soapstone::UI::Prompts::Ask.call(
92
+ question: "What is your OpenAI API key?",
93
+ help: "This is the API key you need to use to authenticate with OpenAI.",
94
+ default: config.get(:ai, :api_keys, :open_ai) || ""
95
+ )
96
+ config.set(value: api_key, path: %w[ai api_keys open_ai])
97
+ end
98
+
99
+ def ask_for_anthropic_api_key
100
+ api_key = Soapstone::UI::Prompts::Ask.call(
101
+ question: "What is your Anthropic API key?",
102
+ help: "This is the API key you need to use to authenticate with Anthropic.",
103
+ default: config.get(:ai, :api_keys, :anthropic) || ""
104
+ )
105
+ config.set(value: api_key, path: %w[ai api_keys anthropic])
106
+ end
107
+
108
+ def ask_if_other_ai_providers_enabled
109
+ Soapstone::UI::Prompts::YesNo.call(
110
+ question: "Do you want to add another AI provider?",
111
+ help: "This will allow you to configure additional AI providers.",
112
+ default: false
113
+ )
114
+ end
115
+
116
+ def show_success_message
117
+ puts
118
+ puts pastel.green("✅ Configuration updated!")
119
+ prompt.keypress("Press any key to continue...")
120
+ end
121
+
122
+ def default_provider_options
123
+ config.get(:ai, :api_keys).map do |provider_name, _|
124
+ Soapstone::UI::Prompts::SelectAnswer.new(name: PROVIDER_OPTIONS[provider_name], value: provider_name.to_sym)
125
+ end
126
+ end
127
+
128
+ def prompt
129
+ @prompt ||= Soapstone::UI::Components::Prompt.call
130
+ end
131
+ end
@@ -0,0 +1,55 @@
1
+ class Soapstone::UI::Menus::GitSettings
2
+ def self.call(config:)
3
+ new(config: config).call
4
+ end
5
+
6
+ def initialize(config:)
7
+ @config = config
8
+ @pastel = Pastel.new
9
+ end
10
+
11
+ def call
12
+ Soapstone::UI::Screens::Utils.clear
13
+ ask_for_default_branch
14
+ ask_for_type_style
15
+ show_success_message
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :pastel, :config
21
+
22
+ def ask_for_default_branch
23
+ default_branch = Soapstone::UI::Prompts::Ask.call(
24
+ question: "What is the name of your default base branch?",
25
+ help: "This is the branch that will be used as the base for new branches, it can be changed later or overwritten with --base / -b.",
26
+ default: config.get("git_settings", "default_branch") || "main"
27
+ )
28
+ config.set(value: default_branch, path: %w[git_settings default_branch])
29
+ end
30
+
31
+ def ask_for_type_style
32
+ type_style = Soapstone::UI::Prompts::Select.call(
33
+ question: "What is the type of commit style you prefer?",
34
+ answers: [
35
+ Soapstone::UI::Prompts::SelectAnswer.new(name: "Conventional", emoji: "\u{2699}\uFE0F ", value: :conventional, help: "ex. feat(scope): message"),
36
+ Soapstone::UI::Prompts::SelectAnswer.new(name: "Issue-Tracker Driven", emoji: "\u{1F4BE}", value: :issue_tracker_driven, help: "ex. Jira-123: message"),
37
+ Soapstone::UI::Prompts::SelectAnswer.new(name: "Gitmoji", emoji: "\u{1F41E}", value: :gitmoji, help: "ex. feat: message"),
38
+ Soapstone::UI::Prompts::SelectAnswer.new(name: "Hybrid", emoji: "\u{1F4A1}", value: :hybrid, help: "ex. feat(Jira-123): message"),
39
+ Soapstone::UI::Prompts::SelectAnswer.new(name: "None", emoji: "\u{1F6AA}", value: :none, help: "ex. message")
40
+ ],
41
+ default: config.get("git_settings", "type_style") || "Conventional"
42
+ )
43
+ config.set(value: type_style, path: %w[git_settings type_style])
44
+ end
45
+
46
+ def show_success_message
47
+ puts
48
+ puts pastel.green("✅ Configuration updated!")
49
+ prompt.keypress("Press any key to continue...")
50
+ end
51
+
52
+ def prompt
53
+ @prompt ||= Soapstone::UI::Components::Prompt.call
54
+ end
55
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Soapstone::UI::Menus::LinearSettings
4
+ def self.call(config:)
5
+ new(config: config).call
6
+ end
7
+
8
+ def initialize(config:)
9
+ @config = config
10
+ @pastel = Pastel.new
11
+ end
12
+
13
+ def call
14
+ Soapstone::UI::Screens::Utils.clear
15
+ ask_for_enabled
16
+ return unless config.get("linear", "enabled")
17
+
18
+ ask_for_ticket_regex
19
+ ask_for_api_key
20
+ show_success_message
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :config, :pastel
26
+
27
+ def ask_for_enabled
28
+ enabled = Soapstone::UI::Prompts::YesNo.call(
29
+ question: "Enable Linear integration?",
30
+ default: config.get("linear", "enabled") || false
31
+ )
32
+ config.set(value: enabled, path: %w[linear enabled])
33
+ end
34
+
35
+ def ask_for_ticket_regex
36
+ default_regex = config.get("linear", "ticket_regex") || "(my-branch-\\d+)"
37
+ regex = Soapstone::UI::Prompts::Ask.call(
38
+ question: "Regex to capture Linear issue key from a branch name:",
39
+ help: "Example: (my-branch-\\d+) captures `my-branch-123` from `my-branch-123-my-feature`.",
40
+ default: default_regex
41
+ )
42
+ config.set(value: regex, path: %w[linear ticket_regex])
43
+ end
44
+
45
+ def ask_for_api_key
46
+ api_key = Soapstone::UI::Prompts::Ask.call(
47
+ question: "Your Linear API key:",
48
+ help: "Generate one from Linear → Settings → Security → API Keys",
49
+ default: config.get("linear", "api_key") || ""
50
+ )
51
+ config.set(value: api_key, path: %w[linear api_key])
52
+ end
53
+
54
+ def show_success_message
55
+ puts
56
+ puts pastel.green("✅ Linear settings updated!")
57
+ Soapstone::UI::Components::Prompt.call.keypress("Press any key to continue...")
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ class Soapstone::UI::Prompts::Ask
2
+ def self.call(question:, default: nil, required: false, help: nil)
3
+ new(question: question, default: default, required: required, help: help).call
4
+ end
5
+
6
+ def initialize(question:, default: nil, required: false, help: nil)
7
+ @question = question
8
+ @default = default
9
+ @required = required
10
+ @help = help
11
+ end
12
+
13
+ def call
14
+ prompt.ask(question, default: default, required: required, help: help)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :question, :default, :required, :help
20
+
21
+ def prompt
22
+ @prompt ||= TTY::Prompt.new(help_color: :dim, enable_color: true, interrupt: :exit)
23
+ end
24
+ end
@@ -0,0 +1,50 @@
1
+ class Soapstone::UI::Prompts::Select
2
+ def self.call(question:, answers:, default: nil, help: nil)
3
+ new(question: question, answers: answers, default: default, help: help).call
4
+ end
5
+
6
+ def initialize(question:, answers:, default: nil, help: nil)
7
+ @question = question
8
+ @answers = answers
9
+ @default = find_default_name(default)
10
+ @help = help
11
+ end
12
+
13
+ def call
14
+ prompt.select(question, formatted_answers, default: default, help: help, show_help: :always, cycle: true)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :question, :answers, :default, :help
20
+
21
+ def prompt
22
+ @prompt ||= TTY::Prompt.new(help_color: :dim, active_color: :red, enable_color: true, interrupt: :exit, symbols: {marker: "\u25B6"}).tap do |p|
23
+ p.on(:keypress) do |event|
24
+ p.trigger(:keydown) if event.value == "j"
25
+ p.trigger(:keyup) if event.value == "k"
26
+ end
27
+ end
28
+ end
29
+
30
+ def find_default_name(default_value)
31
+ return if default_value.nil?
32
+
33
+ answers.find { |a| a.value == default_value.to_sym }&.name
34
+ end
35
+
36
+ def formatted_answers
37
+ pastel = Pastel.new
38
+
39
+ answers.map do |answer|
40
+ name = answer.name
41
+ name.prepend("#{answer.emoji} ") if answer.emoji
42
+ name.concat(" #{answer.help && pastel.dim("(#{answer.help})")}") if answer.help
43
+
44
+ {
45
+ name: name,
46
+ value: answer.value
47
+ }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,10 @@
1
+ class Soapstone::UI::Prompts::SelectAnswer
2
+ def initialize(name:, value:, emoji: nil, help: nil)
3
+ @name = name
4
+ @emoji = emoji
5
+ @value = value
6
+ @help = help
7
+ end
8
+
9
+ attr_reader :name, :emoji, :value, :help
10
+ end
@@ -0,0 +1,25 @@
1
+ class Soapstone::UI::Prompts::YesNo
2
+ def self.call(question:, default: nil, help: nil)
3
+ new(question: question, default: default, help: help).call
4
+ end
5
+
6
+ def initialize(question:, default: nil, help: nil)
7
+ @question = question
8
+ @default = default
9
+ @help = help
10
+ end
11
+
12
+ def call
13
+ prompt.yes?(question, help: help) do |q|
14
+ q.default(default) if default
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :question, :default, :help
21
+
22
+ def prompt
23
+ @prompt ||= TTY::Prompt.new(help_color: :dim, enable_color: true, interrupt: :exit)
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ class Soapstone::UI::Screens::Utils
2
+ def self.clear
3
+ system("clear") || system("cls")
4
+ end
5
+ end