Skip to content

Commit 9cbb2b7

Browse files
sambostockclaude
andcommitted
Add ask_masked for masked input prompts
Add `CLI::UI::Prompt.ask_masked` (and `CLI::UI.ask_masked`) which displays `*` for each character typed, giving visual feedback that input is being received. This is useful for token/secret prompts where completely invisible input (ask_password) is disorienting. Builds on the shared `read_secret` infrastructure from the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cacc55e commit 9cbb2b7

3 files changed

Lines changed: 99 additions & 0 deletions

File tree

lib/cli/ui.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ def ask_password(question)
132132
CLI::UI::Prompt.ask_password(question)
133133
end
134134

135+
# Convenience Method for +CLI::UI::Prompt.ask_masked+
136+
#
137+
# ==== Attributes
138+
#
139+
# * +question+ - question to ask
140+
#
141+
#: (String question) -> String
142+
def ask_masked(question)
143+
CLI::UI::Prompt.ask_masked(question)
144+
end
145+
135146
# Convenience Method to resolve text using +CLI::UI::Formatter.format+
136147
# Check +CLI::UI::Formatter::SGR_MAP+ for available formatting options
137148
#

lib/cli/ui/prompt.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ def ask_password(question)
161161
read_secret(question, mask: false)
162162
end
163163

164+
# Asks the user for a single-line answer, masking each character with '*' as they type.
165+
# Unlike +ask_password+ which shows no feedback, this method provides visual feedback
166+
# by printing a '*' for each character entered.
167+
# Supports backspace to delete characters and Ctrl-C to abort.
168+
#
169+
# ==== Return Value
170+
#
171+
# The input string.
172+
# If the user simply presses "Enter" without typing anything, this will return an empty string.
173+
#: (String question) -> String
174+
def ask_masked(question)
175+
read_secret(question, mask: true)
176+
end
177+
164178
# Asks the user a yes/no question.
165179
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
166180
#

test/cli/ui/prompt_test.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ def test_read_char_returns_only_the_next_read_char
416416
assert_output_includes('c'.inspect)
417417
end
418418

419+
# ask_password tests
420+
419421
def test_ask_password_happy_path
420422
run_in_process('puts "--#{CLI::UI::Prompt.ask_password("Password: ")}--"')
421423
write("secret\n")
@@ -473,6 +475,78 @@ def test_ask_password_sigint
473475
assert_output_includes('sentinel')
474476
end
475477

478+
# ask_masked tests
479+
480+
def test_ask_masked_shows_stars
481+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
482+
write("secret\n")
483+
clean_up do
484+
output = @stdout.read
485+
assert_includes(output, '******')
486+
assert_includes(output, '--secret--')
487+
end
488+
end
489+
490+
def test_ask_masked_supports_backspace
491+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
492+
write("abc\u007Fd\n")
493+
assert_output_includes('--abd--')
494+
end
495+
496+
def test_ask_masked_supports_backspace_0x08
497+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
498+
write("abc\bd\n")
499+
assert_output_includes('--abd--')
500+
end
501+
502+
def test_ask_masked_backspace_on_empty_input
503+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
504+
write("\u007Fabc\n")
505+
assert_output_includes('--abc--')
506+
end
507+
508+
def test_ask_masked_multiple_backspaces
509+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
510+
write("abcd\u007F\u007F\u007Fe\n")
511+
assert_output_includes('--ae--')
512+
end
513+
514+
def test_ask_masked_empty_input
515+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
516+
write("\n")
517+
assert_output_includes('----')
518+
end
519+
520+
def test_ask_masked_carriage_return
521+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
522+
write("secret\r")
523+
assert_output_includes('--secret--')
524+
end
525+
526+
def test_ask_masked_sigint
527+
jruby_skip('SIGINT shuts down the JVM instead of raising Interrupt')
528+
529+
run_in_process(<<~RUBY)
530+
begin
531+
CLI::UI::Prompt.ask_masked("Token: ")
532+
rescue Interrupt
533+
puts 'sentinel'
534+
end
535+
RUBY
536+
537+
wait_for_output_to_include('Token:')
538+
write("\u0003")
539+
540+
assert_output_includes('sentinel')
541+
end
542+
543+
def test_ask_masked_eof_returns_input_so_far
544+
run_in_process('puts "--#{CLI::UI::Prompt.ask_masked("Token: ")}--"')
545+
# Close stdin immediately — EOF with no input
546+
@stdin.close
547+
assert_output_includes('----')
548+
end
549+
476550
def test_spinner_inside_prompt
477551
run_in_process(<<~RUBY)
478552
CLI::UI::Prompt.ask('question') do |handler|

0 commit comments

Comments
 (0)