From dcca8e07f35ba90225d7d70b34ad08dab5ebff3b Mon Sep 17 00:00:00 2001 From: Max Cruz Date: Tue, 27 Oct 2026 16:59:05 +0000 Subject: [PATCH 1/2] fix(cli): print plain URLs when not writing to an interactive terminal OSC 8 hyperlink escapes are only emitted when superconsole is drawing to a compatible TTY. Log viewers such as GitHub Actions drop the link text along with the escape, so test and upload links disappeared from CI logs. Co-Authored-By: Claude Opus 5.5 --- cli/src/upload_command.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/cli/src/upload_command.rs b/cli/src/upload_command.rs index aea241f9..8ecbe254 100644 --- a/cli/src/upload_command.rs +++ b/cli/src/upload_command.rs @@ -13,7 +13,7 @@ use context::bazel_bep::common::BepParseResult; use display::{end_output::EndOutput, message::DisplayMessage}; use pluralizer::pluralize; use superconsole::{ - Hyperlink, Line, Lines, Span, + Hyperlink, Line, Lines, Span, SuperConsole, style::{Attribute, Color, Stylize, style}, }; use tempfile::TempDir; @@ -859,9 +859,19 @@ pub fn get_bundle_upload_id_message(bundle_upload_id: &str) -> String { format!("🏷️ Bundle Upload ID: {}", bundle_upload_id) } +/// Plain text unless superconsole is drawing to an interactive terminal: log viewers such as +/// GitHub Actions don't support OSC 8 and drop the link text along with the escape. fn hyperlink_span(url: String, attribute: Attribute) -> anyhow::Result { + url_span(url, attribute, SuperConsole::compatible()) +} + +fn url_span(url: String, attribute: Attribute, clickable: bool) -> anyhow::Result { let span = Span::new_styled(style(url.clone()).attribute(attribute))?; - Ok(span.with_hyperlink(Some(Hyperlink::new(url)))) + Ok(if clickable { + span.with_hyperlink(Some(Hyperlink::new(url))) + } else { + span + }) } impl UploadRunResult { @@ -1202,3 +1212,24 @@ impl EndOutput for UploadRunResult { Ok(output) } } + +#[cfg(test)] +mod tests { + use superconsole::{Line, style::Attribute}; + + use super::url_span; + + const URL: &str = "https://app.trunk.io/my-org/flaky-tests/test/1"; + + fn rendered(clickable: bool) -> String { + Line::from_iter([url_span(URL.into(), Attribute::Underlined, clickable).unwrap()]).render() + } + + #[test] + fn url_span_adds_osc8_only_when_clickable() { + assert!(rendered(true).contains("\x1b]8;;")); + let plain = rendered(false); + assert!(!plain.contains("\x1b]8;;")); + assert!(plain.contains(URL)); + } +} From b8c55e75e0267de355b36e70c167987c3c8e4715 Mon Sep 17 00:00:00 2001 From: Max Cruz Date: Tue, 27 Oct 2026 17:04:34 +0000 Subject: [PATCH 2/2] fix(display): drop cursor hide/show escapes when not writing to a terminal The superconsole upgrade wraps every frame in cursor hide/show escapes, which GitHub Actions prints as 25l/25h at the start of each line. Filter them from stderr when superconsole falls back to its non-TTY console. Co-Authored-By: Claude Opus 5.5 --- display/src/render.rs | 62 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/display/src/render.rs b/display/src/render.rs index 662dca8f..01e8779b 100644 --- a/display/src/render.rs +++ b/display/src/render.rs @@ -1,13 +1,55 @@ use std::{ + io::{self, Write}, sync::mpsc::{Receiver, Sender, channel}, thread::{self, JoinHandle}, }; -use superconsole::{Dimensions, SuperConsole}; +use superconsole::{Builder, Dimensions, SuperConsole}; use terminal_size::{Height, Width, terminal_size}; use crate::message::DisplayMessage; +/// Cursor hide/show escapes superconsole wraps around every frame. Log viewers such as GitHub +/// Actions don't recognise them and print `25l`/`25h` at the start of each line. +const CURSOR_VISIBILITY: [&[u8]; 2] = [b"\x1b[?25l", b"\x1b[?25h"]; + +/// Stderr minus [`CURSOR_VISIBILITY`], for when there is no interactive terminal. +struct NonTtyStderr(io::Stderr); + +impl Write for NonTtyStderr { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.write_all(&strip_cursor_visibility(buf))?; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } +} + +fn strip_cursor_visibility(buf: &[u8]) -> Vec { + let mut out = Vec::with_capacity(buf.len()); + let mut rest = buf; + while !rest.is_empty() { + match CURSOR_VISIBILITY.iter().find(|seq| rest.starts_with(seq)) { + Some(seq) => rest = &rest[seq.len()..], + None => { + out.push(rest[0]); + rest = &rest[1..]; + } + } + } + out +} + +fn forced_console(fallback_size: Dimensions) -> SuperConsole { + let mut builder = Builder::new(); + builder.write_to(Box::new(NonTtyStderr(io::stderr()))); + builder + .build_forced(fallback_size) + .unwrap_or_else(|_| SuperConsole::forced_new(fallback_size)) +} + fn output_render_loop(receiver: Receiver) { let mut superconsole = match SuperConsole::new() { Some(console) => console, @@ -15,7 +57,7 @@ fn output_render_loop(receiver: Receiver) { tracing::warn!("Failed to create superconsole because of incompatible TTY"); let size = terminal_size(); if let Some((Width(width), Height(height))) = size { - SuperConsole::forced_new(Dimensions { + forced_console(Dimensions { width: width as usize, height: height as usize, }) @@ -27,7 +69,7 @@ fn output_render_loop(receiver: Receiver) { tracing::warn!("Using default dimensions for superconsole"); tracing::warn!("This may not render correctly in some environments"); tracing::warn!("Please use a TTY compatible terminal for best results"); - SuperConsole::forced_new(Dimensions { + forced_console(Dimensions { width: 143, height: 24, }) @@ -46,3 +88,17 @@ pub fn spin_up_renderer() -> (JoinHandle<()>, Sender) { }); (render_handle, render_sender) } + +#[cfg(test)] +mod tests { + use super::strip_cursor_visibility; + + #[test] + fn strips_only_cursor_visibility_escapes() { + let frame = b"\x1b[?25l\x1b[1G\x1b[1mUpload:\x1b[0m\x1b[K\n\x1b[?25h\x1b[J"; + assert_eq!( + strip_cursor_visibility(frame), + b"\x1b[1G\x1b[1mUpload:\x1b[0m\x1b[K\n\x1b[J" + ); + } +}