From 82296704be360902c98abafb864d17e761ea9621 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 12 Aug 2026 16:42:12 -0700 Subject: [PATCH 1/3] fix: ignore OSC sequences in Truncater width calculation Truncater only skipped CSI after ESC, so OSC hyperlink payloads counted as printable width and spinner titles truncated early. Mirror ANSI.strip_codes OSC handling (BEL / ST terminators). --- lib/cli/ui/truncater.rb | 36 +++++++++++++++++++++++++------- test/cli/ui/truncater_test.rb | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/lib/cli/ui/truncater.rb b/lib/cli/ui/truncater.rb index f5c2332e..e539a112 100644 --- a/lib/cli/ui/truncater.rb +++ b/lib/cli/ui/truncater.rb @@ -5,15 +5,19 @@ module CLI module UI # Truncater truncates a string to a provided printable width. module Truncater - PARSE_ROOT = :root - PARSE_ANSI = :ansi - PARSE_ESC = :esc - PARSE_ZWJ = :zwj + PARSE_ROOT = :root + PARSE_ANSI = :ansi + PARSE_ESC = :esc + PARSE_ZWJ = :zwj + PARSE_OSC = :osc + PARSE_OSC_END = :osc_end - ESC = 0x1b - LEFT_SQUARE_BRACKET = 0x5b - ZWJ = 0x200d # emojipedia.org/emoji-zwj-sequences - SEMICOLON = 0x3b + ESC = 0x1b + LEFT_SQUARE_BRACKET = 0x5b + RIGHT_SQUARE_BRACKET = 0x5d # ] + BEL = 0x07 + ZWJ = 0x200d # emojipedia.org/emoji-zwj-sequences + SEMICOLON = 0x3b # EMOJI_RANGE in particular is super inaccurate. This is best-effort. # If you need this to be more accurate, we'll almost certainly accept a @@ -55,6 +59,9 @@ def call(text, printing_width) mode = case cp when LEFT_SQUARE_BRACKET PARSE_ANSI + when RIGHT_SQUARE_BRACKET + # OSC sequences: ESC ] ... (BEL | ESC \) + PARSE_OSC else PARSE_ROOT end @@ -69,6 +76,19 @@ def call(text, printing_width) # unexpected. let's just go back to the root state I guess? mode = PARSE_ROOT end + when PARSE_OSC + # OSC sequences end with BEL or ST (ESC + backslash), matching + # ANSI::OSC_SEQUENCE / ANSI.strip_codes. + case cp + when BEL + mode = PARSE_ROOT + when ESC + mode = PARSE_OSC_END + end + when PARSE_OSC_END + # Completing ST (ESC \). Malformed terminators fall back to root, + # consistent with unexpected CSI bytes. + mode = PARSE_ROOT when PARSE_ZWJ # consume any character and consider it as having no width # width(x+ZWJ+y) = width(x). diff --git a/test/cli/ui/truncater_test.rb b/test/cli/ui/truncater_test.rb index b399face..77e911c7 100644 --- a/test/cli/ui/truncater_test.rb +++ b/test/cli/ui/truncater_test.rb @@ -22,6 +22,45 @@ def test_truncate assert_example(3, 'AB' + MAN_COOKING, 'AB' + Truncater::TRUNCATED) end + def test_truncate_preserves_osc8_hyperlinks + # OSC 8 hyperlinks: ESC ] 8 ;; url ST text ESC ] 8 ;; ST + # Truncater must ignore OSC payload width (ANSI.printing_width already does). + link = CLI::UI.link( + 'https://github.com/Shopify/shopify/pull/12345', + '#12345', + format: false, + ) + + assert_equal(6, ANSI.printing_width(link)) + assert_example(20, link, link) + assert_example(6, link, link) + end + + def test_truncate_osc8_hyperlink_by_visible_width + link = CLI::UI.link('https://example.com/very/long/url', 'hello world', format: false) + opening = "\x1b]8;;https://example.com/very/long/url\x1b\\" + + assert_example(6, link, "#{opening}hello#{Truncater::TRUNCATED}") + end + + def test_truncate_preserves_bel_terminated_osc + # OSC sequences may also end with BEL (e.g. OSC 9 progress) + progress = "\x1b]9;4;1\x07" + text = "#{progress}hello world" + + assert_example(20, text, text) + assert_example(6, text, "#{progress}hello#{Truncater::TRUNCATED}") + end + + def test_truncate_formatted_hyperlink_issue_614 + # Exact issue #614 reproduction (default formatted link) + url = 'https://github.com/Shopify/shopify/pull/12345' + link = CLI::UI.link(url, '#12345') + + assert_equal(6, ANSI.printing_width(link)) + assert_example(20, link, link) + end + private def assert_example(width, from, to) From f41c9b316b107a93bef49bf30dbbf3066b939ced Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 12 Aug 2026 16:59:19 -0700 Subject: [PATCH 2/3] fix: close open OSC 8 hyperlinks when Truncater cuts When truncation removes a hyperlink's closing sequence, emit OSC 8 end before the ellipsis so later output is not left clickable. Require a real ST backslash after ESC, and keep BEL/ST OSC handling with focused regression coverage. --- lib/cli/ui/truncater.rb | 53 +++++++++++++++++++++++++++-------- test/cli/ui/truncater_test.rb | 27 ++++-------------- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/lib/cli/ui/truncater.rb b/lib/cli/ui/truncater.rb index e539a112..a825c170 100644 --- a/lib/cli/ui/truncater.rb +++ b/lib/cli/ui/truncater.rb @@ -15,6 +15,7 @@ module Truncater ESC = 0x1b LEFT_SQUARE_BRACKET = 0x5b RIGHT_SQUARE_BRACKET = 0x5d # ] + BACKSLASH = 0x5c # \ BEL = 0x07 ZWJ = 0x200d # emojipedia.org/emoji-zwj-sequences SEMICOLON = 0x3b @@ -28,15 +29,21 @@ module Truncater UC_ALPHA_RANGE = 0x60..0x71 TRUNCATED = "\x1b[0m…" + # OSC 8 close (empty URI). Both BEL and ST terminate OSC; we emit ST here + # to match CLI::UI.link / ANSI hyperlink endings. + HYPERLINK_END = "\x1b]8;;\x1b\\" class << self #: (String text, Integer printing_width) -> String def call(text, printing_width) return text if text.size <= printing_width - width = 0 - mode = PARSE_ROOT - truncation_index = nil #: Integer? + width = 0 + mode = PARSE_ROOT + truncation_index = nil #: Integer? + open_hyperlink = false + open_hyperlink_at_cut = false + osc_payload_start = nil #: Integer? codepoints = text.codepoints codepoints.each.with_index do |cp, index| @@ -50,7 +57,10 @@ def call(text, printing_width) else width += width(cp) if width >= printing_width - truncation_index ||= index + unless truncation_index + truncation_index = index + open_hyperlink_at_cut = open_hyperlink + end # it looks like we could break here but we still want the # width calculation for the rest of the characters. end @@ -60,7 +70,7 @@ def call(text, printing_width) when LEFT_SQUARE_BRACKET PARSE_ANSI when RIGHT_SQUARE_BRACKET - # OSC sequences: ESC ] ... (BEL | ESC \) + osc_payload_start = index + 1 PARSE_OSC else PARSE_ROOT @@ -77,18 +87,25 @@ def call(text, printing_width) mode = PARSE_ROOT end when PARSE_OSC - # OSC sequences end with BEL or ST (ESC + backslash), matching - # ANSI::OSC_SEQUENCE / ANSI.strip_codes. + # BEL and ST (ESC \) both terminate OSC; see ANSI::OSC_SEQUENCE. case cp when BEL + open_hyperlink = osc8_open?(codepoints, osc_payload_start, index) + osc_payload_start = nil mode = PARSE_ROOT when ESC mode = PARSE_OSC_END end when PARSE_OSC_END - # Completing ST (ESC \). Malformed terminators fall back to root, - # consistent with unexpected CSI bytes. - mode = PARSE_ROOT + if cp == BACKSLASH + # ST is ESC \; payload ends before the ESC. + open_hyperlink = osc8_open?(codepoints, osc_payload_start, index - 1) + osc_payload_start = nil + mode = PARSE_ROOT + else + # Not a String Terminator — keep consuming as OSC payload. + mode = PARSE_OSC + end when PARSE_ZWJ # consume any character and consider it as having no width # width(x+ZWJ+y) = width(x). @@ -104,11 +121,25 @@ def call(text, printing_width) return text if !truncation_index || width <= printing_width slice = codepoints[0...truncation_index] #: as !nil - slice.pack('U*') + TRUNCATED + truncated = slice.pack('U*') + truncated += HYPERLINK_END if open_hyperlink_at_cut + truncated + TRUNCATED end private + #: (Array[Integer] codepoints, Integer? start, Integer end_exclusive) -> bool + def osc8_open?(codepoints, start, end_exclusive) + return false if start.nil? || end_exclusive <= start + + payload = codepoints[start...end_exclusive].pack('U*') + return false unless payload.start_with?('8;') + + # OSC 8: 8;params;URI — nonempty URI opens a link; empty closes it. + _params, uri = payload.delete_prefix('8;').split(';', 2) + !uri.to_s.empty? + end + #: (Integer printable_codepoint) -> Integer def width(printable_codepoint) case printable_codepoint diff --git a/test/cli/ui/truncater_test.rb b/test/cli/ui/truncater_test.rb index 77e911c7..2f12d1a4 100644 --- a/test/cli/ui/truncater_test.rb +++ b/test/cli/ui/truncater_test.rb @@ -22,29 +22,21 @@ def test_truncate assert_example(3, 'AB' + MAN_COOKING, 'AB' + Truncater::TRUNCATED) end - def test_truncate_preserves_osc8_hyperlinks - # OSC 8 hyperlinks: ESC ] 8 ;; url ST text ESC ] 8 ;; ST - # Truncater must ignore OSC payload width (ANSI.printing_width already does). - link = CLI::UI.link( - 'https://github.com/Shopify/shopify/pull/12345', - '#12345', - format: false, - ) + def test_truncate_formatted_hyperlink_issue_614 + url = 'https://github.com/Shopify/shopify/pull/12345' + link = CLI::UI.link(url, '#12345') - assert_equal(6, ANSI.printing_width(link)) assert_example(20, link, link) - assert_example(6, link, link) end - def test_truncate_osc8_hyperlink_by_visible_width + def test_truncate_closes_an_open_osc8_hyperlink link = CLI::UI.link('https://example.com/very/long/url', 'hello world', format: false) opening = "\x1b]8;;https://example.com/very/long/url\x1b\\" - assert_example(6, link, "#{opening}hello#{Truncater::TRUNCATED}") + assert_example(6, link, "#{opening}hello#{Truncater::HYPERLINK_END}#{Truncater::TRUNCATED}") end def test_truncate_preserves_bel_terminated_osc - # OSC sequences may also end with BEL (e.g. OSC 9 progress) progress = "\x1b]9;4;1\x07" text = "#{progress}hello world" @@ -52,15 +44,6 @@ def test_truncate_preserves_bel_terminated_osc assert_example(6, text, "#{progress}hello#{Truncater::TRUNCATED}") end - def test_truncate_formatted_hyperlink_issue_614 - # Exact issue #614 reproduction (default formatted link) - url = 'https://github.com/Shopify/shopify/pull/12345' - link = CLI::UI.link(url, '#12345') - - assert_equal(6, ANSI.printing_width(link)) - assert_example(20, link, link) - end - private def assert_example(width, from, to) From 1210b80cd0862698fb044839efd6b1bf94a3f66f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 13 Aug 2026 12:39:30 -0700 Subject: [PATCH 3/3] fix: keep OSC 8 open across unrelated OSC sequences Non-OSC8 terminators were clearing hyperlink state via osc8_open? returning false. Return nil for non-OSC8 so Truncater still closes dangling links when truncating after sequences like OSC 9. --- lib/cli/ui/truncater.rb | 15 +++++++++------ test/cli/ui/truncater_test.rb | 13 +++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/cli/ui/truncater.rb b/lib/cli/ui/truncater.rb index a825c170..7c799c6f 100644 --- a/lib/cli/ui/truncater.rb +++ b/lib/cli/ui/truncater.rb @@ -90,7 +90,8 @@ def call(text, printing_width) # BEL and ST (ESC \) both terminate OSC; see ANSI::OSC_SEQUENCE. case cp when BEL - open_hyperlink = osc8_open?(codepoints, osc_payload_start, index) + state = osc8_link_state(codepoints, osc_payload_start, index) + open_hyperlink = state unless state.nil? osc_payload_start = nil mode = PARSE_ROOT when ESC @@ -99,7 +100,8 @@ def call(text, printing_width) when PARSE_OSC_END if cp == BACKSLASH # ST is ESC \; payload ends before the ESC. - open_hyperlink = osc8_open?(codepoints, osc_payload_start, index - 1) + state = osc8_link_state(codepoints, osc_payload_start, index - 1) + open_hyperlink = state unless state.nil? osc_payload_start = nil mode = PARSE_ROOT else @@ -128,14 +130,15 @@ def call(text, printing_width) private - #: (Array[Integer] codepoints, Integer? start, Integer end_exclusive) -> bool - def osc8_open?(codepoints, start, end_exclusive) - return false if start.nil? || end_exclusive <= start + #: (Array[Integer] codepoints, Integer? start, Integer end_exclusive) -> bool? + def osc8_link_state(codepoints, start, end_exclusive) + return nil if start.nil? || end_exclusive <= start payload = codepoints[start...end_exclusive].pack('U*') - return false unless payload.start_with?('8;') + return nil unless payload.start_with?('8;') # OSC 8: 8;params;URI — nonempty URI opens a link; empty closes it. + # Non-OSC8 sequences return nil so Truncater does not clear open-link state. _params, uri = payload.delete_prefix('8;').split(';', 2) !uri.to_s.empty? end diff --git a/test/cli/ui/truncater_test.rb b/test/cli/ui/truncater_test.rb index 2f12d1a4..75c0b438 100644 --- a/test/cli/ui/truncater_test.rb +++ b/test/cli/ui/truncater_test.rb @@ -44,6 +44,19 @@ def test_truncate_preserves_bel_terminated_osc assert_example(6, text, "#{progress}hello#{Truncater::TRUNCATED}") end + def test_truncate_keeps_osc8_open_across_unrelated_osc + opening = "\x1b]8;;https://example.com/very/long/url\x1b\\" + progress = "\x1b]9;4;1\x07" + closing = "\x1b]8;;\x1b\\" + text = "#{opening}hello#{progress} world#{closing}" + + assert_example( + 6, + text, + "#{opening}hello#{progress}#{Truncater::HYPERLINK_END}#{Truncater::TRUNCATED}", + ) + end + private def assert_example(width, from, to)