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.
Attach the link_id to the relevant cells
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 link registry
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 capability | Presenter behaviour |
|---|---|
| Full OSC 8 support | Emit \x1b]8;;URL\x07…\x1b]8;;\x07 around link spans |
| Unknown / partial | Emit nothing extra — just the text, styled as the widget requested |
| Capability override via env | FTUI_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.
Worked example: a help overlay with docs links
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.
Inspecting links at the cell level
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_idon a cell without registering the URL first.link_id = 0means “no link”. Always callregister_linkto 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
The 16-byte Cell layout, including where link_id lives.
How the presenter turns cells into ANSI — including OSC 8 emission.
PresenterHow OSC 8 support is detected.
CapabilitiesOSC 8 in context among the escapes FrankenTUI emits.
ANSI referenceHow this piece fits in widgets.
Widgets overview