306 lines
11 KiB
Rust
306 lines
11 KiB
Rust
|
|
//! Per-terminal hyperlink (OSC 8) capabilities.
|
||
|
|
//!
|
||
|
|
//! Classifies caller semantics so input-handling code can consume one struct
|
||
|
|
//! instead of branching on brand.
|
||
|
|
|
||
|
|
use super::TerminalName;
|
||
|
|
|
||
|
|
/// Whether the terminal supports OSC 8 hyperlink sequences.
|
||
|
|
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
|
||
|
|
#[strum(serialize_all = "snake_case")]
|
||
|
|
#[non_exhaustive]
|
||
|
|
pub enum Osc8Support {
|
||
|
|
/// Terminal natively supports OSC 8 sequences.
|
||
|
|
Native,
|
||
|
|
/// Terminal actively garbles unknown OSC sequences (Apple Terminal).
|
||
|
|
HostileParser,
|
||
|
|
/// Terminal explicitly does not support OSC 8.
|
||
|
|
Unsupported,
|
||
|
|
/// Support status is unknown.
|
||
|
|
#[default]
|
||
|
|
Unknown,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Which URL schemes the terminal supports in OSC 8 links.
|
||
|
|
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
|
||
|
|
#[strum(serialize_all = "snake_case")]
|
||
|
|
#[non_exhaustive]
|
||
|
|
pub enum SchemeFilter {
|
||
|
|
/// Standard web schemes: http, https, mailto.
|
||
|
|
#[default]
|
||
|
|
Standard,
|
||
|
|
/// Extended editor schemes: vscode://, cursor://, idea://, zed://.
|
||
|
|
EditorExtended,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl SchemeFilter {
|
||
|
|
/// Returns `true` if the given scheme is permitted by this filter.
|
||
|
|
pub fn allows(&self, scheme: &str) -> bool {
|
||
|
|
match self {
|
||
|
|
Self::Standard => matches!(scheme, "http" | "https" | "mailto"),
|
||
|
|
Self::EditorExtended => matches!(
|
||
|
|
scheme,
|
||
|
|
"http" | "https" | "mailto" | "file" | "vscode" | "cursor" | "idea" | "zed"
|
||
|
|
),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Per-terminal hyperlink capabilities.
|
||
|
|
#[derive(Debug, Clone, Copy, Default)]
|
||
|
|
#[non_exhaustive]
|
||
|
|
pub struct HyperlinkCapabilities {
|
||
|
|
/// OSC 8 support level.
|
||
|
|
pub osc8: Osc8Support,
|
||
|
|
/// Whether the terminal supports the `id=` parameter for hover-grouping.
|
||
|
|
pub id_param: bool,
|
||
|
|
/// Which URL schemes the terminal handles.
|
||
|
|
pub scheme_filter: SchemeFilter,
|
||
|
|
/// Whether the terminal supports OSC 22 cursor-shape changes
|
||
|
|
/// (e.g. switching to a hand/pointer cursor on link hover).
|
||
|
|
pub osc22_cursor: bool,
|
||
|
|
/// Whether the terminal handles link hover styling natively (so our
|
||
|
|
/// app should skip its own Cmd/Ctrl+hover highlight logic).
|
||
|
|
pub native_link_hover: bool,
|
||
|
|
/// Terminal opens bare http(s)/mailto under mouse reporting (Warp).
|
||
|
|
pub native_plain_url_open: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Classify hyperlink capabilities for a given `brand`.
|
||
|
|
pub fn hyperlink_capabilities(brand: TerminalName) -> HyperlinkCapabilities {
|
||
|
|
use Osc8Support::*;
|
||
|
|
match brand {
|
||
|
|
// Apple Terminal actively garbles unknown OSC sequences.
|
||
|
|
TerminalName::AppleTerminal => HyperlinkCapabilities {
|
||
|
|
osc8: HostileParser,
|
||
|
|
id_param: false,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// Reference implementation. Excellent id= handling.
|
||
|
|
TerminalName::Iterm2 => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: true,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
TerminalName::Ghostty => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: true,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// Since kitty v0.19.
|
||
|
|
TerminalName::Kitty => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: true,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// Since Alacritty v0.11. Rio and foot also support OSC 8.
|
||
|
|
TerminalName::Alacritty | TerminalName::Rio | TerminalName::Foot => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
TerminalName::WezTerm => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// VS Code integrated terminal since v1.72. VS Code-family embeds
|
||
|
|
// inherit the same terminal renderer (xterm.js). Zed implements
|
||
|
|
// OSC 8 with similar capabilities. All of these handle link hover
|
||
|
|
// styling natively.
|
||
|
|
TerminalName::VsCode
|
||
|
|
| TerminalName::Cursor
|
||
|
|
| TerminalName::Windsurf
|
||
|
|
| TerminalName::Zed => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: true,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// Open issue warpdotdev/Warp#4194. UrlLocator opens bare URLs under
|
||
|
|
// mouse reporting; keep native_link_hover false for file:// fallback.
|
||
|
|
TerminalName::WarpTerminal => HyperlinkCapabilities {
|
||
|
|
osc8: Unsupported,
|
||
|
|
id_param: false,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: true,
|
||
|
|
},
|
||
|
|
// VTE-based terminals (GNOME Terminal, Terminator, etc.).
|
||
|
|
// Conservative -- gated by version in the route resolver if
|
||
|
|
// vte_version is too old.
|
||
|
|
TerminalName::Vte | TerminalName::Terminator => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// Windows Terminal since v1.4 (OSC 8 support).
|
||
|
|
TerminalName::WindowsTerminal => HyperlinkCapabilities {
|
||
|
|
osc8: Native,
|
||
|
|
id_param: true,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// JetBrains JediTerm: OSC 8 varies across IDE versions; no
|
||
|
|
// runtime probe available (no TERM_FEATURES). Conservative.
|
||
|
|
TerminalName::JetBrains => HyperlinkCapabilities {
|
||
|
|
osc8: Unknown,
|
||
|
|
id_param: false,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
// Electron app; behavior undocumented.
|
||
|
|
TerminalName::GrokDesktop => HyperlinkCapabilities {
|
||
|
|
osc8: Unknown,
|
||
|
|
id_param: false,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
TerminalName::Otty | TerminalName::Unknown => HyperlinkCapabilities {
|
||
|
|
osc8: Unknown,
|
||
|
|
id_param: false,
|
||
|
|
scheme_filter: SchemeFilter::Standard,
|
||
|
|
osc22_cursor: false,
|
||
|
|
native_link_hover: false,
|
||
|
|
native_plain_url_open: false,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── OSC 22 cursor-shape commands ──────────────────────────────────────
|
||
|
|
//
|
||
|
|
// These wrap raw OSC 22 sequences as crossterm `Command`s so call sites
|
||
|
|
// can use `crossterm::execute!` / `queue!` instead of manual byte writes.
|
||
|
|
|
||
|
|
/// OSC 22: set the mouse pointer to the "pointer" (hand) shape.
|
||
|
|
///
|
||
|
|
/// Supported by iTerm2, Ghostty, and Kitty. Silently ignored by
|
||
|
|
/// terminals that don't understand OSC 22.
|
||
|
|
pub struct SetPointerCursor;
|
||
|
|
|
||
|
|
impl crossterm::Command for SetPointerCursor {
|
||
|
|
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||
|
|
f.write_str("\x1b]22;pointer\x1b\\")
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(windows)]
|
||
|
|
fn execute_winapi(&self) -> std::io::Result<()> {
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// OSC 22: reset the mouse pointer to the default (arrow) shape.
|
||
|
|
pub struct SetDefaultCursor;
|
||
|
|
|
||
|
|
impl crossterm::Command for SetDefaultCursor {
|
||
|
|
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||
|
|
f.write_str("\x1b]22;default\x1b\\")
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(windows)]
|
||
|
|
fn execute_winapi(&self) -> std::io::Result<()> {
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn apple_terminal_hostile_parser() {
|
||
|
|
let caps = hyperlink_capabilities(TerminalName::AppleTerminal);
|
||
|
|
assert_eq!(caps.osc8, Osc8Support::HostileParser);
|
||
|
|
assert!(!caps.id_param);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn iterm2_native_with_id() {
|
||
|
|
let caps = hyperlink_capabilities(TerminalName::Iterm2);
|
||
|
|
assert_eq!(caps.osc8, Osc8Support::Native);
|
||
|
|
assert!(caps.id_param);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn warp_unsupported() {
|
||
|
|
let caps = hyperlink_capabilities(TerminalName::WarpTerminal);
|
||
|
|
assert_eq!(caps.osc8, Osc8Support::Unsupported);
|
||
|
|
assert!(!caps.id_param);
|
||
|
|
assert!(caps.native_plain_url_open);
|
||
|
|
assert!(!caps.native_link_hover);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn native_plain_url_open_only_warp() {
|
||
|
|
assert!(hyperlink_capabilities(TerminalName::WarpTerminal).native_plain_url_open);
|
||
|
|
for brand in [
|
||
|
|
TerminalName::Iterm2,
|
||
|
|
TerminalName::VsCode,
|
||
|
|
TerminalName::AppleTerminal,
|
||
|
|
TerminalName::Ghostty,
|
||
|
|
TerminalName::Unknown,
|
||
|
|
] {
|
||
|
|
assert!(
|
||
|
|
!hyperlink_capabilities(brand).native_plain_url_open,
|
||
|
|
"{brand:?} must not set native_plain_url_open"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn unknown_terminal_unknown_support() {
|
||
|
|
let caps = hyperlink_capabilities(TerminalName::Unknown);
|
||
|
|
assert_eq!(caps.osc8, Osc8Support::Unknown);
|
||
|
|
assert!(!caps.id_param);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn scheme_filter_standard_allows_http() {
|
||
|
|
assert!(SchemeFilter::Standard.allows("http"));
|
||
|
|
assert!(SchemeFilter::Standard.allows("https"));
|
||
|
|
assert!(SchemeFilter::Standard.allows("mailto"));
|
||
|
|
assert!(!SchemeFilter::Standard.allows("file"));
|
||
|
|
assert!(!SchemeFilter::Standard.allows("vscode"));
|
||
|
|
assert!(!SchemeFilter::Standard.allows("javascript"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn scheme_filter_extended_allows_editor_schemes() {
|
||
|
|
assert!(SchemeFilter::EditorExtended.allows("http"));
|
||
|
|
assert!(SchemeFilter::EditorExtended.allows("vscode"));
|
||
|
|
assert!(SchemeFilter::EditorExtended.allows("cursor"));
|
||
|
|
assert!(SchemeFilter::EditorExtended.allows("idea"));
|
||
|
|
assert!(SchemeFilter::EditorExtended.allows("zed"));
|
||
|
|
assert!(!SchemeFilter::EditorExtended.allows("javascript"));
|
||
|
|
}
|
||
|
|
}
|