From 94b8d36474145cd077562594e0c4cc40693c7a88 Mon Sep 17 00:00:00 2001 From: Gert Goet Date: Wed, 10 Jun 2026 14:13:36 +0200 Subject: [PATCH] Don't require pressing Enter when selecting a gem When `bundle info`/`show`/`open` show a numbered menu for an ambiguous gem name, accept the choice on a single keypress instead of requiring Enter: - With <10 options every digit (including 0/exit) selects immediately. - With >=10 options a digit waits only while it's a prefix of a larger valid option (e.g. 1 when 10/11 exist); Enter then resolves the prefix to what's typed so far. Backspace edits; invalid digits are ignored. --- lib/bundler/cli/common.rb | 92 ++++++++++++++++++++++++++++++++- spec/bundler/cli_common_spec.rb | 80 ++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/lib/bundler/cli/common.rb b/lib/bundler/cli/common.rb index b44fbc309641..dfa5fea5825c 100644 --- a/lib/bundler/cli/common.rb +++ b/lib/bundler/cli/common.rb @@ -95,8 +95,96 @@ def self.ask_for_spec_from(specs) end Bundler.ui.info "0 : - exit -", true - num = Bundler.ui.ask("> ").to_i - num > 0 ? specs[num - 1] : nil + num = ask_for_number(specs.count) + num && num > 0 ? specs[num - 1] : nil + end + + # Reads a menu selection in the range 0..max, returning the chosen number + # or nil if no selection was made (Ctrl-D/EOF). When stdin/stdout are a TTY, + # a choice is accepted on a single keypress as soon as it is unambiguous, + # i.e. no larger valid number has the typed digits as a prefix (so "1" is + # accepted instantly when there are < 10 options, but waits for a second + # digit when "10"/"11"/... are also valid). Enter resolves the prefix to + # the number typed so far (e.g. selecting "1" while "10" exists), backspace + # edits, and Ctrl-C aborts (exit 130). Falls back to a line-based prompt otherwise. + def self.ask_for_number(max) + return Bundler.ui.ask("> ").to_i unless single_keypress_supported? + + buf = String.new + # The prompt, echo and newlines are written straight to $stdout (rather + # than through Bundler.ui) because this path only runs on an interactive + # TTY, where we need precise, unbuffered control over the cursor. + $stdout.print "> " + $stdout.flush + + loop do + ch = $stdin.getch + # "No selection" -- the same outcome as choosing 0/exit -- via either a + # real EOF (nil, e.g. the terminal detached) or Ctrl-D, which getch's + # raw mode delivers as a byte (4 = EOT) rather than as EOF. + if ch.nil? || ch == 4.chr + $stdout.print "\n" + return + end + # Ctrl-C arrives as a raw byte because getch disables the terminal's + # signal keys. Re-raising Interrupt here would be re-rescued and printed + # by with_friendly_errors; instead exit with the conventional SIGINT + # status (130) so Bundler still terminates with proper signal semantics + # (see rubygems/bundler#6092) but without dumping a backtrace. + if ch == 3.chr + $stdout.print "\n" + exit(128 + Signal.list["INT"]) + end + + case ch + when "\r", "\n" + if valid_number?(buf, max) + $stdout.print "\n" + return buf.to_i + end + when "\u007f", "\b" # backspace / delete + unless buf.empty? + buf.chop! + $stdout.print "\b \b" + $stdout.flush + end + when /\A[0-9]\z/ + candidate = buf + ch + # Ignore a digit that cannot lead to any valid selection. + next unless prefix_of_valid?(candidate, max) + buf = candidate + $stdout.print ch + $stdout.flush + if valid_number?(buf, max) && !has_longer_valid?(buf, max) + $stdout.print "\n" + return buf.to_i + end + end + end + end + + def self.single_keypress_supported? + return false unless $stdin.tty? && $stdout.tty? + require "io/console" + $stdin.respond_to?(:getch) + rescue LoadError + false + end + + # buf is exactly a valid token (no leading zeros) within 0..max. + def self.valid_number?(buf, max) + !buf.empty? && buf == buf.to_i.to_s && buf.to_i <= max + end + + # Some valid token in 0..max starts with the typed digits (incl. equal). + def self.prefix_of_valid?(buf, max) + (0..max).any? {|i| i.to_s.start_with?(buf) } + end + + # A *longer* valid token in 0..max starts with the typed digits, so the + # selection is still ambiguous (e.g. "1" while "10" is also valid). + def self.has_longer_valid?(buf, max) + (0..max).any? {|i| i.to_s != buf && i.to_s.start_with?(buf) } end def self.gem_not_found_message(missing_gem_name, alternatives) diff --git a/spec/bundler/cli_common_spec.rb b/spec/bundler/cli_common_spec.rb index 015894b3a13e..1f871d3895de 100644 --- a/spec/bundler/cli_common_spec.rb +++ b/spec/bundler/cli_common_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "bundler/cli" +require "stringio" RSpec.describe Bundler::CLI::Common do describe "gem_not_found_message" do @@ -19,4 +20,83 @@ expect(message).to match(/Did you mean 'method(|s)' or 'method(|s)'?/) end end + + describe "ask_for_number" do + BACKSPACE = 127.chr # DEL + CTRL_C = 3.chr # ETX + CTRL_D = 4.chr # EOT + + # Run ask_for_number against a scripted sequence of keypresses, with the + # single-keypress path forced on and $stdin/$stdout swapped for fakes. + def select(keys, max) + allow(described_class).to receive(:single_keypress_supported?).and_return(true) + input = double("stdin") + allow(input).to receive(:getch).and_return(*keys) + + old_stdin = $stdin + old_stdout = $stdout + $stdin = input + $stdout = StringIO.new + described_class.ask_for_number(max) + ensure + $stdin = old_stdin + $stdout = old_stdout + end + + context "when no option number is a prefix of another (fewer than ten options)" do + it "accepts a choice on a single keypress, without Enter" do + expect(select(["1"], 3)).to eq(1) + expect(select(["3"], 3)).to eq(3) + end + + it "treats 0 as the exit choice" do + expect(select(["0"], 3)).to eq(0) + end + + it "ignores a digit that cannot match any option" do + expect(select(["8", "2"], 3)).to eq(2) + end + end + + context "when an option number is a prefix of another (ten or more options)" do + it "waits for a second digit before resolving an ambiguous prefix" do + expect(select(["1", "0"], 11)).to eq(10) + expect(select(["1", "1"], 11)).to eq(11) + end + + it "still accepts an unambiguous digit immediately" do + expect(select(["7"], 11)).to eq(7) + expect(select(["0"], 11)).to eq(0) + end + + it "resolves the shorter number when Enter is pressed" do + expect(select(["1", "\r"], 11)).to eq(1) + expect(select(["1", "\n"], 11)).to eq(1) + end + end + + it "supports backspace to edit the buffer" do + expect(select(["1", BACKSPACE, "3"], 11)).to eq(3) + end + + it "aborts with the conventional SIGINT status (130) on Ctrl-C" do + expect { select([CTRL_C], 3) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(130) + end + end + + it "returns nil at EOF instead of looping" do + expect(select([nil], 3)).to be_nil + end + + it "cancels (returns nil) on Ctrl-D" do + expect(select([CTRL_D], 3)).to be_nil + end + + it "falls back to a line prompt when single keypress input is unsupported" do + allow(described_class).to receive(:single_keypress_supported?).and_return(false) + allow(Bundler.ui).to receive(:ask).with("> ").and_return("2") + expect(described_class.ask_for_number(3)).to eq(2) + end + end end