mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 00:41:25 +00:00
fix(tui): add OSC 8 hyperlinks for wrapped URLs (#17814)
* feat(tui): add OSC 8 hyperlinks to make wrapped URLs clickable Long URLs that exceed terminal width get broken across lines by pi-tui's word wrapping, making them unclickable. Post-process rendered markdown output to add OSC 8 terminal hyperlink sequences around URL fragments, so each line fragment links to the full URL. Gracefully degrades on terminals without OSC 8 support. * tui: harden OSC8 URL extraction and prefix resolution * tui: add changelog entry for OSC 8 markdown hyperlinks --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
151
src/tui/osc8-hyperlinks.test.ts
Normal file
151
src/tui/osc8-hyperlinks.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addOsc8Hyperlinks, extractUrls, wrapOsc8 } from "./osc8-hyperlinks.js";
|
||||
|
||||
describe("wrapOsc8", () => {
|
||||
it("wraps text with OSC 8 open and close sequences", () => {
|
||||
const result = wrapOsc8("https://example.com", "click here");
|
||||
expect(result).toBe("\x1b]8;;https://example.com\x07click here\x1b]8;;\x07");
|
||||
});
|
||||
|
||||
it("handles empty text", () => {
|
||||
const result = wrapOsc8("https://example.com", "");
|
||||
expect(result).toBe("\x1b]8;;https://example.com\x07\x1b]8;;\x07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractUrls", () => {
|
||||
it("extracts bare URLs", () => {
|
||||
const urls = extractUrls("Check out https://example.com for more info");
|
||||
expect(urls).toEqual(["https://example.com"]);
|
||||
});
|
||||
|
||||
it("extracts multiple bare URLs", () => {
|
||||
const urls = extractUrls("Visit https://foo.com and http://bar.com");
|
||||
expect(urls).toContain("https://foo.com");
|
||||
expect(urls).toContain("http://bar.com");
|
||||
expect(urls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("extracts markdown link hrefs", () => {
|
||||
const urls = extractUrls("[Click here](https://example.com/path)");
|
||||
expect(urls).toEqual(["https://example.com/path"]);
|
||||
});
|
||||
|
||||
it("extracts markdown links with angle brackets and title text", () => {
|
||||
const urls = extractUrls('[Click here](<https://example.com/path> "Example Title")');
|
||||
expect(urls).toEqual(["https://example.com/path"]);
|
||||
});
|
||||
|
||||
it("extracts both bare URLs and markdown links", () => {
|
||||
const md = "See [docs](https://docs.example.com) and https://api.example.com";
|
||||
const urls = extractUrls(md);
|
||||
expect(urls).toContain("https://docs.example.com");
|
||||
expect(urls).toContain("https://api.example.com");
|
||||
expect(urls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("deduplicates URLs", () => {
|
||||
const md = "Visit https://example.com and [link](https://example.com)";
|
||||
const urls = extractUrls(md);
|
||||
expect(urls).toEqual(["https://example.com"]);
|
||||
});
|
||||
|
||||
it("returns empty array for text without URLs", () => {
|
||||
expect(extractUrls("No links here")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles URLs with query params and fragments", () => {
|
||||
const urls = extractUrls("https://example.com/path?q=1&r=2#section");
|
||||
expect(urls).toEqual(["https://example.com/path?q=1&r=2#section"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOsc8Hyperlinks", () => {
|
||||
it("returns lines unchanged when no URLs", () => {
|
||||
const lines = ["Hello world", "No links here"];
|
||||
expect(addOsc8Hyperlinks(lines, [])).toEqual(lines);
|
||||
});
|
||||
|
||||
it("wraps a single-line URL with OSC 8", () => {
|
||||
const url = "https://example.com";
|
||||
const lines = [`Visit ${url} for info`];
|
||||
const result = addOsc8Hyperlinks(lines, [url]);
|
||||
|
||||
expect(result[0]).toContain(`\x1b]8;;${url}\x07`);
|
||||
expect(result[0]).toContain(`\x1b]8;;\x07`);
|
||||
// The URL text should be between open and close
|
||||
expect(result[0]).toBe(`Visit \x1b]8;;${url}\x07${url}\x1b]8;;\x07 for info`);
|
||||
});
|
||||
|
||||
it("wraps a URL broken across two lines", () => {
|
||||
const fullUrl = "https://example.com/very/long/path/to/resource";
|
||||
const lines = ["https://example.com/very/long/pa", "th/to/resource"];
|
||||
const result = addOsc8Hyperlinks(lines, [fullUrl]);
|
||||
|
||||
// Line 1: fragment should be wrapped with the full URL
|
||||
expect(result[0]).toContain(`\x1b]8;;${fullUrl}\x07`);
|
||||
// Line 2: continuation should also be wrapped
|
||||
expect(result[1]).toContain(`\x1b]8;;${fullUrl}\x07`);
|
||||
});
|
||||
|
||||
it("handles URL with ANSI styling codes", () => {
|
||||
const url = "https://example.com";
|
||||
// Simulate styled text: green URL
|
||||
const styledLine = `\x1b[32m${url}\x1b[0m`;
|
||||
const result = addOsc8Hyperlinks([styledLine], [url]);
|
||||
|
||||
// Should preserve ANSI codes and add OSC 8 around the visible URL
|
||||
expect(result[0]).toContain("\x1b[32m");
|
||||
expect(result[0]).toContain("\x1b[0m");
|
||||
expect(result[0]).toContain(`\x1b]8;;${url}\x07`);
|
||||
expect(result[0]).toContain(`\x1b]8;;\x07`);
|
||||
});
|
||||
|
||||
it("handles named link rendered as text (url)", () => {
|
||||
const url = "https://github.com/org/repo";
|
||||
// pi-tui renders [text](url) as "text (url)"
|
||||
const line = `Click here (${url})`;
|
||||
const result = addOsc8Hyperlinks([line], [url]);
|
||||
|
||||
// The URL part should be wrapped with OSC 8
|
||||
expect(result[0]).toContain(`\x1b]8;;${url}\x07`);
|
||||
});
|
||||
|
||||
it("handles multiple URLs on the same line", () => {
|
||||
const url1 = "https://foo.com";
|
||||
const url2 = "https://bar.com";
|
||||
const line = `${url1} and ${url2}`;
|
||||
const result = addOsc8Hyperlinks([line], [url1, url2]);
|
||||
|
||||
expect(result[0]).toContain(`\x1b]8;;${url1}\x07`);
|
||||
expect(result[0]).toContain(`\x1b]8;;${url2}\x07`);
|
||||
});
|
||||
|
||||
it("does not modify lines without URL text", () => {
|
||||
const url = "https://example.com";
|
||||
const lines = ["Just some text", "No URLs here"];
|
||||
const result = addOsc8Hyperlinks(lines, [url]);
|
||||
|
||||
expect(result).toEqual(lines);
|
||||
});
|
||||
|
||||
it("prefers the longest known URL when a fragment matches multiple prefixes", () => {
|
||||
const short = "https://example.com/api/v2/users";
|
||||
const long = "https://example.com/api/v2/users/list";
|
||||
const fragment = "https://example.com/api/v2/u";
|
||||
const result = addOsc8Hyperlinks([fragment], [short, long]);
|
||||
expect(result[0]).toContain(`\x1b]8;;${long}\x07${fragment}\x1b]8;;\x07`);
|
||||
});
|
||||
|
||||
it("handles URL split across three lines", () => {
|
||||
const fullUrl = "https://example.com/a/very/long/path/that/keeps/going/and/going";
|
||||
const lines = ["https://example.com/a/very/lon", "g/path/that/keeps/going/and/g", "oing"];
|
||||
const result = addOsc8Hyperlinks(lines, [fullUrl]);
|
||||
|
||||
// All three lines should have OSC 8 wrapping
|
||||
for (const line of result) {
|
||||
expect(line).toContain(`\x1b]8;;${fullUrl}\x07`);
|
||||
expect(line).toContain(`\x1b]8;;\x07`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user