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,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", () => {
|
||||
|
||||
Reference in New Issue
Block a user