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,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", () => {

View File

@@ -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"));
});
});

View File

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