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



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# 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

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

  tty_menu(matches, query: query)
end

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

Multi-select menu for templates



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/howzit/prompt.rb', line 221

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

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

  text_template_input(matches)
end

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

Helper function to colorize the Y/N prompt



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



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/howzit/prompt.rb', line 295

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



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

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



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

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



246
247
248
249
250
251
252
253
254
255
# File 'lib/howzit/prompt.rb', line 246

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



331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/howzit/prompt.rb', line 331

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



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/howzit/prompt.rb', line 355

def gum_choose(matches, prompt: nil, multi: false, required: true, query: nil)
  prompt_text = prompt || (query ? "Select for '#{query}'" : 'Select')
  args = %w[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



386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/howzit/prompt.rb', line 386

def gum_input(prompt_text, placeholder: '')
  args = %w[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



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



187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/howzit/prompt.rb', line 187

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



205
206
207
208
209
210
211
# File 'lib/howzit/prompt.rb', line 205

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



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/howzit/prompt.rb', line 166

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

    begin
      puts 'Out of range'
    rescue Errno::EPIPE
      # Pipe closed, ignore
    end
    read_selection(matches)
  end
ensure
  system('stty', @stty_save)
end

.text_template_input(available) ⇒ Array

Text-based template input with fuzzy matching



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/howzit/prompt.rb', line 264

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

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

  begin
    puts "\n{bw}Available templates:{x} #{available.join(', ')}".c
  rescue Errno::EPIPE
    # Pipe closed, ignore
  end
  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



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

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
    begin
      puts "\nSelect a topic for `#{query}`:"
    rescue Errno::EPIPE
      # Pipe closed, ignore
    end
  end
  options_list(matches)
  read_selection(matches)
end

.yn(prompt, default: true) ⇒ Boolean

Display and read a Yes/No prompt



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