irb-autosuggestions 0.2.2 → 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: df8665e8ded51b93cb8c175133f17a82bdf369ffe8dfc191740d65958821ab9c
4
- data.tar.gz: 8c37639ad88f22439a243b5f59e4794feb1edd744dcfaf0caa65c59e662eefb7
3
+ metadata.gz: 4707ca01caa7501953cc12ea344aa7b4ebcc818c3e505a72f5f102f5e702e215
4
+ data.tar.gz: 343171aa866561a3e4af8502df4f02a45ba4df53b45487da08a78fa9d9dad764
5
5
  SHA512:
6
- metadata.gz: eefd9feddd378c75bc6052223b9873460dcc4cf52dc07762eab5c7541bf9bd34d9efb37ed9ceedb74d4dc5acac7ee734cf547a52dd659021e3df00139d97da0c
7
- data.tar.gz: 1ff38d6e5db43910299127bd891780b6e9a82c781af7d9febd7627eda9acfecae578fcbe1dc655fa25ffb006a602d1051c59fe42b4eeb666689e8ca8eea890da
6
+ metadata.gz: 8921326d3b52cba86e73a10fc8ca7eb88322897ad4296dafe2e34e2cd440c88de61086fb522ebf1a06367ec380dc6f1bb2f08afe420347a152d43bb68f393207
7
+ data.tar.gz: 3596a6d3c5a74336f68a21c1f13bff4e664ed2fbd3bd1728e84023a37a2bc2e64dd3ee74b153b810c253f8d6f85f61e5fc962c57bf9927afad4ada3037e77fc1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
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
+
3
11
  ## [0.2.2] - 2026-07-08
4
12
 
5
13
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- irb-autosuggestions (0.2.2)
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.5.1)
12
+ docscribe (1.6.0)
13
13
  parser (>= 3.3)
14
14
  prism (~> 1.8)
15
- io-console (0.8.2)
16
- json (2.20.0)
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.88.1)
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,7 +54,7 @@ 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)
@@ -71,7 +71,7 @@ GEM
71
71
  unicode-display_width (3.2.0)
72
72
  unicode-emoji (~> 4.1)
73
73
  unicode-emoji (4.2.0)
74
- yard (0.9.44)
74
+ yard (0.9.45)
75
75
 
76
76
  PLATFORMS
77
77
  arm64-darwin-24
data/Gemfile_2_7.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- irb-autosuggestions (0.2.2)
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.5.1)
12
+ docscribe (1.6.0)
13
13
  parser (>= 3.3)
14
14
  prism (~> 1.8)
15
- io-console (0.8.2)
16
- json (2.20.0)
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.88.1)
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,7 +51,7 @@ 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)
@@ -68,7 +68,7 @@ GEM
68
68
  unicode-display_width (3.2.0)
69
69
  unicode-emoji (~> 4.1)
70
70
  unicode-emoji (4.2.0)
71
- yard (0.9.44)
71
+ yard (0.9.45)
72
72
 
73
73
  PLATFORMS
74
74
  arm64-darwin-25
@@ -35,21 +35,29 @@ module Irb
35
35
  # @param [Object] key A Reline key event.
36
36
  # @return [Object]
37
37
  def input_key(key)
38
- clear_prefix_anchor if navigation_enabled? && !history_navigation_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?
39
41
 
40
- if enabled? && accept_key?(key)
41
- buffer = whole_buffer
42
- suggestion = find_suggestion(buffer)
42
+ clear_prefix_anchor if navigation_enabled? && !history_navigation_key?(key)
43
43
 
44
- if suggestion && suggestion != buffer
45
- accept_suggestion(suggestion)
46
- return
47
- end
48
- end
44
+ return if enabled? && accept_key?(key) && accept_matching_suggestion?
49
45
 
50
46
  super
51
47
  end
52
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
+
53
61
  # Injects ghost text into terminal output after Reline finishes rendering.
54
62
  # Must be public because Reline::Core calls +rerender+ externally.
55
63
  # Works on both Reline 0.3.x (rerender is the main rendering entry) and
@@ -321,6 +329,13 @@ module Irb
321
329
 
322
330
  # Renders ghost text for the current buffer, if a suggestion exists.
323
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
+ #
324
339
  # @private
325
340
  # @return [void]
326
341
  def render_ghost_suggestion
@@ -334,7 +349,99 @@ module Irb
334
349
  ghost = suggestion[buffer.size..]
335
350
  return if ghost.nil? || ghost.empty?
336
351
 
337
- 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
338
445
  end
339
446
 
340
447
  # Finds the most recent history entry that starts with the given buffer.
@@ -371,10 +478,12 @@ module Irb
371
478
  # @private
372
479
  # @param [String] ghost The ghost text (suffix of the suggestion).
373
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.
374
483
  # @return [void]
375
- 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
376
485
  output = Reline.core.instance_variable_get(:@output)
377
- display_lines = ghost_display_lines(ghost, suggestion)
486
+ display_lines = ghost_display_lines(ghost, suggestion, ghost_byte_start)
378
487
  @ghost_line_count = display_lines.size - 1
379
488
  @has_inline_ghost = true
380
489
 
@@ -396,11 +505,13 @@ module Irb
396
505
  # @private
397
506
  # @param [String] ghost The ghost text (suffix of the suggestion).
398
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.
399
510
  # @raise [StandardError]
400
511
  # @return [Array<String>]
401
- def ghost_display_lines(ghost, suggestion)
512
+ def ghost_display_lines(ghost, suggestion, ghost_byte_start = nil)
402
513
  if suggestion && use_colorize?
403
- colorize_ghost_lines(ghost, suggestion)
514
+ colorize_ghost_lines(ghost, suggestion, ghost_byte_start)
404
515
  else
405
516
  ghost.split("\n").map { |line| "#{ghost_color}#{line}#{RESET}" }
406
517
  end
@@ -445,15 +556,78 @@ module Irb
445
556
 
446
557
  # Colorizes the full suggestion and extracts the ghost portion.
447
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
+ #
448
566
  # @private
449
567
  # @param [String] ghost The ghost text (suffix of the suggestion).
450
568
  # @param [String] suggestion The full matching history entry.
569
+ # @param [Integer, nil] ghost_byte_start Visible-byte offset of the ghost.
451
570
  # @return [Array<String>] Colorized ghost lines with ANSI codes.
452
- def colorize_ghost_lines(ghost, suggestion)
571
+ def colorize_ghost_lines(ghost, suggestion, ghost_byte_start = nil)
453
572
  colored = IRB::Color.colorize_code(suggestion)
454
- ghost_byte_start = suggestion.bytesize - ghost.bytesize
455
- colored_ghost = extract_ansi_colored_suffix(colored, ghost_byte_start)
456
- 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
457
631
  end
458
632
 
459
633
  # Prepends each ANSI foreground color code with +2;+ (dim)
@@ -512,7 +686,6 @@ module Irb
512
686
  def write_extra_ghost_lines(lines)
513
687
  return if lines.empty?
514
688
 
515
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
516
689
  output = Reline.core.instance_variable_get(:@output)
517
690
 
518
691
  lines.each do |line|
@@ -528,7 +701,6 @@ module Irb
528
701
  # @private
529
702
  # @return [Integer]
530
703
  def cursor_column_at_byte_pointer
531
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
532
704
  current_line = @buffer_of_lines[@line_index] || ''
533
705
  prompt_width + Reline::Unicode.calculate_width(current_line[0...@byte_pointer])
534
706
  end
@@ -538,10 +710,8 @@ module Irb
538
710
  # @private
539
711
  # @return [String]
540
712
  def move_to_buffer_end
541
- prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
542
713
  current_line = @buffer_of_lines[@line_index] || ''
543
- buf_end = prompt_width + Reline::Unicode.calculate_width(current_line)
544
- "\e[0G\e[#{buf_end}C"
714
+ "\e[0G\e[#{prompt_width + Reline::Unicode.calculate_width(current_line)}C"
545
715
  end
546
716
  end
547
717
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Irb
4
4
  module Autosuggestions
5
- VERSION = '0.2.2'
5
+ VERSION = '0.2.3'
6
6
  end
7
7
  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.2
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-07-08 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