From e8743e870b9456f13e5873c8b308c2999b9059ee Mon Sep 17 00:00:00 2001 From: Omair Ahmed Date: Thu, 16 Apr 2026 16:13:36 -0400 Subject: [PATCH] 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) Co-authored-by: Mario Zechner --- packages/tui/src/components/markdown.ts | 28 ++++++---- packages/tui/src/index.ts | 2 + packages/tui/src/terminal-image.ts | 19 +++++++ packages/tui/src/utils.ts | 37 ++++++++---- packages/tui/test/markdown.test.ts | 71 ++++++++++++++++++++++-- packages/tui/test/terminal-image.test.ts | 28 +++++++++- packages/tui/test/wrap-ansi.test.ts | 55 ++++++++++++++++++ 7 files changed, 212 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 4399ee53..7bdde68f 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -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; } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 025ecde4..aa3dd68b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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"; diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index e706fedc..a8406a1c 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -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); diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index ad67efdd..7c4ea7c1 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -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;;\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; } } diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index fd265187..029a614e 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -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 ; ; 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", () => { diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index b573837c..feeebafe 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -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")); + }); +}); diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 9fcc89ce..e0944ded 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -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); + }); +});