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:
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 || {
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user