irb-autosuggestions 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02ad5c3a9b0166ac04a5ace310b88b4608ada72fa88d3d0a4f563817042db007
4
- data.tar.gz: b68cd6b388fcdfb423ee895ed1758fdd4f8f878cc8c65faf4e45bfc8ffb7ccab
3
+ metadata.gz: b9d8279c02d62499ce7d58d2b8c4c47c7f8289fa4e601b140106219ea4d0d472
4
+ data.tar.gz: f35c372e76961a2b91fc65bdf1fe94e6ec6daa7fec810461b49eb8dd11c34379
5
5
  SHA512:
6
- metadata.gz: a16cf68316c28e28aa74993736e78eca09ad0f96fda3e0afb5acfc7c9d1a77ab7804abcc820e7e83055982677f3f2631a276ca3f1a536522b25db912a43a7b55
7
- data.tar.gz: bbbc34eaf686fa8c425f83da67c337c06cae44584e2675419b2d8faee4f94859cc8edc3c4ca51f6535bc0496a8e353f744c0ef0c469941978072e04046feacd8
6
+ metadata.gz: 838a7272d857247def598a2989cd8c43eb35b8272183ce3623c25c53faa8e3bb2c375ed3b0006ec9942c2a34537e8fdc644961ab61477dfd86118fbc9f4ccf33
7
+ data.tar.gz: 85643f9c47045b2accbceb5a0f84b4eb13ae988b39550a541a21e320fec2f81d75e56128a73a59a9ab5061f0ccce1a2c8e984fcebb8b5a083b2927fcd0c6171f
data/.rubocop.yml CHANGED
@@ -5,7 +5,7 @@ plugins:
5
5
 
6
6
  AllCops:
7
7
  NewCops: enable
8
- TargetRubyVersion: 3.0
8
+ TargetRubyVersion: 2.7
9
9
 
10
10
  Style/DoubleNegation:
11
11
  Enabled: false
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.0.7
1
+ 2.7.8
data/Gemfile CHANGED
@@ -2,5 +2,6 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- # Specify your gem's dependencies in irb-autosuggestions.gemspec
6
5
  gemspec
6
+
7
+ gem 'rbs', '~> 3.6.0', require: false if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.0')
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- irb-autosuggestions (0.1.1)
4
+ irb-autosuggestions (0.2.1)
5
5
  reline
6
6
 
7
7
  GEM
@@ -70,17 +70,19 @@ GEM
70
70
  unicode-display_width (3.2.0)
71
71
  unicode-emoji (~> 4.1)
72
72
  unicode-emoji (4.2.0)
73
- yard (0.9.43)
73
+ yard (0.9.44)
74
74
 
75
75
  PLATFORMS
76
+ arm64-darwin-24
76
77
  arm64-darwin-25
78
+ ruby
77
79
  x86_64-linux
78
80
 
79
81
  DEPENDENCIES
80
82
  docscribe
81
83
  irb-autosuggestions!
82
84
  rake
83
- rbs
85
+ rbs (~> 3.6.0)
84
86
  rspec (~> 3.0)
85
87
  rubocop
86
88
  rubocop-rake
@@ -89,4 +91,4 @@ DEPENDENCIES
89
91
  yard (>= 0.9.38)
90
92
 
91
93
  BUNDLED WITH
92
- 2.5.23
94
+ 4.0.11
data/Gemfile_2_7 ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in irb-autosuggestions.gemspec
6
+ gemspec
data/Gemfile_2_7.lock ADDED
@@ -0,0 +1,88 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ irb-autosuggestions (0.2.1)
5
+ reline
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ ast (2.4.3)
11
+ diff-lcs (1.6.2)
12
+ docscribe (1.3.3)
13
+ parser (>= 3.3)
14
+ prism (~> 1.8)
15
+ io-console (0.8.2)
16
+ json (2.19.5)
17
+ language_server-protocol (3.17.0.5)
18
+ lint_roller (1.1.0)
19
+ parallel (1.28.0)
20
+ parser (3.3.11.1)
21
+ ast (~> 2.4.1)
22
+ racc
23
+ prism (1.9.0)
24
+ racc (1.8.1)
25
+ rainbow (3.1.1)
26
+ rake (13.4.2)
27
+ regexp_parser (2.12.0)
28
+ reline (0.6.3)
29
+ io-console (~> 0.5)
30
+ rspec (3.13.2)
31
+ rspec-core (~> 3.13.0)
32
+ rspec-expectations (~> 3.13.0)
33
+ rspec-mocks (~> 3.13.0)
34
+ rspec-core (3.13.6)
35
+ rspec-support (~> 3.13.0)
36
+ rspec-expectations (3.13.5)
37
+ diff-lcs (>= 1.2.0, < 2.0)
38
+ rspec-support (~> 3.13.0)
39
+ rspec-mocks (3.13.8)
40
+ diff-lcs (>= 1.2.0, < 2.0)
41
+ rspec-support (~> 3.13.0)
42
+ rspec-support (3.13.7)
43
+ rubocop (1.86.2)
44
+ json (~> 2.3)
45
+ language_server-protocol (~> 3.17.0.2)
46
+ lint_roller (~> 1.1.0)
47
+ parallel (>= 1.10)
48
+ parser (>= 3.3.0.2)
49
+ rainbow (>= 2.2.2, < 4.0)
50
+ regexp_parser (>= 2.9.3, < 3.0)
51
+ rubocop-ast (>= 1.49.0, < 2.0)
52
+ ruby-progressbar (~> 1.7)
53
+ unicode-display_width (>= 2.4.0, < 4.0)
54
+ rubocop-ast (1.49.1)
55
+ parser (>= 3.3.7.2)
56
+ prism (~> 1.7)
57
+ rubocop-rake (0.7.1)
58
+ lint_roller (~> 1.1)
59
+ rubocop (>= 1.72.1)
60
+ rubocop-rspec (3.9.0)
61
+ lint_roller (~> 1.1)
62
+ rubocop (~> 1.81)
63
+ rubocop-sorted_methods_by_call (1.2.2)
64
+ lint_roller
65
+ rubocop (>= 1.72.0)
66
+ ruby-progressbar (1.13.0)
67
+ unicode-display_width (3.2.0)
68
+ unicode-emoji (~> 4.1)
69
+ unicode-emoji (4.2.0)
70
+ yard (0.9.44)
71
+
72
+ PLATFORMS
73
+ arm64-darwin-25
74
+ x86_64-linux
75
+
76
+ DEPENDENCIES
77
+ docscribe
78
+ irb-autosuggestions!
79
+ rake
80
+ rspec (~> 3.0)
81
+ rubocop
82
+ rubocop-rake
83
+ rubocop-rspec
84
+ rubocop-sorted_methods_by_call
85
+ yard (>= 0.9.38)
86
+
87
+ BUNDLED WITH
88
+ 2.4.22
data/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  # Irb::Autosuggestions
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/irb-autosuggestions.svg)](https://rubygems.org/gems/irb-autosuggestions)
4
+ [![RubyGems Downloads](https://img.shields.io/gem/dt/irb-autosuggestions.svg)](https://rubygems.org/gems/irb-autosuggestions)
4
5
  [![CI](https://github.com/unurgunite/irb-autosuggestions/actions/workflows/ci.yml/badge.svg)](https://github.com/unurgunite/irb-autosuggestions/actions)
6
+ [![License](https://img.shields.io/github/license/unurgunite/irb-autosuggestions.svg)](https://github.com/unurgunite/irb-autosuggestions/blob/master/LICENSE.txt)
7
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-blue.svg)](#installation)
5
8
 
6
9
  ![Irb::Autosuggestions](readme.png)
7
10
 
@@ -13,7 +16,9 @@ No need to explain. Fish-like autosuggestions for IRB — ghost text from histor
13
16
  * [Contents](#contents)
14
17
  * [Installation](#installation)
15
18
  * [Usage](#usage)
19
+ * [Prefix-filtered history navigation](#prefix-filtered-history-navigation)
16
20
  * [Configuration](#configuration)
21
+ * [Colors](#colors)
17
22
  * [How it works](#how-it-works)
18
23
  * [Development](#development)
19
24
  * [License](#license)
@@ -34,7 +39,8 @@ require 'irb-autosuggestions'
34
39
 
35
40
  ## Usage
36
41
 
37
- Start typing in IRB. Gray ghost text appears after the cursor, showing the most recent matching history entry:
42
+ Start typing in IRB. Ghost text appears after the cursor, showing the most recent matching history entry with syntax
43
+ coloring (or gray if colorization is unavailable or disabled):
38
44
 
39
45
  ```
40
46
  irb(main):001* [1,2,3].map do |el|
@@ -44,6 +50,36 @@ irb(main):003> end <- "d" in gray
44
50
 
45
51
  Press **right arrow** (`->`) to accept the full multiline suggestion.
46
52
 
53
+ ### Prefix-filtered history navigation
54
+
55
+ **Up/down arrows** navigate history filtered by the typed prefix (like zsh). Start typing and press **up** — only
56
+ entries matching your prefix are shown. The prefix is frozen on the first press; subsequent presses keep searching
57
+ within that prefix:
58
+
59
+ ```
60
+ irb(main):001:0> def <- type "def", press up
61
+ irb(main):001:0> def foo <- press up again then older "def*" entry
62
+ ```
63
+
64
+ Press **right arrow** to accept the suggestion and exit prefix mode. Any non-history key (letter, enter, etc.) resets
65
+ the prefix anchor, returning arrows to normal unfiltered browsing.
66
+
67
+ Consecutive duplicate entries are collapsed during prefix search so each unique line appears once.
68
+
69
+ Regular unfiltered history browsing (empty buffer + up arrow) is unchanged — all entries are shown including duplicates.
70
+
71
+ To disable prefix navigation while keeping ghost text:
72
+
73
+ ```ruby
74
+ IRB.conf[:USE_PREFIX_HISTORY_NAVIGATION] = false
75
+ ```
76
+
77
+ Or via environment variable:
78
+
79
+ ```sh
80
+ export IRB_PREFIX_HISTORY_NAVIGATION=0
81
+ ```
82
+
47
83
  ## Configuration
48
84
 
49
85
  Autosuggestions are enabled by default. To disable:
@@ -60,13 +96,40 @@ Or via environment variable:
60
96
  export IRB_AUTOSUGGESTIONS=0
61
97
  ```
62
98
 
99
+ ### Colors
100
+
101
+ Syntax-colored ghost text is **enabled by default** when `IRB::Color` is available and `IRB.conf[:USE_COLORIZE]` is
102
+ true.
103
+
104
+ To disable colored ghost text (falls back to plain gray):
105
+
106
+ ```ruby
107
+ IRB.conf[:USE_COLORIZE] = false
108
+ ```
109
+
110
+ Or from command line:
111
+
112
+ ```sh
113
+ irb --nocolorize
114
+ ```
115
+
116
+ > [!NOTE]
117
+ > Colorized ghost rendering may behave differently across terminal emulators, Ruby versions, and IRB color
118
+ > schemes. If you notice visual artifacts (e.g., wrong colors, underlines, or unusual brightness), try disabling the
119
+ > feature or switch to the gray fallback or create new issue.
120
+
63
121
  ### How it works
64
122
 
65
123
  - Each keystroke queries `Reline::HISTORY` for the most recent entry whose prefix matches the current buffer.
66
- - The suggestion is rendered inline as gray (`\e[90m`) text without modifying the buffer.
124
+ - The suggestion is rendered inline as ghost text without modifying the buffer.
125
+ - When available, the ghost uses `IRB::Color.colorize_code` to match IRB's syntax colors, dimmed via ANSI escape codes
126
+ for visual distinction.
67
127
  - Extra ghost lines (for multiline history entries) are drawn below the prompt.
68
- - `\e[J` clears stale ghost artifacts from the viewport.
128
+ - Ghost artifacts are cleared each frame using cursor save/restore (`\e[s`/`\e[u`) and per-line clearing (`\e[2K`),
129
+ avoiding `\e[J` which interfered with Reline's cursor tracking.
69
130
  - Right arrow triggers `ed_next_char`, which replaces the buffer with the ghost text.
131
+ - Up/down arrows use prefix-filtered navigation when the buffer is non-empty, falling back to unfiltered browsing
132
+ when nothing was typed.
70
133
 
71
134
  ## Development
72
135
 
@@ -3,17 +3,26 @@
3
3
  module Irb
4
4
  module Autosuggestions
5
5
  # Patches Reline::LineEditor to display fish-like autosuggestions from history.
6
+ # rubocop:disable Metrics/ModuleLength, SortedMethodsByCall/Waterfall
6
7
  module LineEditorPatch
7
8
  GRAY = "\e[90m"
9
+ DIM = "\e[2m"
10
+ RESET_COLOR = "\e[39;49m"
8
11
  RESET = "\e[0m"
12
+ FG_COLORS = ((30..37).to_a + (90..97).to_a + [38, 39]).freeze
9
13
  CONFIG_KEY = :USE_AUTOSUGGESTIONS
10
14
  ENV_KEY = 'IRB_AUTOSUGGESTIONS'
15
+ CONFIG_NAV_KEY = :USE_PREFIX_HISTORY_NAVIGATION
16
+ ENV_NAV_KEY = 'IRB_PREFIX_HISTORY_NAVIGATION'
11
17
 
12
- # Intercepts key input to accept autosuggestions on right arrow.
18
+ # Intercepts key input to accept autosuggestions on right arrow
19
+ # and clears the prefix navigation anchor on non-history keys.
13
20
  #
14
21
  # @param [Object] key A Reline key event.
15
- # @return [Object] Returns +super+ for non-right-arrow keys, +nil+ after accept.
22
+ # @return [Object]
16
23
  def input_key(key)
24
+ clear_prefix_anchor if navigation_enabled? && !history_navigation_key?(key)
25
+
17
26
  if enabled? && right_arrow?(key)
18
27
  buffer = whole_buffer
19
28
  suggestion = find_suggestion(buffer)
@@ -27,25 +36,166 @@ module Irb
27
36
  super
28
37
  end
29
38
 
30
- private
31
-
32
39
  # Injects ghost text into terminal output after Reline finishes rendering.
40
+ # Must be public because Reline::Core calls +rerender+ externally.
41
+ # Works on both Reline 0.3.x (rerender is the main rendering entry) and
42
+ # Reline 0.4+ (rerender calls render internally).
33
43
  #
34
- # @private
35
44
  # @return [Object] The result of +super+.
36
- def render(...)
45
+ def rerender
37
46
  result = super
38
- Reline.core.instance_variable_get(:@output).write("\e[J")
39
- return result unless enabled?
47
+ if enabled?
48
+ clear_previous_ghost
49
+ render_ghost_suggestion
50
+ end
51
+ result
52
+ end
40
53
 
41
- buffer = whole_buffer
42
- return result if buffer.empty?
54
+ private
43
55
 
44
- ghost = ghost_for(buffer)
45
- return result unless ghost
56
+ # Prefix-filtered up-arrow history navigation.
57
+ #
58
+ # @private
59
+ # @param [Object] key
60
+ # @param [Integer] arg Repeat count.
61
+ # @return [Object]
62
+ def ed_prev_history(key, arg: 1)
63
+ if navigation_enabled? && (@line_index.zero? || @history_pointer)
64
+ buffer = prefix_buffer_for_nav
65
+ if buffer && !buffer.empty?
66
+ walk_history_back(buffer, arg)
67
+ return
68
+ end
69
+ end
70
+ clear_prefix_anchor
71
+ super
72
+ end
46
73
 
47
- render_ghost(ghost)
48
- result
74
+ # Prefix-filtered down-arrow history navigation.
75
+ #
76
+ # @private
77
+ # @param [Object] key
78
+ # @param [Integer] arg Repeat count.
79
+ # @return [Object]
80
+ def ed_next_history(key, arg: 1)
81
+ if navigation_enabled? && @history_pointer && @prefix_buffer
82
+ walk_history_forward(arg)
83
+ return
84
+ end
85
+ clear_prefix_anchor
86
+ super
87
+ end
88
+
89
+ # Returns the anchor buffer for prefix navigation: the frozen prefix
90
+ # during an ongoing session, +whole_buffer+ on the first press from
91
+ # base buffer, or +nil+ when nothing was typed.
92
+ #
93
+ # @private
94
+ # @return [String, nil]
95
+ def prefix_buffer_for_nav
96
+ return @prefix_buffer if @prefix_buffer
97
+
98
+ whole_buffer unless @history_pointer
99
+ end
100
+
101
+ # Checks if a key event triggers history navigation (up/down arrow).
102
+ #
103
+ # @private
104
+ # @param [Object] key A Reline key event.
105
+ # @return [Boolean]
106
+ def history_navigation_key?(key)
107
+ key.respond_to?(:method_symbol) &&
108
+ %i[ed_prev_history ed_next_history].include?(key.method_symbol)
109
+ end
110
+
111
+ # Clears ghost from the previous frame: inline text from buffer end
112
+ # to end of line, and each extra line below. Restores cursor column
113
+ # afterwards so Reline's cursor tracking is not disturbed.
114
+ #
115
+ # @private
116
+ # @return [void]
117
+ def clear_previous_ghost
118
+ clear_inline_ghost
119
+ return unless @ghost_line_count&.positive?
120
+
121
+ origin_column = cursor_column_at_byte_pointer
122
+ output = Reline.core.instance_variable_get(:@output)
123
+ @ghost_line_count.times { output.write("\e[1B\e[2K") }
124
+ output.write("\e[#{@ghost_line_count}A")
125
+ output.write("\e[0G\e[#{origin_column}C")
126
+ end
127
+
128
+ # Clears inline ghost text from the buffer end to end of line.
129
+ #
130
+ # @private
131
+ # @return [void]
132
+ def clear_inline_ghost
133
+ return unless @has_inline_ghost
134
+
135
+ output = Reline.core.instance_variable_get(:@output)
136
+ buf_end = move_to_buffer_end
137
+ origin_column = cursor_column_at_byte_pointer
138
+ output.write("#{buf_end}\e[K")
139
+ output.write("\e[0G\e[#{origin_column}C")
140
+ @has_inline_ghost = false
141
+ end
142
+
143
+ # Walks backward through history, loading each matching entry.
144
+ #
145
+ # @private
146
+ # @param [String] buffer The prefix anchor.
147
+ # @param [Integer] arg Repeat count.
148
+ # @return [Object]
149
+ def walk_history_back(buffer, arg)
150
+ arg.times do
151
+ pointer = find_prev_match(buffer, @history_pointer)
152
+ break unless pointer
153
+
154
+ move_history(pointer, line: :end, cursor: :end)
155
+ @prefix_buffer = buffer
156
+ end
157
+ end
158
+
159
+ # Walks forward through history. Returns to base when exhausted.
160
+ #
161
+ # @private
162
+ # @param [Integer] arg Repeat count.
163
+ # @return [Object]
164
+ def walk_history_forward(arg)
165
+ arg.times do
166
+ pointer = find_next_match(@prefix_buffer, @history_pointer)
167
+ if pointer
168
+ move_history(pointer, line: :start, cursor: :end)
169
+ else
170
+ move_history(Reline::HISTORY.size, line: :start, cursor: :end)
171
+ break
172
+ end
173
+ end
174
+ end
175
+
176
+ # Removes the prefix anchor so the next up/down starts fresh.
177
+ #
178
+ # @private
179
+ # @return [void]
180
+ def clear_prefix_anchor
181
+ return unless instance_variable_defined?(:@prefix_buffer)
182
+
183
+ remove_instance_variable(:@prefix_buffer)
184
+ end
185
+
186
+ # Whether prefix-filtered history navigation is enabled.
187
+ # Falls back to the value of +enabled?+ when not explicitly set.
188
+ #
189
+ # @private
190
+ # @return [Boolean]
191
+ def navigation_enabled?
192
+ case ENV.fetch(ENV_NAV_KEY, nil)
193
+ when '0' then false
194
+ when '1' then true
195
+ else
196
+ val = IRB.conf[CONFIG_NAV_KEY]
197
+ val.nil? ? enabled? : val
198
+ end
49
199
  end
50
200
 
51
201
  # Checks whether autosuggestions are enabled via IRB.conf or env var.
@@ -56,7 +206,9 @@ module Irb
56
206
  case ENV.fetch(ENV_KEY, nil)
57
207
  when '0' then false
58
208
  when '1' then true
59
- else IRB.conf.fetch(CONFIG_KEY, true)
209
+ else
210
+ val = IRB.conf[CONFIG_KEY]
211
+ val.nil? || val
60
212
  end
61
213
  end
62
214
 
@@ -70,69 +222,81 @@ module Irb
70
222
  key.method_symbol == :ed_next_char
71
223
  end
72
224
 
73
- # Computes the ghost text for a given buffer by finding the matching history entry.
225
+ # Finds the index of the previous (older) history entry starting with +buffer+.
74
226
  #
75
227
  # @private
76
- # @param [String] buffer The current whole buffer.
77
- # @return [String, nil] The remaining text of the suggestion, or nil.
78
- def ghost_for(buffer)
79
- suggestion = find_suggestion(buffer)
80
- return unless suggestion
228
+ # @param [String] buffer Search prefix.
229
+ # @param [Integer, nil] from_pointer Current history pointer (nil = base buffer).
230
+ # @return [Integer, nil]
231
+ def find_prev_match(buffer, from_pointer)
232
+ start_idx = (from_pointer || Reline::HISTORY.size) - 1
233
+ return nil if start_idx.negative?
81
234
 
82
- ghost = suggestion[buffer.size..]
83
- return if ghost.nil? || ghost.empty?
235
+ start_idx.downto(0) do |i|
236
+ entry = Reline::HISTORY[i]
237
+ next if entry.nil? || (dedup?(buffer) && duplicate_of_newer?(i, entry))
84
238
 
85
- ghost
239
+ return i if entry.start_with?(buffer)
240
+ end
241
+ nil
86
242
  end
87
243
 
88
- # Writes the ghost text (inline + extra lines) to terminal output.
244
+ # Finds the index of the next (newer) history entry starting with +buffer+.
89
245
  #
90
246
  # @private
91
- # @param [String] ghost The full ghost text (may contain newlines).
92
- # @return [void]
93
- def render_ghost(ghost)
94
- lines = ghost.split("\n")
247
+ # @param [String] buffer Search prefix.
248
+ # @param [Integer, nil] from_pointer Current history pointer.
249
+ # @return [Integer, nil]
250
+ def find_next_match(buffer, from_pointer) # rubocop:disable Metrics/CyclomaticComplexity
251
+ return nil unless from_pointer
95
252
 
96
- Reline.core.instance_variable_get(:@output).write("#{GRAY}#{lines.first}#{RESET}") unless lines.first.empty?
253
+ start_idx = from_pointer + 1
254
+ return nil if start_idx > Reline::HISTORY.size - 1
97
255
 
98
- write_extra_ghost_lines(lines.drop(1))
99
- restore_cursor_after(lines)
256
+ (start_idx...Reline::HISTORY.size).each do |i|
257
+ entry = Reline::HISTORY[i]
258
+ next if entry.nil? || (dedup?(buffer) && duplicate_of_newer?(i, entry))
100
259
 
101
- Reline.core.instance_variable_get(:@output).flush
260
+ return i if entry.start_with?(buffer)
261
+ end
262
+ nil
102
263
  end
103
264
 
104
- # Writes extra ghost lines below the current buffer line with prompt-width alignment.
265
+ # Whether duplicate collapsing is active (only for prefix search).
105
266
  #
106
267
  # @private
107
- # @param [Array<String>] lines Extra ghost lines (excluding the first inline line).
108
- # @return [void]
109
- def write_extra_ghost_lines(lines)
110
- return if lines.empty?
111
-
112
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
113
- output = Reline.core.instance_variable_get(:@output)
268
+ # @param [String] buffer Search prefix.
269
+ # @return [Boolean]
270
+ def dedup?(buffer)
271
+ !buffer.empty?
272
+ end
114
273
 
115
- lines.each do |line|
116
- output.write("\n\e[K")
117
- output.write("\e[#{prompt_width}C") if prompt_width.positive?
118
- output.write("#{GRAY}#{line}#{RESET}")
119
- end
274
+ # Whether +entry+ at index +idx+ is a consecutive duplicate of the next entry.
275
+ #
276
+ # @private
277
+ # @param [Integer] idx
278
+ # @param [String] entry
279
+ # @return [Boolean]
280
+ def duplicate_of_newer?(idx, entry)
281
+ idx < Reline::HISTORY.size - 1 && entry == Reline::HISTORY[idx + 1]
120
282
  end
121
283
 
122
- # Restores the cursor to the end of the buffer after ghost rendering.
284
+ # Renders ghost text for the current buffer, if a suggestion exists.
123
285
  #
124
286
  # @private
125
- # @param [Array<String>] lines The ghost split into lines.
126
287
  # @return [void]
127
- def restore_cursor_after(lines)
128
- extra_count = lines.size - 1
129
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
130
- pos = prompt_width + (@buffer_of_lines[@line_index] || '').length
131
- output = Reline.core.instance_variable_get(:@output)
288
+ def render_ghost_suggestion
289
+ buffer = whole_buffer
290
+ @ghost_line_count = 0
291
+ return if buffer.empty?
132
292
 
133
- output.write("\e[#{extra_count}A") if extra_count.positive?
134
- output.write("\e[0G")
135
- output.write("\e[#{pos}C")
293
+ suggestion = find_suggestion(buffer)
294
+ return unless suggestion
295
+
296
+ ghost = suggestion[buffer.size..]
297
+ return if ghost.nil? || ghost.empty?
298
+
299
+ render_ghost(ghost, suggestion)
136
300
  end
137
301
 
138
302
  # Finds the most recent history entry that starts with the given buffer.
@@ -158,6 +322,166 @@ module Irb
158
322
  @byte_pointer = sug_lines.last.bytesize
159
323
  rerender
160
324
  end
325
+
326
+ # Writes the ghost text (inline + extra lines) to terminal output.
327
+ # Saves and restores cursor column so Reline's cursor tracking
328
+ # (used for left/right arrow positioning) is not disturbed.
329
+ #
330
+ # If +suggestion+ is provided and colorization is enabled, the ghost
331
+ # is rendered with syntax highlighting via IRB::Color.
332
+ #
333
+ # @private
334
+ # @param [String] ghost The ghost text (suffix of the suggestion).
335
+ # @param [String, nil] suggestion The full matching history entry.
336
+ # @return [void]
337
+ def render_ghost(ghost, suggestion = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
338
+ output = Reline.core.instance_variable_get(:@output)
339
+ display_lines = ghost_display_lines(ghost, suggestion)
340
+ @ghost_line_count = display_lines.size - 1
341
+ @has_inline_ghost = true
342
+
343
+ output.write(move_to_buffer_end)
344
+ first_line = display_lines.first
345
+ output.write(first_line) if first_line && !first_line.empty?
346
+ write_extra_ghost_lines(display_lines.drop(1))
347
+ origin_column = cursor_column_at_byte_pointer
348
+ output.write("\e[#{@ghost_line_count}A") if @ghost_line_count.positive?
349
+ output.write("\e[0G\e[#{origin_column}C")
350
+ output.flush
351
+ end
352
+
353
+ # Returns ghost lines ready for terminal output (with ANSI codes).
354
+ #
355
+ # When colorization is enabled, the full suggestion is colorized via
356
+ # IRB::Color and the ghost portion is extracted from the colored output.
357
+ # Otherwise, each line is wrapped in GRAY/RESET.
358
+ #
359
+ # @private
360
+ # @param [String] ghost The ghost text (suffix of the suggestion).
361
+ # @param [String, nil] suggestion The full matching history entry.
362
+ # @raise [StandardError]
363
+ # @return [Array<String>]
364
+ def ghost_display_lines(ghost, suggestion)
365
+ if suggestion && use_colorize?
366
+ colorize_ghost_lines(ghost, suggestion)
367
+ else
368
+ ghost.split("\n").map { |line| "#{GRAY}#{line}#{RESET}" }
369
+ end
370
+ rescue StandardError
371
+ ghost.split("\n").map { |line| "#{GRAY}#{line}#{RESET}" }
372
+ end
373
+
374
+ # Checks whether syntax coloring is available and enabled.
375
+ #
376
+ # @private
377
+ # @return [Boolean]
378
+ def use_colorize?
379
+ defined?(IRB::Color) &&
380
+ IRB::Color.colorable? &&
381
+ IRB.conf.fetch(:USE_COLORIZE, true)
382
+ end
383
+
384
+ # Colorizes the full suggestion and extracts the ghost portion.
385
+ #
386
+ # @private
387
+ # @param [String] ghost The ghost text (suffix of the suggestion).
388
+ # @param [String] suggestion The full matching history entry.
389
+ # @return [Array<String>] Colorized ghost lines with ANSI codes.
390
+ def colorize_ghost_lines(ghost, suggestion)
391
+ colored = IRB::Color.colorize_code(suggestion)
392
+ ghost_byte_start = suggestion.bytesize - ghost.bytesize
393
+ colored_ghost = extract_ansi_colored_suffix(colored, ghost_byte_start)
394
+ colored_ghost.split("\n").map { |line| dim_line(line) }
395
+ end
396
+
397
+ # Prepends each ANSI foreground color code with +2;+ (dim)
398
+ # and strips non-color attributes (bold, underline, reverse…).
399
+ # Inner full resets are replaced with +RESET_COLOR+ so dim
400
+ # stays active across token boundaries.
401
+ #
402
+ # @private
403
+ # @param [String] line ANSI-colored line.
404
+ # @return [String] Dimmed ANSI-colored line.
405
+ def dim_line(line)
406
+ inner = line.gsub(/\e\[(\d+(?:;\d+)*)m/) do
407
+ params = Regexp.last_match(1)
408
+ next RESET_COLOR if params == '0'
409
+
410
+ color = params.split(';').map(&:to_i).select { |p| FG_COLORS.include?(p) }
411
+ color.empty? ? '' : "\e[2;#{color.join(';')}m"
412
+ end
413
+ "#{DIM}#{inner}#{RESET}"
414
+ end
415
+
416
+ # Extracts the suffix of an ANSI-colored string starting at a given
417
+ # visible byte offset, preserving all ANSI codes.
418
+ #
419
+ # @private
420
+ # @param [String] colored_text Text with embedded ANSI escape sequences.
421
+ # @param [Integer] visible_byte_offset Offset in visible (non-ANSI) bytes.
422
+ # @return [String]
423
+ def extract_ansi_colored_suffix(colored_text, visible_byte_offset) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
424
+ pos = 0
425
+ visible = 0
426
+ pending_code = nil
427
+
428
+ while visible < visible_byte_offset && pos < colored_text.length
429
+ if colored_text[pos] == "\e"
430
+ code_start = pos
431
+ pos = colored_text.index('m', pos)&.succ || colored_text.length
432
+ code = colored_text[code_start...pos]
433
+
434
+ pending_code = [RESET, "\e[m"].include?(code) ? nil : code
435
+ else
436
+ visible += 1
437
+ pos += 1
438
+ end
439
+ end
440
+
441
+ suffix = colored_text[pos..] || String.new
442
+ pending_code ? "#{pending_code}#{suffix}" : suffix
443
+ end
444
+
445
+ # Writes extra ghost lines below the current buffer line with prompt-width alignment.
446
+ #
447
+ # @private
448
+ # @param [Array<String>] lines Extra lines with ANSI codes (excluding first inline line).
449
+ # @return [void]
450
+ def write_extra_ghost_lines(lines)
451
+ return if lines.empty?
452
+
453
+ prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
454
+ output = Reline.core.instance_variable_get(:@output)
455
+
456
+ lines.each do |line|
457
+ output.write("\n\e[K")
458
+ output.write("\e[#{prompt_width}C") if prompt_width.positive?
459
+ output.write(line)
460
+ end
461
+ end
462
+
463
+ # Returns the terminal column where the cursor should sit (after the
464
+ # prompt and currently typed text at +@byte_pointer+).
465
+ #
466
+ # @private
467
+ # @return [Integer]
468
+ def cursor_column_at_byte_pointer
469
+ prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
470
+ current_line = @buffer_of_lines[@line_index] || ''
471
+ prompt_width + Reline::Unicode.calculate_width(current_line[0...@byte_pointer])
472
+ end
473
+
474
+ # Moves cursor to the end of the buffer line (for writing inline ghost).
475
+ #
476
+ # @private
477
+ # @return [String]
478
+ def move_to_buffer_end
479
+ prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
480
+ current_line = @buffer_of_lines[@line_index] || ''
481
+ buf_end = prompt_width + Reline::Unicode.calculate_width(current_line)
482
+ "\e[0G\e[#{buf_end}C"
483
+ end
161
484
  end
162
485
  end
163
486
  end
487
+ # rubocop:enable Metrics/ModuleLength, SortedMethodsByCall/Waterfall
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Irb
4
4
  module Autosuggestions
5
- VERSION = '0.1.1'
5
+ VERSION = '0.2.1'
6
6
  end
7
7
  end
@@ -93,10 +93,6 @@ gems:
93
93
  version: 4.0.2
94
94
  source:
95
95
  type: rubygems
96
- - name: rdoc
97
- version: '0'
98
- source:
99
- type: stdlib
100
96
  - name: regexp_parser
101
97
  version: '2.8'
102
98
  source:
@@ -1,10 +1,15 @@
1
1
  module Irb
2
2
  module Autosuggestions
3
3
  module LineEditorPatch
4
+ CONFIG_NAV_KEY: Symbol
5
+ DIM: String
6
+ ENV_NAV_KEY: String
7
+ FG_COLORS: Array[Integer]
4
8
  GRAY: String
5
9
  RESET: String
6
10
  CONFIG_KEY: Symbol
7
11
  ENV_KEY: String
12
+ RESET_COLOR: String
8
13
 
9
14
  @buffer_of_lines: Array[String]
10
15
  @byte_pointer: Integer
@@ -13,9 +18,12 @@ module Irb
13
18
  def input_key: (untyped key) -> untyped
14
19
  def render: (*untyped) -> untyped
15
20
  def enabled?: () -> bool
21
+ def use_colorize?: () -> bool
16
22
  def right_arrow?: (untyped key) -> bool
17
- def ghost_for: (String buffer) -> (String | nil)
18
- def render_ghost: (String ghost) -> void
23
+ def render_ghost: (String ghost, ?String suggestion) -> void
24
+ def ghost_display_lines: (String ghost, ?String suggestion) -> Array[String]
25
+ def colorize_ghost_lines: (String ghost, String suggestion) -> Array[String]
26
+ def extract_ansi_colored_suffix: (String colored_text, Integer visible_byte_offset) -> String
19
27
  def write_extra_ghost_lines: (Array[String] lines) -> void
20
28
  def restore_cursor_after: (Array[String] lines) -> void
21
29
  def find_suggestion: (String buffer) -> (String | nil)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: irb-autosuggestions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - unurgunite
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-23 00:00:00.000000000 Z
11
+ date: 2026-05-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: reline
@@ -52,20 +52,6 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: rbs
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
55
  - !ruby/object:Gem::Dependency
70
56
  name: rspec
71
57
  requirement: !ruby/object:Gem::Requirement
@@ -162,6 +148,8 @@ files:
162
148
  - CODE_OF_CONDUCT.md
163
149
  - Gemfile
164
150
  - Gemfile.lock
151
+ - Gemfile_2_7
152
+ - Gemfile_2_7.lock
165
153
  - LICENSE.txt
166
154
  - README.md
167
155
  - Rakefile
@@ -191,14 +179,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
191
179
  requirements:
192
180
  - - ">="
193
181
  - !ruby/object:Gem::Version
194
- version: '3.0'
182
+ version: '2.7'
195
183
  required_rubygems_version: !ruby/object:Gem::Requirement
196
184
  requirements:
197
185
  - - ">="
198
186
  - !ruby/object:Gem::Version
199
187
  version: '0'
200
188
  requirements: []
201
- rubygems_version: 3.5.23
189
+ rubygems_version: 3.4.22
202
190
  signing_key:
203
191
  specification_version: 4
204
192
  summary: Fish-like autosuggestions for irb.