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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { Chalk } from "chalk";
|
||||
import { Markdown } from "../src/components/markdown.js";
|
||||
import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.js";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
import { defaultMarkdownTheme } from "./test-themes.js";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
@@ -325,6 +326,8 @@ describe("Markdown component", () => {
|
||||
});
|
||||
|
||||
it("should wrap long unbroken tokens inside table cells (not only at line start)", () => {
|
||||
// Pin to no-hyperlinks so width checks work on plain text without OSC 8 sequences.
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const url = "https://example.com/this/is/a/very/long/url/that/should/wrap";
|
||||
const markdown = new Markdown(
|
||||
`| Value |
|
||||
@@ -337,6 +340,7 @@ describe("Markdown component", () => {
|
||||
|
||||
const width = 30;
|
||||
const lines = markdown.render(width);
|
||||
resetCapabilitiesCache();
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
||||
|
||||
for (const line of plainLines) {
|
||||
@@ -1087,7 +1091,13 @@ bar`,
|
||||
});
|
||||
|
||||
describe("Links", () => {
|
||||
afterEach(() => {
|
||||
resetCapabilitiesCache();
|
||||
});
|
||||
|
||||
it("should not duplicate URL for autolinked emails", () => {
|
||||
// Hyperlinks capability does not affect the mailto: display check.
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
@@ -1100,6 +1110,7 @@ bar`,
|
||||
});
|
||||
|
||||
it("should not duplicate URL for bare URLs", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
@@ -1111,29 +1122,79 @@ bar`,
|
||||
assert.strictEqual(urlCount, 1, "URL should appear exactly once");
|
||||
});
|
||||
|
||||
it("should show URL for explicit markdown links with different text", () => {
|
||||
it("should show URL in parentheses when hyperlinks are not supported", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
const joinedPlain = plainLines.join(" ");
|
||||
|
||||
// Should show both link text and URL
|
||||
assert.ok(joinedPlain.includes("click here"), "Should contain link text");
|
||||
assert.ok(joinedPlain.includes("(https://example.com)"), "Should show URL in parentheses");
|
||||
});
|
||||
|
||||
it("should show URL for explicit mailto links with different text", () => {
|
||||
it("should show mailto URL in parentheses when hyperlinks are not supported", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
const joinedPlain = plainLines.join(" ");
|
||||
|
||||
// Should show both link text and mailto URL
|
||||
assert.ok(joinedPlain.includes("Email me"), "Should contain link text");
|
||||
assert.ok(joinedPlain.includes("(mailto:test@example.com)"), "Should show mailto URL in parentheses");
|
||||
});
|
||||
|
||||
it("should emit OSC 8 hyperlink sequence when terminal supports hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
// OSC 8 open: ESC ] 8 ; ; <url> ESC \
|
||||
assert.ok(joined.includes("\x1b]8;;https://example.com\x1b\\"), "Should contain OSC 8 open sequence");
|
||||
// OSC 8 close: ESC ] 8 ; ; ESC \
|
||||
assert.ok(joined.includes("\x1b]8;;\x1b\\"), "Should contain OSC 8 close sequence");
|
||||
// Visible text is present
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b[^a-zA-Z]*[a-zA-Z]|\x1b\].*?\x1b\\/g, ""));
|
||||
assert.ok(plainLines.join("").includes("click here"), "Should contain link text");
|
||||
// URL is NOT printed inline as plain text
|
||||
const rawPlain = lines.map((line) =>
|
||||
line.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""),
|
||||
);
|
||||
assert.ok(!rawPlain.join("").includes("(https://example.com)"), "URL should not appear inline in parentheses");
|
||||
});
|
||||
|
||||
it("should use OSC 8 for mailto links when terminal supports hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(
|
||||
joined.includes("\x1b]8;;mailto:test@example.com\x1b\\"),
|
||||
"Should contain OSC 8 open with mailto URL",
|
||||
);
|
||||
assert.ok(joined.includes("\x1b]8;;\x1b\\"), "Should contain OSC 8 close sequence");
|
||||
});
|
||||
|
||||
it("should use OSC 8 for bare URLs when terminal supports hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(joined.includes("\x1b]8;;https://example.com\x1b\\"), "Should contain OSC 8 hyperlink");
|
||||
// URL should not also appear as raw parenthetical text
|
||||
const rawPlain = lines.map((line) =>
|
||||
line.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""),
|
||||
);
|
||||
assert.ok(!rawPlain.join("").includes("(https://example.com)"), "URL should not appear twice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML-like tags in text", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { isImageLine } from "../src/terminal-image.js";
|
||||
import { hyperlink, isImageLine } from "../src/terminal-image.js";
|
||||
|
||||
describe("isImageLine", () => {
|
||||
describe("iTerm2 image protocol", () => {
|
||||
@@ -151,3 +151,29 @@ describe("isImageLine", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hyperlink", () => {
|
||||
it("wraps text in OSC 8 open and close sequences", () => {
|
||||
const result = hyperlink("click me", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("preserves ANSI styling inside the hyperlink", () => {
|
||||
const styled = "\x1b[4m\x1b[34mclick me\x1b[0m";
|
||||
const result = hyperlink(styled, "https://example.com");
|
||||
assert.ok(result.startsWith("\x1b]8;;https://example.com\x1b\\"));
|
||||
assert.ok(result.includes(styled));
|
||||
assert.ok(result.endsWith("\x1b]8;;\x1b\\"));
|
||||
});
|
||||
|
||||
it("works with empty text", () => {
|
||||
const result = hyperlink("", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("works with file:// URIs", () => {
|
||||
const result = hyperlink("README.md", "file:///home/user/README.md");
|
||||
assert.ok(result.includes("file:///home/user/README.md"));
|
||||
assert.ok(result.includes("README.md"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,3 +150,58 @@ describe("wrapTextWithAnsi", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => {
|
||||
it("re-emits OSC 8 open at the start of continuation lines", () => {
|
||||
// A hyperlink whose text is long enough to wrap
|
||||
const url = "https://example.com";
|
||||
// OSC 8 open + text that is 10 visible chars + OSC 8 close
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
// Every line that contains visible text from inside the hyperlink
|
||||
// should start with the OSC 8 open sequence (or be preceded by it).
|
||||
for (const line of lines) {
|
||||
// If the line has visible content it must begin with the OSC 8 re-open
|
||||
// OR it is the line where the close appeared with no following content.
|
||||
const stripped = line.replace(/\x1b\]8;;[^\x1b\x07]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
if (stripped.trim().length > 0) {
|
||||
assert.ok(
|
||||
line.startsWith(`\x1b]8;;${url}\x1b\\`) || line.includes(`\x1b]8;;${url}\x1b\\`),
|
||||
`Line "${line}" has visible text but no OSC 8 re-open`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("closes OSC 8 before each line break", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i];
|
||||
// Every non-final line that is inside a hyperlink should end with the close
|
||||
if (line.includes(`\x1b]8;;${url}\x1b\\`)) {
|
||||
assert.ok(
|
||||
line.endsWith("\x1b]8;;\x1b\\"),
|
||||
`Non-final line "${line}" is inside a hyperlink but does not close it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;
|
||||
const lines = wrapTextWithAnsi(input, 80);
|
||||
|
||||
// With width 80 everything fits on one line; there should be exactly one
|
||||
// OSC 8 open and one OSC 8 close.
|
||||
assert.strictEqual(lines.length, 1);
|
||||
const openCount = (lines[0].match(/\x1b\]8;;https:[^\x1b]+\x1b\\/g) ?? []).length;
|
||||
const closeCount = (lines[0].match(/\x1b\]8;;\x1b\\/g) ?? []).length;
|
||||
assert.strictEqual(openCount, 1);
|
||||
assert.strictEqual(closeCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user