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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user