Module: Howzit::Prompt

Defined in:
lib/howzit/prompt.rb

Overview

Command line prompt utils

Class Method Summary collapse

Class Method Details

.choose(matches, height: :auto, query: nil) ⇒ Array

Choose from a list of items. If fzf is available, uses that, otherwise generates its own list of options and accepts a numeric response

Parameters:

  • matches (Array)

    The options list

  • height (Symbol) (defaults to: :auto)

    height of fzf menu (:auto adjusts height to number of options, anything else gets max height for terminal)

  • query (String) (defaults to: nil)

    The search term to display in prompt

Returns:

  • (Array)

    the selected results



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/howzit/prompt.rb', line 90

def choose(matches, height: :auto, query: nil)
  return [] if matches.count.zero?
  return matches if matches.count == 1
  return [] unless $stdout.isatty

  if Util.command_exist?('fzf')
    height = height == :auto ? matches.count + 3 : TTY::Screen.rows

    settings = fzf_options(height, query: query)
    res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
    return fzf_result(res)
  end

  if Util.command_exist?('gum')
    return gum_choose(matches, query: query, multi: true)
  end

  tty_menu(matches, query: query)
end

.choose_templates(matches, prompt_text: 'Select templates') ⇒ Array

Multi-select menu for templates

Parameters:

  • matches (Array)

    The options list

  • prompt_text (String) (defaults to: 'Select templates')

    The prompt to display

Returns:

  • (Array)

    the selected results (can be empty)



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/howzit/prompt.rb', line 215

def choose_templates(matches, prompt_text: 'Select templates')
  return [] if matches.count.zero?
  return [] unless $stdout.isatty

  if Util.command_exist?('fzf')
    height = matches.count + 3
    settings = fzf_template_options(height, prompt_text: prompt_text)

    # Save terminal state before fzf
    tty_state = `stty -g`.chomp
    res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
    # Restore terminal state after fzf
    system("stty #{tty_state}")

    return res.empty? ? [] : res.split(/\n/)
  end

  if Util.command_exist?('gum')
    return gum_choose(matches, prompt: prompt_text, multi: true, required: false)
  end

  text_template_input(matches)
end

.color_single_options(choices = %w[y n])) ⇒ String

Helper function to colorize the Y/N prompt

Parameters:

  • choices (Array) (defaults to: %w[y n]))

    The choices with default capitalized

Returns:

  • (String)

    colorized string



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/howzit/prompt.rb', line 46

def color_single_options(choices = %w[y n])
  out = []
  choices.each do |choice|
    case choice
    when /[A-Z]/
      out.push(Color.template("{bw}#{choice}{x}"))
    else
      out.push(Color.template("{dw}#{choice}{xg}"))
    end
  end
  Color.template("{xg}[#{out.join('/')}{xg}]{x}")
end

.fuzzy_match_templates(input, available) ⇒ Array

Fuzzy match user input against available templates

Parameters:

  • input (String)

    Comma-separated user input

  • available (Array)

    Available template names

Returns:

  • (Array)

    Matched template names



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/howzit/prompt.rb', line 287

def fuzzy_match_templates(input, available)
  terms = input.split(',').map(&:strip).reject(&:empty?)
  matched = []

  terms.each do |term|
    # Try exact match first (case-insensitive)
    exact = available.find { |t| t.downcase == term.downcase }
    if exact
      matched << exact unless matched.include?(exact)
      next
    end

    # Try fuzzy match using the same regex approach as topic matching
    rx = term.to_rx
    fuzzy = available.select { |t| t =~ rx }

    # Prefer matches that start with the term
    if fuzzy.length > 1
      starts_with = fuzzy.select { |t| t.downcase.start_with?(term.downcase) }
      fuzzy = starts_with unless starts_with.empty?
    end

    fuzzy.each { |t| matched << t unless matched.include?(t) }
  end

  matched
end

.fzf_options(height, query: nil) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/howzit/prompt.rb', line 118

def fzf_options(height, query: nil)
  prompt = if query
             "Select a topic for \\`#{query}\\` > "
           else
             'Select a topic > '
           end
  [
    '-0',
    '-1',
    '-m',
    "--height=#{height}",
    '--header="Tab: add selection, ctrl-a/d: (de)select all, return: display/run"',
    '--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
    "--prompt=\"#{prompt}\"",
    %(--preview="howzit --no-pager --header-format block --no-color --default --multiple first {}")
  ]
end

.fzf_result(res) ⇒ Object



110
111
112
113
114
115
116
# File 'lib/howzit/prompt.rb', line 110

def fzf_result(res)
  if res.nil? || res.empty?
    Howzit.console.info 'Cancelled'
    Process.exit 0
  end
  res.split(/\n/)
end

.fzf_template_options(height, prompt_text: 'Select templates') ⇒ Object

FZF options for template selection



242
243
244
245
246
247
248
249
250
251
# File 'lib/howzit/prompt.rb', line 242

def fzf_template_options(height, prompt_text: 'Select templates')
  [
    '-0',
    '-m',
    "--height=#{height}",
    '--header="Tab: add selection, ctrl-a/d: (de)select all, esc: skip, return: confirm"',
    '--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
    "--prompt=\"#{prompt_text} > \""
  ]
end

.get_line(prompt_text, default: nil) ⇒ String

Prompt for a single line of input

Parameters:

  • prompt_text (String)

    The prompt to display

  • default (String) (defaults to: nil)

    Default value if empty

Returns:

  • (String)

    the entered value



323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/howzit/prompt.rb', line 323

def get_line(prompt_text, default: nil)
  return (default || '') unless $stdout.isatty

  if Util.command_exist?('gum')
    result = gum_input(prompt_text, placeholder: default || '')
    return result.empty? && default ? default : result
  end

  prompt_with_default = default ? "#{prompt_text} [#{default}]: " : "#{prompt_text}: "
  result = Readline.readline(prompt_with_default, true).to_s.strip
  result.empty? && default ? default : result
end

.gum_choose(matches, prompt: nil, multi: false, required: true, query: nil) ⇒ Array

Use gum for single or multi-select menu

Parameters:

  • matches (Array)

    The options list

  • prompt (String) (defaults to: nil)

    The prompt text

  • multi (Boolean) (defaults to: false)

    Allow multiple selections

  • required (Boolean) (defaults to: true)

    Require at least one selection

  • query (String) (defaults to: nil)

    The search term for display

Returns:

  • (Array)

    Selected items



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/howzit/prompt.rb', line 347

def gum_choose(matches, prompt: nil, multi: false, required: true, query: nil)
  prompt_text = prompt || (query ? "Select for '#{query}'" : 'Select')
  args = ['gum', 'choose']
  args << '--no-limit' if multi
  args << "--header=#{Shellwords.escape(prompt_text)}"
  args << '--cursor.foreground=6'
  args << '--selected.foreground=2'

  tty_state = `stty -g`.chomp
  res = `echo #{Shellwords.escape(matches.join("\n"))} | #{args.join(' ')}`.strip
  system("stty #{tty_state}")

  if res.empty?
    if required
      Howzit.console.info 'Cancelled'
      Process.exit 0
    end
    return []
  end

  res.split(/\n/)
end

.gum_input(prompt_text, placeholder: '') ⇒ String

Use gum for text input

Parameters:

  • prompt_text (String)

    The prompt to display

  • placeholder (String) (defaults to: '')

    Placeholder text

Returns:

  • (String)

    The entered value



378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/howzit/prompt.rb', line 378

def gum_input(prompt_text, placeholder: '')
  args = ['gum', 'input']
  args << "--header=#{Shellwords.escape(prompt_text)}"
  args << "--placeholder=#{Shellwords.escape(placeholder)}" unless placeholder.empty?
  args << '--cursor.foreground=6'

  tty_state = `stty -g`.chomp
  res = `#{args.join(' ')}`.strip
  system("stty #{tty_state}")

  res
end

.options_list(matches) ⇒ Object

Create a numbered list of options. Outputs directly to console, returns nothing

Parameters:

  • matches (Array)

    The list items



65
66
67
68
69
70
71
72
73
# File 'lib/howzit/prompt.rb', line 65

def options_list(matches)
  counter = 1
  puts
  matches.each do |match|
    printf("%<counter>2d ) %<option>s\n", counter: counter, option: match)
    counter += 1
  end
  puts
end

.read_editor(default = nil) ⇒ Object

Request editor



181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/howzit/prompt.rb', line 181

def read_editor(default = nil)
  @stty_save = `stty -g`.chomp

  default ||= 'vim'
  prompt = "Define a default editor command (default #{default}): "
  res = Readline.readline(prompt, true).squeeze(' ').strip
  res = default if res.empty?

  Util.valid_command?(res) ? res : default
ensure
  system('stty', @stty_save)
end

.read_num(line) ⇒ Object

Convert a response to an Integer

Parameters:

  • line

    The response to convert



199
200
201
202
203
204
205
# File 'lib/howzit/prompt.rb', line 199

def read_num(line)
  if line =~ /^[a-z]/i
    system('stty', @stty_save) # Restore
    exit
  end
  line == '' ? 1 : line.to_i
end

.read_selection(matches) ⇒ Object

Read a single number response from the command line

Parameters:

  • matches

    The matches



164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/howzit/prompt.rb', line 164

def read_selection(matches)
  printf "Type 'q' to cancel, enter for first item"
  while (line = Readline.readline(': ', true))
    line = read_num(line)

    return [matches[line - 1]] if line.positive? && line <= matches.length

    puts 'Out of range'
    read_selection(matches)
  end
ensure
  system('stty', @stty_save)
end

.text_template_input(available) ⇒ Array

Text-based template input with fuzzy matching

Parameters:

  • available (Array)

    Available template names

Returns:

  • (Array)

    Matched template names



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/howzit/prompt.rb', line 260

def text_template_input(available)
  @stty_save = `stty -g`.chomp

  trap('INT') do
    system('stty', @stty_save)
    exit
  end

  puts "\n{bw}Available templates:{x} #{available.join(', ')}".c
  printf '{bw}Enter templates to include, comma-separated (return to skip):{x} '.c
  input = Readline.readline('', true).strip

  return [] if input.empty?

  fuzzy_match_templates(input, available)
ensure
  system('stty', @stty_save) if @stty_save
end

.tty_menu(matches, query: nil) ⇒ Object

Display a numeric menu on the TTY

Parameters:

  • matches

    The matches from which to select

  • query (String) (defaults to: nil)

    The search term to display



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/howzit/prompt.rb', line 142

def tty_menu(matches, query: nil)
  return matches if matches.count == 1

  @stty_save = `stty -g`.chomp

  trap('INT') do
    system('stty')
    exit
  end

  if query
    puts "\nSelect a topic for `#{query}`:"
  end
  options_list(matches)
  read_selection(matches)
end

.yn(prompt, default: true) ⇒ Boolean

Display and read a Yes/No prompt

Parameters:

  • prompt (String)

    The prompt string

  • default (Boolean) (defaults to: true)

    default value if return is pressed or prompt is skipped

Returns:

  • (Boolean)

    result



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/howzit/prompt.rb', line 17

def yn(prompt, default: true)
  return default unless $stdout.isatty

  return true if Howzit.options[:yes]

  return false if Howzit.options[:no]

  return default if Howzit.options[:default]

  tty_state = `stty -g`
  system 'stty raw -echo cbreak isig'
  yn = color_single_options(default ? %w[Y n] : %w[y N])
  $stdout.syswrite "\e[1;37m#{prompt} #{yn}\e[1;37m? \e[0m"
  res = $stdin.sysread 1
  res.chomp!
  puts
  system 'stty cooked'
  system "stty #{tty_state}"
  res.empty? ? default : res =~ /y/i
end