irb-autosuggestions 0.2.1 → 0.2.3

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: b9d8279c02d62499ce7d58d2b8c4c47c7f8289fa4e601b140106219ea4d0d472
4
- data.tar.gz: f35c372e76961a2b91fc65bdf1fe94e6ec6daa7fec810461b49eb8dd11c34379
3
+ metadata.gz: 4707ca01caa7501953cc12ea344aa7b4ebcc818c3e505a72f5f102f5e702e215
4
+ data.tar.gz: 343171aa866561a3e4af8502df4f02a45ba4df53b45487da08a78fa9d9dad764
5
5
  SHA512:
6
- metadata.gz: 838a7272d857247def598a2989cd8c43eb35b8272183ce3623c25c53faa8e3bb2c375ed3b0006ec9942c2a34537e8fdc644961ab61477dfd86118fbc9f4ccf33
7
- data.tar.gz: 85643f9c47045b2accbceb5a0f84b4eb13ae988b39550a541a21e320fec2f81d75e56128a73a59a9ab5061f0ccce1a2c8e984fcebb8b5a083b2927fcd0c6171f
6
+ metadata.gz: 8921326d3b52cba86e73a10fc8ca7eb88322897ad4296dafe2e34e2cd440c88de61086fb522ebf1a06367ec380dc6f1bb2f08afe420347a152d43bb68f393207
7
+ data.tar.gz: 3596a6d3c5a74336f68a21c1f13bff4e664ed2fbd3bd1728e84023a37a2bc2e64dd3ee74b153b810c253f8d6f85f61e5fc962c57bf9927afad4ada3037e77fc1
data/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ # Changelog
2
+
3
+ ## [0.2.3] - 2026-08-21
4
+
5
+ ### Fixed
6
+
7
+ - Text duplication and stale ghost fragments when typing lines that wrap past the terminal edge; ghost preview is now
8
+ hidden for suggestions that do not fit the current row instead of corrupting the display
9
+ - Dim/color state leaking into subsequent input after accepting a suggestion
10
+
11
+ ## [0.2.2] - 2026-07-08
12
+
13
+ ### Added
14
+
15
+ - Alternative key bindings to accept ghost suggestion: Tab, Ctrl+F, Ctrl+E (configurable via
16
+ `IRB.conf[:AUTOSUGGESTION_ACCEPT_KEYS]`) ([#15], [#157])
17
+ - Configurable ghost color/style via `IRB.conf[:AUTOSUGGESTION_GHOST_COLOR]` (ANSI string) and
18
+ `IRB.conf[:AUTOSUGGESTION_GHOST_STYLE]` (declarative hash) ([#16], [#163])
19
+ - GitHub issue templates (bug report, feature request) and pull request template
20
+
21
+ ### Fixed
22
+
23
+ - Ghost text artifacts and cursor misalignment after accepting multiline (10+ lines) suggestions ([#19], [#256])
24
+ - Auto-execute guard — verified Enter always fires exactly once regardless of ghost visibility ([#18], [#159])
25
+
26
+ ## [0.2.1] - 2026-07-05
27
+
28
+ ### Fixed
29
+
30
+ - Ruby 4.0 compatibility: arrow (`ed_next_char`) not erasing after suggestion accept ([#13])
31
+
32
+ ## [0.2.0] - 2026-06-30
33
+
34
+ ### Added
35
+
36
+ - Syntax-colored ghost text via `IRB::Color.colorize_code` (dimmed)
37
+ - Prefix-filtered history navigation (up/down arrows match typed prefix)
38
+ - Ruby 2.7 support
39
+
40
+ ### Changed
41
+
42
+ - Ghost text now uses dim syntax colors when `IRB.conf[:USE_COLORIZE]` is enabled
43
+
44
+ ## [0.1.1] - 2026-06-25
45
+
46
+ ### Added
47
+
48
+ - `IRB.conf[:USE_AUTOSUGGESTIONS]` toggle
49
+ - Environment variable `IRB_AUTOSUGGESTIONS` override
50
+ - RBS type signatures
51
+
52
+ ### Fixed
53
+
54
+ - Gem metadata (licenses, homepage)
55
+
56
+ ## [0.1.0] - 2026-06-20
57
+
58
+ ### Added
59
+
60
+ - Initial release: fish-like autosuggestions from `Reline::HISTORY`
61
+ - Right arrow key accepts ghost suggestion
62
+ - Gray ghost text (`\e[90m`)
63
+ - Configuration via `IRB.conf[:USE_AUTOSUGGESTIONS]`
64
+
65
+ [#15]: https://github.com/unurgunite/irb-autosuggestions/pull/15
66
+
67
+ [#16]: https://github.com/unurgunite/irb-autosuggestions/pull/16
68
+
69
+ [#18]: https://github.com/unurgunite/irb-autosuggestions/pull/18
70
+
71
+ [#19]: https://github.com/unurgunite/irb-autosuggestions/pull/19
72
+
73
+ [#157]: https://github.com/unurgunite/irb-autosuggestions/issues/157
74
+
75
+ [#163]: https://github.com/unurgunite/irb-autosuggestions/issues/163
76
+
77
+ [#159]: https://github.com/unurgunite/irb-autosuggestions/issues/159
78
+
79
+ [#256]: https://github.com/unurgunite/irb-autosuggestions/issues/256
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- irb-autosuggestions (0.2.1)
4
+ irb-autosuggestions (0.2.3)
5
5
  reline
6
6
 
7
7
  GEM
@@ -9,16 +9,16 @@ GEM
9
9
  specs:
10
10
  ast (2.4.3)
11
11
  diff-lcs (1.6.2)
12
- docscribe (1.3.3)
12
+ docscribe (1.6.0)
13
13
  parser (>= 3.3)
14
14
  prism (~> 1.8)
15
- io-console (0.8.2)
16
- json (2.19.5)
17
- language_server-protocol (3.17.0.5)
15
+ io-console (0.9.2)
16
+ json (2.21.2)
17
+ language_server-protocol (3.17.0.6)
18
18
  lint_roller (1.1.0)
19
19
  logger (1.7.0)
20
20
  parallel (1.28.0)
21
- parser (3.3.11.1)
21
+ parser (3.3.12.0)
22
22
  ast (~> 2.4.1)
23
23
  racc
24
24
  prism (1.9.0)
@@ -28,7 +28,7 @@ GEM
28
28
  rbs (3.6.1)
29
29
  logger
30
30
  regexp_parser (2.12.0)
31
- reline (0.6.3)
31
+ reline (0.7.0)
32
32
  io-console (~> 0.5)
33
33
  rspec (3.13.2)
34
34
  rspec-core (~> 3.13.0)
@@ -43,7 +43,7 @@ GEM
43
43
  diff-lcs (>= 1.2.0, < 2.0)
44
44
  rspec-support (~> 3.13.0)
45
45
  rspec-support (3.13.7)
46
- rubocop (1.86.2)
46
+ rubocop (1.89.0)
47
47
  json (~> 2.3)
48
48
  language_server-protocol (~> 3.17.0.2)
49
49
  lint_roller (~> 1.1.0)
@@ -54,23 +54,24 @@ GEM
54
54
  rubocop-ast (>= 1.49.0, < 2.0)
55
55
  ruby-progressbar (~> 1.7)
56
56
  unicode-display_width (>= 2.4.0, < 4.0)
57
- rubocop-ast (1.49.1)
57
+ rubocop-ast (1.50.0)
58
58
  parser (>= 3.3.7.2)
59
59
  prism (~> 1.7)
60
60
  rubocop-rake (0.7.1)
61
61
  lint_roller (~> 1.1)
62
62
  rubocop (>= 1.72.1)
63
- rubocop-rspec (3.9.0)
63
+ rubocop-rspec (3.10.2)
64
64
  lint_roller (~> 1.1)
65
- rubocop (~> 1.81)
66
- rubocop-sorted_methods_by_call (1.2.2)
65
+ regexp_parser (>= 2.0)
66
+ rubocop (~> 1.86, >= 1.86.2)
67
+ rubocop-sorted_methods_by_call (1.2.3)
67
68
  lint_roller
68
69
  rubocop (>= 1.72.0)
69
70
  ruby-progressbar (1.13.0)
70
71
  unicode-display_width (3.2.0)
71
72
  unicode-emoji (~> 4.1)
72
73
  unicode-emoji (4.2.0)
73
- yard (0.9.44)
74
+ yard (0.9.45)
74
75
 
75
76
  PLATFORMS
76
77
  arm64-darwin-24
@@ -91,4 +92,4 @@ DEPENDENCIES
91
92
  yard (>= 0.9.38)
92
93
 
93
94
  BUNDLED WITH
94
- 4.0.11
95
+ 2.4.22
data/Gemfile_2_7.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- irb-autosuggestions (0.2.1)
4
+ irb-autosuggestions (0.2.3)
5
5
  reline
6
6
 
7
7
  GEM
@@ -9,15 +9,15 @@ GEM
9
9
  specs:
10
10
  ast (2.4.3)
11
11
  diff-lcs (1.6.2)
12
- docscribe (1.3.3)
12
+ docscribe (1.6.0)
13
13
  parser (>= 3.3)
14
14
  prism (~> 1.8)
15
- io-console (0.8.2)
16
- json (2.19.5)
17
- language_server-protocol (3.17.0.5)
15
+ io-console (0.9.2)
16
+ json (2.21.2)
17
+ language_server-protocol (3.17.0.6)
18
18
  lint_roller (1.1.0)
19
19
  parallel (1.28.0)
20
- parser (3.3.11.1)
20
+ parser (3.3.12.0)
21
21
  ast (~> 2.4.1)
22
22
  racc
23
23
  prism (1.9.0)
@@ -25,7 +25,7 @@ GEM
25
25
  rainbow (3.1.1)
26
26
  rake (13.4.2)
27
27
  regexp_parser (2.12.0)
28
- reline (0.6.3)
28
+ reline (0.7.0)
29
29
  io-console (~> 0.5)
30
30
  rspec (3.13.2)
31
31
  rspec-core (~> 3.13.0)
@@ -40,7 +40,7 @@ GEM
40
40
  diff-lcs (>= 1.2.0, < 2.0)
41
41
  rspec-support (~> 3.13.0)
42
42
  rspec-support (3.13.7)
43
- rubocop (1.86.2)
43
+ rubocop (1.89.0)
44
44
  json (~> 2.3)
45
45
  language_server-protocol (~> 3.17.0.2)
46
46
  lint_roller (~> 1.1.0)
@@ -51,23 +51,24 @@ GEM
51
51
  rubocop-ast (>= 1.49.0, < 2.0)
52
52
  ruby-progressbar (~> 1.7)
53
53
  unicode-display_width (>= 2.4.0, < 4.0)
54
- rubocop-ast (1.49.1)
54
+ rubocop-ast (1.50.0)
55
55
  parser (>= 3.3.7.2)
56
56
  prism (~> 1.7)
57
57
  rubocop-rake (0.7.1)
58
58
  lint_roller (~> 1.1)
59
59
  rubocop (>= 1.72.1)
60
- rubocop-rspec (3.9.0)
60
+ rubocop-rspec (3.10.2)
61
61
  lint_roller (~> 1.1)
62
- rubocop (~> 1.81)
63
- rubocop-sorted_methods_by_call (1.2.2)
62
+ regexp_parser (>= 2.0)
63
+ rubocop (~> 1.86, >= 1.86.2)
64
+ rubocop-sorted_methods_by_call (1.2.3)
64
65
  lint_roller
65
66
  rubocop (>= 1.72.0)
66
67
  ruby-progressbar (1.13.0)
67
68
  unicode-display_width (3.2.0)
68
69
  unicode-emoji (~> 4.1)
69
70
  unicode-emoji (4.2.0)
70
- yard (0.9.44)
71
+ yard (0.9.45)
71
72
 
72
73
  PLATFORMS
73
74
  arm64-darwin-25
data/README.md CHANGED
@@ -18,7 +18,9 @@ No need to explain. Fish-like autosuggestions for IRB — ghost text from histor
18
18
  * [Usage](#usage)
19
19
  * [Prefix-filtered history navigation](#prefix-filtered-history-navigation)
20
20
  * [Configuration](#configuration)
21
+ * [Ghost color](#ghost-color)
21
22
  * [Colors](#colors)
23
+ * [Accept keys](#accept-keys)
22
24
  * [How it works](#how-it-works)
23
25
  * [Development](#development)
24
26
  * [License](#license)
@@ -48,7 +50,8 @@ irb(main):002* el.succ <- "cc" in gray
48
50
  irb(main):003> end <- "d" in gray
49
51
  ```
50
52
 
51
- Press **right arrow** (`->`) to accept the full multiline suggestion.
53
+ Press **right arrow** (`->`), **Tab**, **Ctrl+F**, or **Ctrl+E** to accept the full multiline suggestion. Tab falls
54
+ back to normal completion when no suggestion is visible. Accept keys are configurable (see below).
52
55
 
53
56
  ### Prefix-filtered history navigation
54
57
 
@@ -96,6 +99,29 @@ Or via environment variable:
96
99
  export IRB_AUTOSUGGESTIONS=0
97
100
  ```
98
101
 
102
+ ### Ghost color
103
+
104
+ The plain gray fallback color can be customized. Syntax-colored ghost text (when `IRB::Color` is available) always
105
+ takes precedence over these settings.
106
+
107
+ Set a raw ANSI escape code:
108
+
109
+ ```ruby
110
+ IRB.conf[:AUTOSUGGESTION_GHOST_COLOR] = "\e[32m" # green
111
+ ```
112
+
113
+ Or use the declarative style hash:
114
+
115
+ ```ruby
116
+ IRB.conf[:AUTOSUGGESTION_GHOST_STYLE] = { fg: :bright_black, italic: true }
117
+ ```
118
+
119
+ Supported colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `default`,
120
+ `bright_black`, `bright_red`, `bright_green`, `bright_yellow`, `bright_blue`, `bright_magenta`,
121
+ `bright_cyan`, `bright_white`.
122
+
123
+ Supported attributes: `bold`, `dim`, `italic`, `underline`, `blink`, `reverse`.
124
+
99
125
  ### Colors
100
126
 
101
127
  Syntax-colored ghost text is **enabled by default** when `IRB::Color` is available and `IRB.conf[:USE_COLORIZE]` is
@@ -118,6 +144,19 @@ irb --nocolorize
118
144
  > schemes. If you notice visual artifacts (e.g., wrong colors, underlines, or unusual brightness), try disabling the
119
145
  > feature or switch to the gray fallback or create new issue.
120
146
 
147
+ ### Accept keys
148
+
149
+ Configure which keys accept the ghost suggestion:
150
+
151
+ ```ruby
152
+ IRB.conf[:AUTOSUGGESTION_ACCEPT_KEYS] = %i[ed_next_char tab ed_end_of_line]
153
+ ```
154
+
155
+ Defaults: `[:ed_next_char, :ed_end_of_line, :tab]` (right arrow/Ctrl+F, Ctrl+E, Tab).
156
+
157
+ Available symbols: `:ed_next_char` (right arrow/Ctrl+F), `:ed_end_of_line` (Ctrl+E), `:tab`, or
158
+ any Reline method symbol. When no suggestion is visible, the key falls through to its normal behavior.
159
+
121
160
  ### How it works
122
161
 
123
162
  - Each keystroke queries `Reline::HISTORY` for the most recent entry whose prefix matches the current buffer.
@@ -127,7 +166,7 @@ irb --nocolorize
127
166
  - Extra ghost lines (for multiline history entries) are drawn below the prompt.
128
167
  - Ghost artifacts are cleared each frame using cursor save/restore (`\e[s`/`\e[u`) and per-line clearing (`\e[2K`),
129
168
  avoiding `\e[J` which interfered with Reline's cursor tracking.
130
- - Right arrow triggers `ed_next_char`, which replaces the buffer with the ghost text.
169
+ - Configurable accept keys (right arrow, Tab, Ctrl+F, Ctrl+E by default) replace the buffer with the ghost text.
131
170
  - Up/down arrows use prefix-filtered navigation when the buffer is non-empty, falling back to unfiltered browsing
132
171
  when nothing was typed.
133
172
 
@@ -14,40 +14,66 @@ module Irb
14
14
  ENV_KEY = 'IRB_AUTOSUGGESTIONS'
15
15
  CONFIG_NAV_KEY = :USE_PREFIX_HISTORY_NAVIGATION
16
16
  ENV_NAV_KEY = 'IRB_PREFIX_HISTORY_NAVIGATION'
17
-
18
- # Intercepts key input to accept autosuggestions on right arrow
17
+ ACCEPT_KEYS_CONFIG = :AUTOSUGGESTION_ACCEPT_KEYS
18
+ DEFAULT_ACCEPT_KEYS = %i[ed_next_char ed_end_of_line tab].freeze
19
+ GHOST_COLOR_CONFIG = :AUTOSUGGESTION_GHOST_COLOR
20
+ GHOST_STYLE_CONFIG = :AUTOSUGGESTION_GHOST_STYLE
21
+ DEFAULT_GHOST_COLOR = "\e[90m"
22
+ FG_MAP = {
23
+ black: 30, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37,
24
+ default: 39,
25
+ bright_black: 90, bright_red: 91, bright_green: 92, bright_yellow: 93,
26
+ bright_blue: 94, bright_magenta: 95, bright_cyan: 96, bright_white: 97
27
+ }.freeze
28
+ ATTR_MAP = {
29
+ bold: 1, dim: 2, italic: 3, underline: 4, blink: 5, reverse: 7
30
+ }.freeze
31
+
32
+ # Intercepts key input to accept autosuggestions on configured keys
19
33
  # and clears the prefix navigation anchor on non-history keys.
20
34
  #
21
35
  # @param [Object] key A Reline key event.
22
36
  # @return [Object]
23
37
  def input_key(key)
38
+ # Some keys (Enter, submission) bypass rerender, so ghost is cleared
39
+ # on every key to avoid stale ghost lines leftover on screen.
40
+ clear_previous_ghost if enabled?
41
+
24
42
  clear_prefix_anchor if navigation_enabled? && !history_navigation_key?(key)
25
43
 
26
- if enabled? && right_arrow?(key)
27
- buffer = whole_buffer
28
- suggestion = find_suggestion(buffer)
29
-
30
- if suggestion && suggestion != buffer
31
- accept_suggestion(suggestion)
32
- return
33
- end
34
- end
44
+ return if enabled? && accept_key?(key) && accept_matching_suggestion?
35
45
 
36
46
  super
37
47
  end
38
48
 
49
+ # Accepts the current suggestion when it extends the buffer.
50
+ #
51
+ # @return [Boolean] true when a suggestion was accepted
52
+ def accept_matching_suggestion?
53
+ buffer = whole_buffer
54
+ suggestion = find_suggestion(buffer)
55
+ return false unless suggestion && suggestion != buffer
56
+
57
+ accept_suggestion(suggestion)
58
+ true
59
+ end
60
+
39
61
  # Injects ghost text into terminal output after Reline finishes rendering.
40
62
  # Must be public because Reline::Core calls +rerender+ externally.
41
63
  # Works on both Reline 0.3.x (rerender is the main rendering entry) and
42
64
  # Reline 0.4+ (rerender calls render internally).
43
65
  #
66
+ # Ghost is cleared BEFORE super to avoid cursor-position-dependent
67
+ # escapes targeting wrong lines after accept_suggestion changes buffer.
68
+ #
44
69
  # @return [Object] The result of +super+.
45
70
  def rerender
71
+ clear_previous_ghost if enabled?
72
+
46
73
  result = super
47
- if enabled?
48
- clear_previous_ghost
49
- render_ghost_suggestion
50
- end
74
+
75
+ render_ghost_suggestion if enabled?
76
+
51
77
  result
52
78
  end
53
79
 
@@ -212,14 +238,34 @@ module Irb
212
238
  end
213
239
  end
214
240
 
215
- # Checks if a key event is a right arrow press.
241
+ # Checks if a key event matches any configured accept key.
242
+ # Defaults to right arrow (:ed_next_char).
243
+ # Tab is matched specially — method_symbol == :ed_insert + char == "\t".
216
244
  #
217
245
  # @private
218
246
  # @param [Object] key A Reline key event.
219
247
  # @return [Boolean]
220
- def right_arrow?(key)
221
- key.respond_to?(:method_symbol) &&
222
- key.method_symbol == :ed_next_char
248
+ def accept_key?(key)
249
+ accept_keys = IRB.conf.fetch(ACCEPT_KEYS_CONFIG, DEFAULT_ACCEPT_KEYS)
250
+ accept_keys.any? { |k| key_match?(key, k) }
251
+ end
252
+
253
+ # Checks if a key matches a given config symbol.
254
+ #
255
+ # @private
256
+ # @param [Object] key A Reline key event.
257
+ # @param [Symbol] config_symbol One of :ed_next_char, :ed_end_of_line, :tab, etc.
258
+ # @return [Boolean]
259
+ def key_match?(key, config_symbol)
260
+ return false unless key.respond_to?(:method_symbol)
261
+
262
+ if config_symbol == :tab
263
+ key.method_symbol == :ed_insert &&
264
+ key.respond_to?(:char) &&
265
+ key.char == "\t"
266
+ else
267
+ key.method_symbol == config_symbol
268
+ end
223
269
  end
224
270
 
225
271
  # Finds the index of the previous (older) history entry starting with +buffer+.
@@ -283,6 +329,13 @@ module Irb
283
329
 
284
330
  # Renders ghost text for the current buffer, if a suggestion exists.
285
331
  #
332
+ # The ghost is reduced to fit the terminal width so it never wraps
333
+ # onto the following row. A ghost that wraps corrupts Reline's
334
+ # single-row cursor model (duplicated text, stale ANSI state),
335
+ # because Reline does not track visual wrapping of long lines.
336
+ # Suggestions remain accept-able via the configured keys even when
337
+ # the ghost is truncated.
338
+ #
286
339
  # @private
287
340
  # @return [void]
288
341
  def render_ghost_suggestion
@@ -296,7 +349,99 @@ module Irb
296
349
  ghost = suggestion[buffer.size..]
297
350
  return if ghost.nil? || ghost.empty?
298
351
 
299
- render_ghost(ghost, suggestion)
352
+ render_ghost(truncate_ghost_to_terminal(ghost), suggestion, suggestion.bytesize - ghost.bytesize)
353
+ end
354
+
355
+ # Truncates ghost text so no visible line exceeds the terminal width:
356
+ # the inline part is capped at the space left on the current line,
357
+ # each following line at the space left after the prompt.
358
+ #
359
+ # @private
360
+ # @param [String] ghost The ghost text (suffix of the suggestion).
361
+ # @return [String]
362
+ def truncate_ghost_to_terminal(ghost)
363
+ inline_limit = terminal_width - current_line_width - 1
364
+ return String.new if inline_limit <= 0
365
+
366
+ lines = ghost.split("\n", -1)
367
+ truncate_to_width(lines.first, inline_limit) + truncate_rest_lines(lines)
368
+ end
369
+
370
+ # Truncates every ghost line after the first at the width left
371
+ # after the prompt, keeping line breaks intact.
372
+ #
373
+ # @private
374
+ # @param [Array<String>] lines
375
+ # @return [String]
376
+ def truncate_rest_lines(lines)
377
+ rest_limit = terminal_width - prompt_width - 1
378
+ rest = lines.drop(1).map { |line| truncate_to_width(line, rest_limit) }
379
+ rest.empty? ? String.new : "\n#{rest.join("\n")}"
380
+ end
381
+
382
+ # Display width of the first ghost line.
383
+ #
384
+ # @private
385
+ # Truncates a string to the given display width, appending an
386
+ # ellipsis when cut. Counts Unicode display width, so multi-column
387
+ # characters are handled correctly.
388
+ #
389
+ # @private
390
+ # @param [String] str
391
+ # @param [Integer] max_width
392
+ # @return [String]
393
+ def truncate_to_width(str, max_width)
394
+ return String.new if max_width.negative?
395
+ return str if max_width.zero? || Reline::Unicode.calculate_width(str) <= max_width
396
+
397
+ "#{chars_up_to(str, max_width - 1)}…"
398
+ end
399
+
400
+ # Characters of +str+ whose combined display width does not
401
+ # exceed +max_width+.
402
+ #
403
+ # @private
404
+ # @param [String] str
405
+ # @param [Integer] max_width
406
+ # @return [String]
407
+ def chars_up_to(str, max_width)
408
+ width = 0
409
+ str.each_char.with_object(+'') do |char, out|
410
+ char_width = Reline::Unicode.calculate_width(char)
411
+ break out if width + char_width > max_width
412
+
413
+ width += char_width
414
+ out << char
415
+ end
416
+ end
417
+
418
+ # Width of the prompt (in display columns).
419
+ #
420
+ # @private
421
+ # @return [Integer]
422
+ def prompt_width
423
+ @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
424
+ end
425
+
426
+ # Display width of the prompt plus the current buffer line.
427
+ #
428
+ # @private
429
+ # @return [Integer]
430
+ def current_line_width
431
+ line = @buffer_of_lines[@line_index] || ''
432
+ prompt_width + Reline::Unicode.calculate_width(line)
433
+ end
434
+
435
+ # Terminal width in columns, falling back to 80 when unknown.
436
+ #
437
+ # @private
438
+ # @return [Integer]
439
+ def terminal_width
440
+ if defined?(Reline::IOGate) && Reline::IOGate.respond_to?(:get_screen_size)
441
+ Reline::IOGate.get_screen_size[1]
442
+ else
443
+ 80
444
+ end
300
445
  end
301
446
 
302
447
  # Finds the most recent history entry that starts with the given buffer.
@@ -333,10 +478,12 @@ module Irb
333
478
  # @private
334
479
  # @param [String] ghost The ghost text (suffix of the suggestion).
335
480
  # @param [String, nil] suggestion The full matching history entry.
481
+ # @param [Integer, nil] ghost_byte_start Byte offset of the ghost within the
482
+ # full suggestion (pre-truncation), used to extract the colored suffix.
336
483
  # @return [void]
337
- def render_ghost(ghost, suggestion = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
484
+ def render_ghost(ghost, suggestion = nil, ghost_byte_start = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
338
485
  output = Reline.core.instance_variable_get(:@output)
339
- display_lines = ghost_display_lines(ghost, suggestion)
486
+ display_lines = ghost_display_lines(ghost, suggestion, ghost_byte_start)
340
487
  @ghost_line_count = display_lines.size - 1
341
488
  @has_inline_ghost = true
342
489
 
@@ -354,21 +501,47 @@ module Irb
354
501
  #
355
502
  # When colorization is enabled, the full suggestion is colorized via
356
503
  # IRB::Color and the ghost portion is extracted from the colored output.
357
- # Otherwise, each line is wrapped in GRAY/RESET.
358
504
  #
359
505
  # @private
360
506
  # @param [String] ghost The ghost text (suffix of the suggestion).
361
507
  # @param [String, nil] suggestion The full matching history entry.
508
+ # @param [Integer, nil] ghost_byte_start Byte offset of the ghost within the
509
+ # full suggestion (pre-truncation), used to extract the colored suffix.
362
510
  # @raise [StandardError]
363
511
  # @return [Array<String>]
364
- def ghost_display_lines(ghost, suggestion)
512
+ def ghost_display_lines(ghost, suggestion, ghost_byte_start = nil)
365
513
  if suggestion && use_colorize?
366
- colorize_ghost_lines(ghost, suggestion)
514
+ colorize_ghost_lines(ghost, suggestion, ghost_byte_start)
367
515
  else
368
- ghost.split("\n").map { |line| "#{GRAY}#{line}#{RESET}" }
516
+ ghost.split("\n").map { |line| "#{ghost_color}#{line}#{RESET}" }
369
517
  end
370
518
  rescue StandardError
371
- ghost.split("\n").map { |line| "#{GRAY}#{line}#{RESET}" }
519
+ ghost.split("\n").map { |line| "#{ghost_color}#{line}#{RESET}" }
520
+ end
521
+
522
+ # Returns the effective ANSI color code for ghost text.
523
+ # Checks GHOST_STYLE_CONFIG first, then GHOST_COLOR_CONFIG, then default.
524
+ #
525
+ # @private
526
+ # @return [String]
527
+ def ghost_color
528
+ style = IRB.conf[GHOST_STYLE_CONFIG]
529
+ return resolve_ghost_style(style) if style.is_a?(Hash)
530
+
531
+ IRB.conf.fetch(GHOST_COLOR_CONFIG, DEFAULT_GHOST_COLOR)
532
+ end
533
+
534
+ # Converts a style hash like +{ fg: :bright_black, italic: true }+
535
+ # to an ANSI escape sequence.
536
+ #
537
+ # @private
538
+ # @param [Hash] hash Style hash with +:fg+ color and optional attribute keys.
539
+ # @return [String]
540
+ def resolve_ghost_style(hash)
541
+ codes = []
542
+ codes << FG_MAP[hash[:fg]] if hash[:fg]
543
+ ATTR_MAP.each { |attr, code| codes << code if hash[attr] }
544
+ "\e[#{codes.join(';')}m"
372
545
  end
373
546
 
374
547
  # Checks whether syntax coloring is available and enabled.
@@ -383,15 +556,78 @@ module Irb
383
556
 
384
557
  # Colorizes the full suggestion and extracts the ghost portion.
385
558
  #
559
+ # +ghost_byte_start+ is the visible-byte offset of the ghost within
560
+ # the suggestion. It must come from the full (untruncated) ghost:
561
+ # using a truncated ghost shifts the offset into the middle of the
562
+ # suggestion and the colored suffix no longer matches the buffer.
563
+ # Colored lines are then truncated to fit the terminal width, same
564
+ # as the plain-text ghost.
565
+ #
386
566
  # @private
387
567
  # @param [String] ghost The ghost text (suffix of the suggestion).
388
568
  # @param [String] suggestion The full matching history entry.
569
+ # @param [Integer, nil] ghost_byte_start Visible-byte offset of the ghost.
389
570
  # @return [Array<String>] Colorized ghost lines with ANSI codes.
390
- def colorize_ghost_lines(ghost, suggestion)
571
+ def colorize_ghost_lines(ghost, suggestion, ghost_byte_start = nil)
391
572
  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) }
573
+ start = ghost_byte_start || (suggestion.bytesize - ghost.bytesize)
574
+ colored_ghost = extract_ansi_colored_suffix(colored, start)
575
+ truncate_colored_ghost(colored_ghost).map { |line| dim_line(line) }
576
+ end
577
+
578
+ # Truncates colorized ghost lines so no line exceeds the terminal
579
+ # width, mirroring +truncate_ghost_to_terminal+ for plain text.
580
+ #
581
+ # @private
582
+ # @param [String] colored_ghost ANSI-colored ghost text.
583
+ # @return [Array<String>]
584
+ def truncate_colored_ghost(colored_ghost)
585
+ lines = colored_ghost.split("\n")
586
+ rest_limit = terminal_width - prompt_width - 1
587
+ first = truncate_colored_to_width(lines.first, terminal_width - current_line_width - 1)
588
+ [first] + lines.drop(1).map { |line| truncate_colored_to_width(line, rest_limit) }
589
+ end
590
+
591
+ # Truncates an ANSI-colored line to the given display width, counting
592
+ # escape codes as zero-width, and appending an ellipsis when cut.
593
+ #
594
+ # @private
595
+ # @param [String] line ANSI-colored line.
596
+ # @param [Integer] max_width
597
+ # @return [String]
598
+ def truncate_colored_to_width(line, max_width)
599
+ return String.new if max_width <= 0
600
+ return line if Reline::Unicode.calculate_width(line, true) <= max_width
601
+
602
+ "#{colored_chars_up_to(line, max_width - 1)}…"
603
+ end
604
+
605
+ # Characters of +line+ (with ANSI codes preserved) whose combined
606
+ # display width does not exceed +max_width+.
607
+ #
608
+ # @private
609
+ # @param [String] line ANSI-colored line.
610
+ # @param [Integer] max_width
611
+ # @return [String]
612
+ def colored_chars_up_to(line, max_width) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
613
+ width = 0
614
+ out = +''
615
+ rest = line.dup
616
+ until rest.empty?
617
+ if rest.start_with?("\e")
618
+ code = rest[/\e\[[0-9;]*m/]
619
+ out << code
620
+ rest = code ? rest[code.length..] : ''
621
+ next
622
+ end
623
+ char = rest.each_char.first
624
+ break if width + Reline::Unicode.calculate_width(char) > max_width
625
+
626
+ width += Reline::Unicode.calculate_width(char)
627
+ out << char
628
+ rest = rest[char.length..]
629
+ end
630
+ out
395
631
  end
396
632
 
397
633
  # Prepends each ANSI foreground color code with +2;+ (dim)
@@ -450,7 +686,6 @@ module Irb
450
686
  def write_extra_ghost_lines(lines)
451
687
  return if lines.empty?
452
688
 
453
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
454
689
  output = Reline.core.instance_variable_get(:@output)
455
690
 
456
691
  lines.each do |line|
@@ -466,7 +701,6 @@ module Irb
466
701
  # @private
467
702
  # @return [Integer]
468
703
  def cursor_column_at_byte_pointer
469
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
470
704
  current_line = @buffer_of_lines[@line_index] || ''
471
705
  prompt_width + Reline::Unicode.calculate_width(current_line[0...@byte_pointer])
472
706
  end
@@ -476,10 +710,8 @@ module Irb
476
710
  # @private
477
711
  # @return [String]
478
712
  def move_to_buffer_end
479
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
480
713
  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"
714
+ "\e[0G\e[#{prompt_width + Reline::Unicode.calculate_width(current_line)}C"
483
715
  end
484
716
  end
485
717
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Irb
4
4
  module Autosuggestions
5
- VERSION = '0.2.1'
5
+ VERSION = '0.2.3'
6
6
  end
7
7
  end
data/readme.png CHANGED
Binary file
@@ -10,24 +10,39 @@ module Irb
10
10
  CONFIG_KEY: Symbol
11
11
  ENV_KEY: String
12
12
  RESET_COLOR: String
13
+ ACCEPT_KEYS_CONFIG: Symbol
14
+ DEFAULT_ACCEPT_KEYS: Array[Symbol]
15
+ GHOST_COLOR_CONFIG: Symbol
16
+ GHOST_STYLE_CONFIG: Symbol
17
+ DEFAULT_GHOST_COLOR: String
18
+ FG_MAP: Hash[Symbol, Integer]
19
+ ATTR_MAP: Hash[Symbol, Integer]
13
20
 
14
21
  @buffer_of_lines: Array[String]
15
22
  @byte_pointer: Integer
16
23
  @line_index: Integer
17
24
 
18
25
  def input_key: (untyped key) -> untyped
26
+ def rerender: () -> untyped
19
27
  def render: (*untyped) -> untyped
20
28
  def enabled?: () -> bool
29
+ def navigation_enabled?: () -> bool
21
30
  def use_colorize?: () -> bool
22
- def right_arrow?: (untyped key) -> bool
31
+ def accept_key?: (untyped key) -> bool
32
+ def key_match?: (untyped key, Symbol config_symbol) -> bool
33
+ def ghost_color: () -> String
34
+ def resolve_ghost_style: (Hash[Symbol, untyped] hash) -> String
35
+ def render_ghost_suggestion: () -> void
23
36
  def render_ghost: (String ghost, ?String suggestion) -> void
24
37
  def ghost_display_lines: (String ghost, ?String suggestion) -> Array[String]
25
38
  def colorize_ghost_lines: (String ghost, String suggestion) -> Array[String]
39
+ def dim_line: (String line) -> String
26
40
  def extract_ansi_colored_suffix: (String colored_text, Integer visible_byte_offset) -> String
27
41
  def write_extra_ghost_lines: (Array[String] lines) -> void
28
- def restore_cursor_after: (Array[String] lines) -> void
29
42
  def find_suggestion: (String buffer) -> (String | nil)
30
43
  def accept_suggestion: (String suggestion) -> void
44
+ def find_prev_match: (String buffer, Integer? from_pointer) -> Integer?
45
+ def find_next_match: (String buffer, Integer? from_pointer) -> Integer?
31
46
  end
32
47
  end
33
48
  end
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.2.1
4
+ version: 0.2.3
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-30 00:00:00.000000000 Z
11
+ date: 2026-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: reline
@@ -145,6 +145,7 @@ files:
145
145
  - ".rspec"
146
146
  - ".rubocop.yml"
147
147
  - ".ruby-version"
148
+ - CHANGELOG.md
148
149
  - CODE_OF_CONDUCT.md
149
150
  - Gemfile
150
151
  - Gemfile.lock