From 7570b0385945fb5bdabb99879ad3a3b6d5ddb048 Mon Sep 17 00:00:00 2001 From: Akira Matsuda Date: Wed, 14 Jan 2026 00:11:03 +0900 Subject: [PATCH 1/2] Add rprompt support for right-side prompt display This adds support for displaying a right-aligned prompt (rprompt) similar to zsh's RPROMPT feature. The rprompt is displayed at the right edge of the terminal and automatically hides when the input line gets too long. Usage: Reline.readline("> ", rprompt: "[%H:%M]") The rprompt is rendered as part of Reline's normal render cycle, so it persists during line editing unlike workarounds using pre_input_hook. --- lib/reline.rb | 11 ++++---- lib/reline/line_editor.rb | 19 ++++++++++++-- test/reline/test_reline.rb | 12 +++++++++ test/reline/yamatanooroti/multiline_repl | 6 ++++- test/reline/yamatanooroti/test_rendering.rb | 29 +++++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/reline.rb b/lib/reline.rb index 03e4b745cf..ce42ec4071 100644 --- a/lib/reline.rb +++ b/lib/reline.rb @@ -247,14 +247,14 @@ def get_screen_size } # :nodoc: Reline::DEFAULT_DIALOG_CONTEXT = Array.new # :nodoc: - def readmultiline(prompt = '', add_hist = false, &confirm_multiline_termination) + def readmultiline(prompt = '', add_hist = false, rprompt: nil, &confirm_multiline_termination) @mutex.synchronize do unless confirm_multiline_termination raise ArgumentError.new('#readmultiline needs block to confirm multiline termination') end io_gate.with_raw_input do - inner_readline(prompt, add_hist, true, &confirm_multiline_termination) + inner_readline(prompt, add_hist, true, rprompt: rprompt, &confirm_multiline_termination) end whole_buffer = line_editor.whole_buffer.dup @@ -273,10 +273,10 @@ def readmultiline(prompt = '', add_hist = false, &confirm_multiline_termination) end end - def readline(prompt = '', add_hist = false) + def readline(prompt = '', add_hist = false, rprompt: nil) @mutex.synchronize do io_gate.with_raw_input do - inner_readline(prompt, add_hist, false) + inner_readline(prompt, add_hist, false, rprompt: rprompt) end line = line_editor.line.dup @@ -290,7 +290,7 @@ def readline(prompt = '', add_hist = false) end end - private def inner_readline(prompt, add_hist, multiline, &confirm_multiline_termination) + private def inner_readline(prompt, add_hist, multiline, rprompt: nil, &confirm_multiline_termination) if ENV['RELINE_STDERR_TTY'] if io_gate.win? $stderr = File.open(ENV['RELINE_STDERR_TTY'], 'a') @@ -323,6 +323,7 @@ def readline(prompt = '', add_hist = false) line_editor.prompt_proc = prompt_proc line_editor.auto_indent_proc = auto_indent_proc line_editor.dig_perfect_match_proc = dig_perfect_match_proc + line_editor.rprompt = rprompt&.encode(encoding) pre_input_hook&.call diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index bfffd17d59..ab60979873 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -13,6 +13,7 @@ class Reline::LineEditor attr_accessor :prompt_proc attr_accessor :auto_indent_proc attr_accessor :dig_perfect_match_proc + attr_accessor :rprompt VI_MOTIONS = %i{ ed_prev_char @@ -476,6 +477,20 @@ def render prompt_width = Reline::Unicode.calculate_width(prompt, true) [[0, prompt_width, prompt], [prompt_width, Reline::Unicode.calculate_width(line, true), line]] end + + # Add rprompt to the first visible line if set and there's room + if @rprompt && !@rprompt.empty? && new_lines[0] + rprompt_width = Reline::Unicode.calculate_width(@rprompt, true) + right_col = screen_width - rprompt_width + first_line = new_lines[0] + # Calculate the end of the current content (prompt + input) + content_end = first_line.sum { |_, width, _| width } + # Only show rprompt if there's at least 1 char gap between content and rprompt + if right_col > content_end + first_line << [right_col, rprompt_width, @rprompt] + end + end + if @menu_info @menu_info.lines(screen_width).each do |item| new_lines << [[0, Reline::Unicode.calculate_width(item), item]] @@ -491,8 +506,8 @@ def render next if row < 0 || row >= screen_height dialog_rows = new_lines[row] ||= [] - # index 0 is for prompt, index 1 is for line, index 2.. is for dialog - dialog_rows[index + 2] = [x_range.begin, dialog.width, dialog.contents[row - y_range.begin]] + # index 0 is for prompt, index 1 is for line, index 2 is for rprompt, index 3.. is for dialog + dialog_rows[index + 3] = [x_range.begin, dialog.width, dialog.contents[row - y_range.begin]] end end diff --git a/test/reline/test_reline.rb b/test/reline/test_reline.rb index aa0fd7d29a..2844a1d34e 100644 --- a/test/reline/test_reline.rb +++ b/test/reline/test_reline.rb @@ -232,6 +232,18 @@ def test_pre_input_hook assert_equal(l, Reline.pre_input_hook) end + def test_readline_with_rprompt + pend if win? + lib = File.expand_path("../../lib", __dir__) + code = "p result: Reline.readline('>', rprompt: '[TIME]')" + out = IO.popen([Reline.test_rubybin, "-I#{lib}", "-rreline", "-e", code], "r+") do |io| + io.write "a\n" + io.close_write + io.read + end + assert_include(out, { result: 'a' }.inspect) + end + def test_dig_perfect_match_proc assert_equal(nil, Reline.dig_perfect_match_proc) diff --git a/test/reline/yamatanooroti/multiline_repl b/test/reline/yamatanooroti/multiline_repl index 4930f2e9d8..bcab36b3d4 100755 --- a/test/reline/yamatanooroti/multiline_repl +++ b/test/reline/yamatanooroti/multiline_repl @@ -212,6 +212,10 @@ opt.on('--autocomplete-width-long') { }.select{ |c| c.start_with?(target) } } } +rprompt = nil +opt.on('--rprompt VAL') { |v| + rprompt = v +} opt.parse!(ARGV) begin @@ -222,7 +226,7 @@ end begin prompt = ENV['RELINE_TEST_PROMPT'] || "\e[1mprompt>\e[m " puts 'Multiline REPL.' - while code = Reline.readmultiline(prompt, true) { |code| TerminationChecker.terminated?(code) } + while code = Reline.readmultiline(prompt, true, rprompt: rprompt) { |code| TerminationChecker.terminated?(code) } case code.chomp when 'exit', 'quit', 'q' exit 0 diff --git a/test/reline/yamatanooroti/test_rendering.rb b/test/reline/yamatanooroti/test_rendering.rb index 008ff4a5e2..a996b9ddd1 100644 --- a/test/reline/yamatanooroti/test_rendering.rb +++ b/test/reline/yamatanooroti/test_rendering.rb @@ -183,6 +183,35 @@ def test_prompt close end + def test_rprompt + start_terminal(5, 40, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl --rprompt [RPROMPT]}, startup_message: 'Multiline REPL.') + assert_screen(<<~EOC) + Multiline REPL. + prompt> [RPROMPT] + EOC + close + end + + def test_rprompt_with_input + start_terminal(5, 40, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl --rprompt [RPROMPT]}, startup_message: 'Multiline REPL.') + write("hello") + assert_screen(<<~EOC) + Multiline REPL. + prompt> hello [RPROMPT] + EOC + close + end + + def test_rprompt_hides_when_input_reaches_rprompt + start_terminal(5, 40, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl --rprompt [RPROMPT]}, startup_message: 'Multiline REPL.') + write("a" * 30) + assert_screen(<<~EOC) + Multiline REPL. + prompt> aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + EOC + close + end + def test_mode_string_emacs write_inputrc <<~LINES set show-mode-in-prompt on From 333509fa05de1434509a8177a070fcbd29888ffb Mon Sep 17 00:00:00 2001 From: Akira Matsuda Date: Mon, 26 Jan 2026 19:34:35 +0900 Subject: [PATCH 2/2] Let readline and readmultiline accept both positional and keyword args for prompt, add_hist, and rprompt. --- lib/reline.rb | 4 ++-- test/reline/test_reline.rb | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/reline.rb b/lib/reline.rb index ce42ec4071..9112f4e058 100644 --- a/lib/reline.rb +++ b/lib/reline.rb @@ -247,7 +247,7 @@ def get_screen_size } # :nodoc: Reline::DEFAULT_DIALOG_CONTEXT = Array.new # :nodoc: - def readmultiline(prompt = '', add_hist = false, rprompt: nil, &confirm_multiline_termination) + def readmultiline(_prompt = '', _add_hist = false, prompt: _prompt, add_hist: _add_hist, rprompt: nil, &confirm_multiline_termination) @mutex.synchronize do unless confirm_multiline_termination raise ArgumentError.new('#readmultiline needs block to confirm multiline termination') @@ -273,7 +273,7 @@ def readmultiline(prompt = '', add_hist = false, rprompt: nil, &confirm_multilin end end - def readline(prompt = '', add_hist = false, rprompt: nil) + def readline(_prompt = '', _add_hist = false, prompt: _prompt, add_hist: _add_hist, rprompt: nil) @mutex.synchronize do io_gate.with_raw_input do inner_readline(prompt, add_hist, false, rprompt: rprompt) diff --git a/test/reline/test_reline.rb b/test/reline/test_reline.rb index 2844a1d34e..6673188980 100644 --- a/test/reline/test_reline.rb +++ b/test/reline/test_reline.rb @@ -244,6 +244,30 @@ def test_readline_with_rprompt assert_include(out, { result: 'a' }.inspect) end + def test_readline_with_keyword_arguments + pend if win? + lib = File.expand_path("../../lib", __dir__) + code = "p result: Reline.readline(prompt: '>', add_hist: true, rprompt: '[TIME]')" + out = IO.popen([Reline.test_rubybin, "-I#{lib}", "-rreline", "-e", code], "r+") do |io| + io.write "a\n" + io.close_write + io.read + end + assert_include(out, { result: 'a' }.inspect) + end + + def test_readmultiline_with_keyword_arguments + pend if win? + lib = File.expand_path("../../lib", __dir__) + code = "p result: Reline.readmultiline(prompt: '>', add_hist: true, rprompt: '[TIME]') { true }" + out = IO.popen([Reline.test_rubybin, "-I#{lib}", "-rreline", "-e", code], "r+") do |io| + io.write "a\n" + io.close_write + io.read + end + assert_include(out, { result: 'a' }.inspect) + end + def test_dig_perfect_match_proc assert_equal(nil, Reline.dig_perfect_match_proc)