Merge pull request #515 from Hmbown/feat/498-osc8-hyperlinks

feat(tui): OSC 8 hyperlinks for Cmd+click-openable URLs (#498)
This commit is contained in:
Hunter Bown
2026-05-03 08:18:21 -05:00
committed by GitHub
10 changed files with 284 additions and 14 deletions
+1
View File
@@ -171,6 +171,7 @@ max_subagents = 10 # optional (1-20)
alternate_screen = "auto" # auto | always | never
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
# ─────────────────────────────────────────────────────────────────────────────────
# Feature Flags
+7
View File
@@ -336,6 +336,13 @@ pub struct TuiConfig {
/// Edited interactively via `/statusline`; persisted to `tui.status_items`
/// in `~/.deepseek/config.toml`.
pub status_items: Option<Vec<StatusItem>>,
/// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so
/// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,
/// WezTerm, Alacritty, recent gnome-terminal/konsole) make them
/// Cmd+click-openable. Terminals without OSC 8 support render the plain
/// label and ignore the escape. Defaults to `true`; set `false` for
/// terminals that misrender the sequence.
pub osc8_links: Option<bool>,
}
/// Notification delivery method (mirrors `tui::notifications::Method`).
+2
View File
@@ -3372,6 +3372,7 @@ mod terminal_mode_tests {
mouse_capture: Some(false),
terminal_probe_timeout_ms: None,
status_items: None,
osc8_links: None,
}),
..Config::default()
};
@@ -3396,6 +3397,7 @@ mod terminal_mode_tests {
mouse_capture: Some(true),
terminal_probe_timeout_ms: None,
status_items: None,
osc8_links: None,
}),
..Config::default()
};
+50 -6
View File
@@ -32,6 +32,7 @@ use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::palette;
use crate::tui::osc8;
// Thread-local counter incremented every time `parse` runs. Used by tests to
// prove that width-only changes hit the cached-AST path and skip parsing.
@@ -324,11 +325,8 @@ fn render_line_with_links(
let mut current_width = 0usize;
for word in line.split_whitespace() {
let style = if looks_like_link(word) {
link_style
} else {
base_style
};
let is_link = looks_like_link(word);
let style = if is_link { link_style } else { base_style };
let word_width = word.width();
let additional = if current_width == 0 {
word_width
@@ -347,7 +345,16 @@ fn render_line_with_links(
current_width += 1;
}
current_spans.push(Span::styled(word.to_string(), style));
// For URLs, wrap the visible text in OSC 8 escapes when the runtime
// flag allows it. Display width is computed from the bare URL — the
// escapes are zero-width on supporting terminals and ignored on the
// rest. The clipboard / selection path strips OSC 8 before yanking.
let content = if is_link && osc8::enabled() {
osc8::wrap_link(word, word)
} else {
word.to_string()
};
current_spans.push(Span::styled(content, style));
current_width += word_width;
}
@@ -512,4 +519,41 @@ mod tests {
.collect();
assert_eq!(items, vec![("-", "alpha"), ("-", "beta"), ("1.", "gamma")]);
}
/// Render with the OSC 8 flag pinned to `enabled`, then restore the prior
/// value. We serialize through a static mutex because `osc8::ENABLED` is
/// process-wide state and other tests touching it would race otherwise.
fn render_with_osc8(enabled: bool, source: &str) -> String {
use std::sync::Mutex;
static OSC8_GUARD: Mutex<()> = Mutex::new(());
let _guard = OSC8_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = osc8::enabled();
osc8::set_enabled(enabled);
let lines = render_markdown(source, 80, Style::default());
let joined = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect::<String>();
osc8::set_enabled(prior);
joined
}
#[test]
fn http_links_get_osc_8_wrapped_when_enabled() {
let joined = render_with_osc8(true, "see https://example.com for details");
assert!(
joined.contains("\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\"),
"expected OSC 8 wrapper around URL; got {joined:?}"
);
}
#[test]
fn osc_8_disabled_emits_plain_url() {
let joined = render_with_osc8(false, "see https://example.com for details");
assert!(
!joined.contains("\x1b]8;;"),
"expected no OSC 8 wrapper when disabled; got {joined:?}"
);
assert!(joined.contains("https://example.com"));
}
}
+1
View File
@@ -25,6 +25,7 @@ mod mcp_routing;
pub mod model_picker;
pub mod notifications;
pub mod onboarding;
pub mod osc8;
pub mod pager;
pub mod paste;
pub mod paste_burst;
+165
View File
@@ -0,0 +1,165 @@
//! OSC 8 hyperlink emission and stripping.
//!
//! Modern terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm,
//! Alacritty, recent gnome-terminal/konsole) make a substring clickable when
//! it is wrapped in:
//!
//! ```text
//! \x1b]8;;TARGET\x1b\\LABEL\x1b]8;;\x1b\\
//! ```
//!
//! Terminals that don't understand the sequence simply render the visible
//! `LABEL` and ignore the escape. So emitting OSC 8 is a strict UX upgrade for
//! supporting terminals and a no-op for the rest.
//!
//! The TUI emits these inside `Span::content` strings so the existing
//! ratatui pipeline carries them through. The tradeoff is that the clipboard
//! / selection extraction path must strip the codes before handing text to the
//! user — that's what [`strip_into`] is for.
use std::sync::atomic::{AtomicBool, Ordering};
const OSC8_PREFIX: &str = "\x1b]8;;";
const OSC8_TERMINATOR: &str = "\x1b\\";
/// Process-wide enable flag. `true` by default. Set once at app init from
/// `[ui] osc8_links` (when present) and read by the renderer.
static ENABLED: AtomicBool = AtomicBool::new(true);
/// Set the process-wide OSC 8 enable flag. Intended to be called once at
/// startup; subsequent calls take effect immediately.
pub fn set_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
}
/// Whether OSC 8 hyperlink emission is currently enabled.
#[must_use]
pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// 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.
///
/// Does **not** check [`enabled()`]; callers wanting the runtime gate should
/// branch on it before calling this. That keeps the helper test-friendly.
#[must_use]
pub fn wrap_link(target: &str, label: &str) -> String {
let mut out = String::with_capacity(target.len() + label.len() + 12);
out.push_str(OSC8_PREFIX);
out.push_str(target);
out.push_str(OSC8_TERMINATOR);
out.push_str(label);
out.push_str(OSC8_PREFIX);
out.push_str(OSC8_TERMINATOR);
out
}
/// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible
/// label text. Other escapes (color, style) pass through untouched. The
/// implementation handles both the standard `ESC \` and the lone `BEL`
/// terminators that some emitters use.
pub fn strip_into(s: &str, out: &mut String) {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
// Look for the OSC 8 prefix `ESC ] 8 ;`
if i + 4 <= bytes.len()
&& bytes[i] == 0x1b
&& bytes[i + 1] == b']'
&& bytes[i + 2] == b'8'
&& bytes[i + 3] == b';'
{
// Skip until the string terminator (ESC \) or BEL.
let mut j = i + 4;
while j < bytes.len() {
if bytes[j] == 0x07 {
j += 1;
break;
}
if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
j += 2;
break;
}
j += 1;
}
i = j;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Serialize tests that read or write the `ENABLED` flag so they don't
/// race each other under cargo's default parallel test runner.
static FLAG_GUARD: Mutex<()> = Mutex::new(());
fn strip(s: &str) -> String {
let mut out = String::with_capacity(s.len());
strip_into(s, &mut out);
out
}
#[test]
fn wrap_link_shape_is_osc_8_compliant() {
let wrapped = wrap_link("https://example.com", "click me");
assert_eq!(
wrapped,
"\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\"
);
}
#[test]
fn strip_removes_wrapper_keeps_label() {
let wrapped = wrap_link("https://example.com", "click me");
assert_eq!(strip(&wrapped), "click me");
}
#[test]
fn strip_handles_bel_terminator() {
let wrapped = "\x1b]8;;https://example.com\x07click me\x1b]8;;\x07";
assert_eq!(strip(wrapped), "click me");
}
#[test]
fn strip_passes_through_text_with_no_escapes() {
let plain = "no escapes here";
assert_eq!(strip(plain), plain);
}
#[test]
fn strip_preserves_non_osc_8_escapes() {
// Color escape stays in place; only OSC 8 wrappers are removed.
let mixed = format!(
"\x1b[31mred\x1b[0m {wrapped}",
wrapped = wrap_link("https://example.com", "click")
);
assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click");
}
#[test]
fn enabled_is_true_by_default_when_untouched() {
// Hold the flag guard so we observe the initial state, not a value
// mid-flight from `set_enabled_round_trips`. The flag *defaults* to
// true at static init and tests in this module are the only writers.
let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
assert!(enabled());
}
#[test]
fn set_enabled_round_trips() {
let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = enabled();
set_enabled(false);
assert!(!enabled());
set_enabled(true);
assert!(enabled());
set_enabled(prior);
}
}
+9
View File
@@ -148,6 +148,15 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
let use_mouse_capture = options.use_mouse_capture;
let use_bracketed_paste = options.use_bracketed_paste;
// Apply OSC 8 hyperlink toggle from config; default `true`.
crate::tui::osc8::set_enabled(
config
.tui
.as_ref()
.and_then(|tui| tui.osc8_links)
.unwrap_or(true),
);
// Terminal probe with timeout to prevent hanging on unresponsive terminals
let probe_timeout = terminal_probe_timeout(config);
let enable_raw = tokio::task::spawn_blocking(move || {
+1
View File
@@ -592,6 +592,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() {
mouse_capture: None,
terminal_probe_timeout_ms: Some(750),
status_items: None,
osc8_links: None,
}),
..Config::default()
};
+47 -8
View File
@@ -4,6 +4,7 @@ use ratatui::text::Line;
use unicode_width::UnicodeWidthChar;
use crate::tui::history::HistoryCell;
use crate::tui::osc8;
pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
cell.transcript_lines(width)
@@ -14,17 +15,27 @@ pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
}
fn line_to_string(line: Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
let mut out = String::new();
for span in line.spans {
if span.content.contains('\x1b') {
osc8::strip_into(&span.content, &mut out);
} else {
out.push_str(&span.content);
}
}
out
}
pub(super) fn line_to_plain(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
let mut out = String::new();
for span in &line.spans {
if span.content.contains('\x1b') {
osc8::strip_into(&span.content, &mut out);
} else {
out.push_str(span.content.as_ref());
}
}
out
}
pub(super) fn text_display_width(text: &str) -> usize {
@@ -60,3 +71,31 @@ fn char_display_width(ch: char) -> usize {
UnicodeWidthChar::width(ch).unwrap_or(0).max(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Span;
#[test]
fn line_to_plain_strips_osc_8_wrapper() {
// A span carrying an OSC 8-wrapped URL must not leak the escape into
// selection / clipboard output. The visible label survives.
let wrapped = format!(
"\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
"https://example.com", "https://example.com"
);
let line = Line::from(vec![
Span::raw("see "),
Span::raw(wrapped),
Span::raw(" for details"),
]);
assert_eq!(line_to_plain(&line), "see https://example.com for details");
}
#[test]
fn line_to_plain_passes_through_plain_spans() {
let line = Line::from(vec![Span::raw("plain "), Span::raw("text")]);
assert_eq!(line_to_plain(&line), "plain text");
}
}
+1
View File
@@ -275,6 +275,7 @@ If you are upgrading from older releases:
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. `auto` disables the alternate screen in Zellij; `--no-alt-screen` forces inline mode. Set `never` or run with `--no-alt-screen` when you want real terminal scrollback.
- `tui.mouse_capture` (bool, optional, default `true` when the alternate screen is active): enable internal mouse scrolling, transcript selection, and right-click context actions. TUI-owned drag selection copies only user/assistant transcript text. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection.
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.
- `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes.
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
- `features.*` (optional): feature flag overrides (see below).