Skip to Content
ftui-widgetsHyperlinks

Hyperlinks (OSC 8)

Modern terminals support clickable hyperlinks via the OSC 8 escape sequence, a VT extension standardised by most emulators (iTerm2, Kitty, WezTerm, Windows Terminal, GNOME Terminal, Alacritty on master, etc.). FrankenTUI exposes this at the widget layer through a tiny API: you attach a link to a text span, and the presenter emits the right escape bytes.

On terminals that don’t support OSC 8, the text renders normally and the link quietly disappears — no broken escape sequences, no ugly control codes bleeding into the buffer.

The two-step model

Hyperlinks work in two steps:

Register the URL with the frame

frame.register_link(url) interns a URL into the frame-wide link registry and returns a small stable link_id (u16). Identical URLs dedup to the same ID.

Each Cell has a link_id field in its CellAttrs. Setting it tells the presenter “this cell is part of a link; when you emit this run, wrap it in the appropriate OSC 8 bytes”.

This separation is what lets a 10,000-cell buffer reference a few dozen URLs without duplicating the strings: the Cell is still 16 bytes.

The helpers in ftui-widgets

You rarely touch the raw primitive. Text widgets (Paragraph, Help, StatusLine) already understand links, and a shared helper does the plumbing:

Source: lib.rs:707.

pub(crate) fn draw_text_span_with_link( frame: &mut Frame, x: u16, y: u16, text: &str, style: Style, link_url: Option<&str>, ) { let link_id = if let Some(url) = link_url { frame.register_link(url) } else { 0 }; // … per-cell writes, each tagged with link_id if non-zero }

Your widget-facing API is typically:

use ftui_widgets::paragraph::{Paragraph, Span}; let p = Paragraph::new(vec![ Span::raw("See "), Span::link("the docs", "https://frankentui.com/docs"), Span::raw(" for details."), ]); p.render(area, frame);

The Span::link builder sets the URL on that span; the Paragraph widget calls draw_text_span_with_link for you.

The registry lives on the Frame and resets each frame. Two identical URLs in the same frame collapse to one entry; a URL used next frame starts fresh — there is no cross-frame caching. That keeps the registry bounded by the current view’s link density, not by program lifetime.

OSC 8 sequence format

For reference, OSC 8 looks like:

\x1b]8;;https://example.com\x07clickable text\x1b]8;;\x07
  • \x1b]8;;URL\x07 — begin link
  • \x1b]8;;\x07 — end link

The ;; is an empty parameter slot reserved for link IDs (different from our internal link_id; terminals don’t need it). FrankenTUI’s presenter handles the emission; you do not write escapes in widget code.

Graceful degradation

OSC 8 support is a terminal capability, queried once at startup and cached on the capability descriptor. Three modes of degradation apply:

Terminal capabilityPresenter behaviour
Full OSC 8 supportEmit \x1b]8;;URL\x07…\x1b]8;;\x07 around link spans
Unknown / partialEmit nothing extra — just the text, styled as the widget requested
Capability override via envFTUI_NO_OSC8=1 disables emission even on capable terminals

The important property: no escape bytes ever reach an incapable terminal. Unsupported emulators either strip OSC 8 silently (most) or print it verbatim (a few ancient ones). FrankenTUI sidesteps the latter class by emitting nothing.

Styling is orthogonal to linkability. A link with no style looks like plain text on a non-OSC-8 terminal. If you want visible affordance on every terminal, underline the span as well — the underline survives degradation.

use ftui_widgets::help::Help; use ftui_widgets::paragraph::{Paragraph, Span}; let help = Help::new() .title(" Keys ") .body(vec![ Span::raw("Press "), Span::link("?", "https://frankentui.com/widgets/help"), Span::raw(" for help."), ]); help.render(area, frame);

On a capable terminal, clicking the ? launches the browser. On a dumb terminal, the text renders and mouse clicks just move the cursor — exactly the same UX as a non-link help overlay.

For debug overlays you can read the link ID back off a cell:

let cell = frame.buffer.get(x, y); let link_id = cell.attrs.link_id(); // 0 means no link if link_id != 0 { // Resolve the URL from the registry let url = frame.resolve_link(link_id); }

The Inspector widget uses this internally to annotate which cells will emit OSC 8 sequences on the next frame.

Pitfalls

  • Setting a link_id on a cell without registering the URL first. link_id = 0 means “no link”. Always call register_link to get a non-zero ID.
  • Long URLs in tight spaces. The URL only affects the escape bytes; the visible text is whatever your span contains. A 400-character URL is fine as long as the displayed span is short.
  • Assuming click behaviour is uniform. Terminals decide what clicking a link does — some open in the browser, some copy to clipboard, some pop a context menu. That is out of FrankenTUI’s scope.
  • Nested links. OSC 8 does not nest. FrankenTUI emits a link end before starting a new one, which is the correct behaviour, but it means a span inside a linked span will override the outer link for those cells.
  • Using link IDs across frames. The registry is per-frame. Re-register each frame that wants the link; don’t cache the ID.

Where next