feat(tui): use OSC 8 hyperlinks in Markdown when terminal supports them (#3248)

TerminalCapabilities already tracks hyperlinks: boolean and returns true
for Ghostty, Kitty, WezTerm, and iTerm2, but nothing generated OSC 8
sequences. This completes that stub.

Changes to packages/tui:
- terminal-image.ts: add hyperlink(text, url) and setCapabilities()
- index.ts: export hyperlink and setCapabilities
- utils.ts: extend AnsiCodeTracker to track active OSC 8 URLs
  - process() now handles OSC 8 open/close sequences
  - getActiveCodes() re-emits the OSC 8 open at each line start
  - getLineEndReset() closes the OSC 8 hyperlink before each line break
  This ensures hyperlinks wrap correctly across multiple lines.
- components/markdown.ts: link renderer uses hyperlink() when
  getCapabilities().hyperlinks is true; falls back to (url) text
- Tests: new wrap-ansi tests for OSC 8 line-wrapping; terminal-image
  tests for hyperlink(); markdown tests covering both code paths;
  table-cell width test pinned to hyperlinks:false (checks raw columns)

closes #3239

Co-authored-by: AI (Pi/Claude Sonnet 4.6) <noreply@pi.dev>
Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
Omair Ahmed
2026-04-16 16:13:36 -04:00
committed by GitHub
parent acbf8eca06
commit e8743e870b
7 changed files with 212 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
import { Marked, type Token, Tokenizer, type Tokens } from "marked";
import { isImageLine } from "../terminal-image.js";
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
@@ -488,18 +488,22 @@ export class Markdown implements Component {
case "link": {
const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
// If link text matches href, only show the link once
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
// For mailto: links, strip the prefix before comparing (autolinked emails have
// text="foo@bar.com" but href="mailto:foo@bar.com")
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
if (token.text === token.href || token.text === hrefForComparison) {
result += this.theme.link(this.theme.underline(linkText)) + stylePrefix;
const styledLink = this.theme.link(this.theme.underline(linkText));
if (getCapabilities().hyperlinks) {
// OSC 8: render as a clickable hyperlink. The URL is not printed inline,
// so we always show only the link text regardless of whether it matches href.
result += hyperlink(styledLink, token.href) + stylePrefix;
} else {
result +=
this.theme.link(this.theme.underline(linkText)) +
this.theme.linkUrl(` (${token.href})`) +
stylePrefix;
// Fallback: print URL in parentheses when text differs from href.
// Compare raw token.text (not styled) against href for the equality check.
// For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
// but href="mailto:foo@bar.com").
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
if (token.text === token.href || token.text === hrefForComparison) {
result += styledLink + stylePrefix;
} else {
result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;
}
}
break;
}

View File

@@ -78,12 +78,14 @@ export {
getJpegDimensions,
getPngDimensions,
getWebpDimensions,
hyperlink,
type ImageDimensions,
type ImageProtocol,
type ImageRenderOptions,
imageFallback,
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.js";

View File

@@ -81,6 +81,11 @@ export function resetCapabilitiesCache(): void {
cachedCapabilities = null;
}
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
export function setCapabilities(caps: TerminalCapabilities): void {
cachedCapabilities = caps;
}
const KITTY_PREFIX = "\x1b_G";
const ITERM2_PREFIX = "\x1b]1337;File=";
@@ -372,6 +377,20 @@ export function renderImage(
return null;
}
/**
* Wrap text in an OSC 8 hyperlink sequence.
* The text is rendered as a clickable hyperlink in terminals that support OSC 8
* (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others).
* In terminals that do not support OSC 8, the escape sequences are ignored
* and only the plain text is displayed.
*
* @param text - The visible text to display
* @param url - The URL to link to
*/
export function hyperlink(text: string, url: string): string {
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
}
export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
const parts: string[] = [];
if (filename) parts.push(filename);

View File

@@ -311,8 +311,16 @@ class AnsiCodeTracker {
private strikethrough = false;
private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
private activeHyperlink: string | null = null; // Active OSC 8 hyperlink URL, or null
process(ansiCode: string): void {
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close)
if (ansiCode.startsWith("\x1b]8;")) {
const m = ansiCode.match(/^\x1b\]8;[^;]*;([^\x1b\x07]*)/);
this.activeHyperlink = m?.[1] ? m[1] : null;
return;
}
if (!ansiCode.endsWith("m")) {
return;
}
@@ -447,11 +455,13 @@ class AnsiCodeTracker {
this.strikethrough = false;
this.fgColor = null;
this.bgColor = null;
// SGR reset does not affect OSC 8 hyperlink state
}
/** Clear all state for reuse. */
clear(): void {
this.reset();
this.activeHyperlink = null;
}
getActiveCodes(): string {
@@ -467,8 +477,11 @@ class AnsiCodeTracker {
if (this.fgColor) codes.push(this.fgColor);
if (this.bgColor) codes.push(this.bgColor);
if (codes.length === 0) return "";
return `\x1b[${codes.join(";")}m`;
let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
if (this.activeHyperlink) {
result += `\x1b]8;;${this.activeHyperlink}\x1b\\`;
}
return result;
}
hasActiveCodes(): boolean {
@@ -482,22 +495,26 @@ class AnsiCodeTracker {
this.hidden ||
this.strikethrough ||
this.fgColor !== null ||
this.bgColor !== null
this.bgColor !== null ||
this.activeHyperlink !== null
);
}
/**
* Get reset codes for attributes that need to be turned off at line end,
* specifically underline which bleeds into padding.
* Returns empty string if no problematic attributes are active.
* Get reset codes for attributes that need to be turned off at line end.
* Underline must be closed to prevent bleeding into padding.
* Active OSC 8 hyperlinks must be closed and re-opened on the next line.
* Returns empty string if no attributes need closing.
*/
getLineEndReset(): string {
// Only underline causes visual bleeding into padding
// Other attributes like colors don't visually bleed to padding
let result = "";
if (this.underline) {
return "\x1b[24m"; // Underline off only
result += "\x1b[24m"; // Underline off only
}
return "";
if (this.activeHyperlink) {
result += "\x1b]8;;\x1b\\"; // Close hyperlink; re-opened at line start via getActiveCodes()
}
return result;
}
}