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;
}