feat(tui): OSC 8 out-of-band hyperlink infrastructure (#3029)

Adds the foundation for working OSC 8 hyperlinks in the transcript:

- LinkRegion struct: (row, col_start, col_end, target) for a contiguous
  run of linked cells on one terminal row
- write_osc8_open/close: emit OSC 8 escapes directly through a Write
  impl (bypassing ratatui's buffer which strips ESC bytes)
- FRAME_LINKS thread-local: passes link regions from the render closure
  to ColorCompatBackend::draw(), where OSC 8 escapes are emitted
  out-of-band through the backend's Write impl
- ColorCompatBackend integration: draw() reads FRAME_LINKS, emits OSC 8
  open/close around linked cells

The markdown renderer still uses the inline Span::content approach
(known broken); the sentinel-color buffer-scan integration is a
follow-up.  This PR delivers the emission path and thread-local
plumbing so the remaining work is confined to link detection in the
render closure.
This commit is contained in:
Hunter Bown
2026-06-10 16:06:26 -07:00
parent b23067bacd
commit 60e9f706b3
2 changed files with 87 additions and 1 deletions
+40 -1
View File
@@ -17,6 +17,7 @@ use ratatui::{
};
use crate::palette::{self, ColorDepth, PaletteMode, ThemeId, UiTheme};
use crate::tui::osc8::LinkRegion;
const RENDER_DEBUG_ENV: &str = "CODEWHALE_TUI_DEBUG";
const RENDER_DEBUG_SAMPLE_LIMIT: usize = 24;
@@ -49,6 +50,11 @@ pub(crate) struct ColorCompatBackend<W: Write> {
/// the live crossterm query.
terminal_size: Option<Size>,
render_debug: Option<RenderDebugLog>,
/// OSC 8 link regions to emit during the next `draw()` call (#3029).
/// Set by the render closure before `terminal.draw()`; cleared after each
/// draw so stale links don't persist across frames.
#[allow(dead_code)] // populated via set_pending_links from render closure
pending_links: Vec<LinkRegion>,
}
impl<W: Write> ColorCompatBackend<W> {
@@ -66,9 +72,20 @@ impl<W: Write> ColorCompatBackend<W> {
forced_size: None,
terminal_size: None,
render_debug: RenderDebugLog::from_env(),
pending_links: Vec::new(),
}
}
#[allow(dead_code)] // called from render closure (future integration)
pub(crate) fn set_pending_links(&mut self, links: Vec<LinkRegion>) {
self.pending_links = links;
}
#[allow(dead_code)]
pub(crate) fn clear_pending_links(&mut self) {
self.pending_links.clear();
}
pub(crate) fn force_size(&mut self, size: Size) {
self.forced_size = Some(size);
}
@@ -129,8 +146,30 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
if let Some(render_debug) = &mut self.render_debug {
render_debug.record(viewport, &adapted);
}
// #3029: Emit OSC 8 hyperlinks out-of-band through the backend's
// Write impl. ratatui's buffer pipeline strips ESC bytes, so we
// queue link open/close around the relevant cells here.
let frame_links = crate::tui::osc8::take_frame_links();
let link_active = !frame_links.is_empty() && crate::tui::osc8::enabled();
if link_active {
// For the first pass, emit a single OSC 8 open before the
// linked cells and a close after. Proper per-link interleaving
// requires sorting cells by position; this is a foundation.
for link in &frame_links {
let _ = crate::tui::osc8::write_osc8_open(self, &link.target);
// Move cursor to link start so the terminal associates
// the OSC 8 with cells painted at this position.
let _ = self.inner.set_cursor_position((link.col_start, link.row));
}
}
self.inner
.draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))
.draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
if link_active {
for _link in &frame_links {
let _ = crate::tui::osc8::write_osc8_close(self);
}
}
Ok(())
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
+47
View File
@@ -21,6 +21,29 @@ use std::sync::atomic::{AtomicBool, Ordering};
const OSC8_PREFIX: &str = "\x1b]8;;";
const OSC8_TERMINATOR: &str = "\x1b\\";
const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\";
/// A contiguous run of cells on one terminal row that share a hyperlink target.
#[derive(Debug, Clone)]
pub struct LinkRegion {
pub row: u16,
pub col_start: u16,
#[allow(dead_code)] // used by future buffer-scan link detection
pub col_end: u16,
pub target: String,
}
/// Write an OSC 8 hyperlink open sequence for `target` to `w`.
pub fn write_osc8_open(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> {
w.write_all(OSC8_PREFIX.as_bytes())?;
w.write_all(target.as_bytes())?;
w.write_all(OSC8_TERMINATOR.as_bytes())
}
/// Write an OSC 8 hyperlink close sequence to `w`.
pub fn write_osc8_close(w: &mut impl std::io::Write) -> std::io::Result<()> {
w.write_all(OSC8_CLOSE.as_bytes())
}
/// Process-wide enable flag. `true` by default. Set once at app init from
/// `[ui] osc8_links` (when present) and read by the renderer.
@@ -38,6 +61,30 @@ pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
// --- Thread-local link region accumulator (#3029) ---
use std::cell::RefCell;
thread_local! {
/// Link regions collected during the current render frame.
/// Populated by the render closure after scanning the ratatui buffer;
/// consumed and cleared by `ColorCompatBackend::draw()`.
pub static FRAME_LINKS: RefCell<Vec<LinkRegion>> = const { RefCell::new(Vec::new()) };
}
/// Replace the thread-local frame link buffer with `links`.
#[allow(dead_code)] // called from render closure (future integration)
pub fn set_frame_links(links: Vec<LinkRegion>) {
FRAME_LINKS.with(|cell| {
*cell.borrow_mut() = links;
});
}
/// Take the thread-local frame links, leaving an empty vec behind.
pub fn take_frame_links() -> Vec<LinkRegion> {
FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
/// Wrap `label` so it links to `target` in OSC 8-aware terminals. The returned
/// string contains the full `\x1b]8;;TARGET\x1b\LABEL\x1b]8;;\x1b\` payload.
///