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:
@@ -726,6 +726,7 @@ Docs: https://docs.openclaw.ai
|
||||
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
|
||||
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
|
||||
- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
|
||||
- TUI: render assistant markdown URLs with OSC 8 hyperlinks (including wrapped URL fragments) so links stay clickable without breaking ANSI styling. (#17777) Thanks @Phineas1500.
|
||||
- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
|
||||
|
||||
## 2026.2.14
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { MarkdownMessageComponent } from "./markdown-message.js";
|
||||
import { Container, Spacer } from "@mariozechner/pi-tui";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
import { HyperlinkMarkdown } from "./hyperlink-markdown.js";
|
||||
|
||||
export class AssistantMessageComponent extends Container {
|
||||
private body: HyperlinkMarkdown;
|
||||
|
||||
export class AssistantMessageComponent extends MarkdownMessageComponent {
|
||||
constructor(text: string) {
|
||||
super(text, 0, {
|
||||
super();
|
||||
this.body = new HyperlinkMarkdown(text, 1, 0, markdownTheme, {
|
||||
// Keep assistant body text in terminal default foreground so contrast
|
||||
// follows the user's terminal theme (dark or light).
|
||||
color: (line) => theme.assistantText(line),
|
||||
});
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.body);
|
||||
}
|
||||
|
||||
setText(text: string) {
|
||||
this.body.setText(text);
|
||||
}
|
||||
}
|
||||
|
||||
37
src/tui/components/hyperlink-markdown.ts
Normal file
37
src/tui/components/hyperlink-markdown.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Component, DefaultTextStyle, MarkdownTheme } from "@mariozechner/pi-tui";
|
||||
import { Markdown } from "@mariozechner/pi-tui";
|
||||
import { addOsc8Hyperlinks, extractUrls } from "../osc8-hyperlinks.js";
|
||||
|
||||
/**
|
||||
* Wrapper around pi-tui's Markdown component that adds OSC 8 terminal
|
||||
* hyperlinks to rendered output, making URLs clickable even when broken
|
||||
* across multiple lines by word wrapping.
|
||||
*/
|
||||
export class HyperlinkMarkdown implements Component {
|
||||
private inner: Markdown;
|
||||
private urls: string[];
|
||||
|
||||
constructor(
|
||||
text: string,
|
||||
paddingX: number,
|
||||
paddingY: number,
|
||||
theme: MarkdownTheme,
|
||||
options?: DefaultTextStyle,
|
||||
) {
|
||||
this.inner = new Markdown(text, paddingX, paddingY, theme, options);
|
||||
this.urls = extractUrls(text);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return addOsc8Hyperlinks(this.inner.render(width), this.urls);
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.inner.setText(text);
|
||||
this.urls = extractUrls(text);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.inner.invalidate();
|
||||
}
|
||||
}
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
231
src/tui/osc8-hyperlinks.ts
Normal file
231
src/tui/osc8-hyperlinks.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// Regex patterns for ANSI escape sequences (constructed from strings to
|
||||
// satisfy the no-control-regex lint rule).
|
||||
const SGR_PATTERN = "\\x1b\\[[0-9;]*m";
|
||||
const OSC8_PATTERN = "\\x1b\\]8;;.*?(?:\\x07|\\x1b\\\\)";
|
||||
const ANSI_RE = new RegExp(`${SGR_PATTERN}|${OSC8_PATTERN}`, "g");
|
||||
const SGR_START_RE = new RegExp(`^${SGR_PATTERN}`);
|
||||
const OSC8_START_RE = new RegExp(`^${OSC8_PATTERN}`);
|
||||
|
||||
/** Wrap text with an OSC 8 terminal hyperlink. */
|
||||
export function wrapOsc8(url: string, text: string): string {
|
||||
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all unique URLs from raw markdown text.
|
||||
* Finds both bare URLs and markdown link hrefs [text](url).
|
||||
*/
|
||||
export function extractUrls(markdown: string): string[] {
|
||||
const urls = new Set<string>();
|
||||
|
||||
// Markdown link hrefs: [text](url), with optional <...> and optional title.
|
||||
const mdLinkRe = /\[(?:[^\]]*)\]\(\s*<?(https?:\/\/[^)\s>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = mdLinkRe.exec(markdown)) !== null) {
|
||||
urls.add(m[1]);
|
||||
}
|
||||
|
||||
// Bare URLs (remove markdown links first to avoid double-matching)
|
||||
const stripped = markdown.replace(
|
||||
/\[(?:[^\]]*)\]\(\s*<?https?:\/\/[^)\s>]+>?(?:\s+["'][^"']*["'])?\s*\)/g,
|
||||
"",
|
||||
);
|
||||
const bareRe = /https?:\/\/[^\s)\]>]+/g;
|
||||
while ((m = bareRe.exec(stripped)) !== null) {
|
||||
urls.add(m[0]);
|
||||
}
|
||||
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
/** Strip ANSI SGR and OSC 8 sequences to get visible text. */
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(ANSI_RE, "");
|
||||
}
|
||||
|
||||
interface UrlRange {
|
||||
start: number; // visible text start index
|
||||
end: number; // visible text end index (exclusive)
|
||||
url: string; // full URL to link to
|
||||
}
|
||||
|
||||
/**
|
||||
* Find URL ranges in a line's visible text, handling cross-line URL splits.
|
||||
*/
|
||||
function findUrlRanges(
|
||||
visibleText: string,
|
||||
knownUrls: string[],
|
||||
pending: { url: string; consumed: number } | null,
|
||||
): { ranges: UrlRange[]; pending: { url: string; consumed: number } | null } {
|
||||
const ranges: UrlRange[] = [];
|
||||
let newPending: { url: string; consumed: number } | null = null;
|
||||
let searchFrom = 0;
|
||||
|
||||
// Handle continuation of a URL broken from the previous line
|
||||
if (pending) {
|
||||
const remaining = pending.url.slice(pending.consumed);
|
||||
const trimmed = visibleText.trimStart();
|
||||
const leadingSpaces = visibleText.length - trimmed.length;
|
||||
|
||||
let matchLen = 0;
|
||||
for (let j = 0; j < remaining.length && j < trimmed.length; j++) {
|
||||
if (remaining[j] === trimmed[j]) {
|
||||
matchLen++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchLen > 0) {
|
||||
ranges.push({
|
||||
start: leadingSpaces,
|
||||
end: leadingSpaces + matchLen,
|
||||
url: pending.url,
|
||||
});
|
||||
searchFrom = leadingSpaces + matchLen;
|
||||
|
||||
if (pending.consumed + matchLen < pending.url.length) {
|
||||
newPending = { url: pending.url, consumed: pending.consumed + matchLen };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find new URL starts in visible text
|
||||
const urlRe = /https?:\/\/[^\s)\]>]+/g;
|
||||
urlRe.lastIndex = searchFrom;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = urlRe.exec(visibleText)) !== null) {
|
||||
const fragment = match[0];
|
||||
const start = match.index;
|
||||
|
||||
// Resolve fragment to a known URL (exact > prefix > superstring)
|
||||
let resolvedUrl = fragment;
|
||||
let found = false;
|
||||
|
||||
for (const known of knownUrls) {
|
||||
if (known === fragment) {
|
||||
resolvedUrl = known;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
let bestLen = 0;
|
||||
for (const known of knownUrls) {
|
||||
if (known.startsWith(fragment) && known.length > bestLen) {
|
||||
resolvedUrl = known;
|
||||
bestLen = known.length;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
let bestLen = 0;
|
||||
for (const known of knownUrls) {
|
||||
if (fragment.startsWith(known) && known.length > bestLen) {
|
||||
resolvedUrl = known;
|
||||
bestLen = known.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ranges.push({ start, end: start + fragment.length, url: resolvedUrl });
|
||||
|
||||
// If fragment is a strict prefix of the resolved URL, it may be split
|
||||
if (resolvedUrl.length > fragment.length && resolvedUrl.startsWith(fragment)) {
|
||||
newPending = { url: resolvedUrl, consumed: fragment.length };
|
||||
}
|
||||
}
|
||||
|
||||
return { ranges, pending: newPending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply OSC 8 hyperlink sequences to a line based on visible-text URL ranges.
|
||||
* Walks through the raw string character by character, inserting OSC 8
|
||||
* open/close sequences at URL range boundaries while preserving ANSI codes.
|
||||
*/
|
||||
function applyOsc8Ranges(line: string, ranges: UrlRange[]): string {
|
||||
if (ranges.length === 0) {
|
||||
return line;
|
||||
}
|
||||
|
||||
// Build a lookup: visible position → URL
|
||||
const urlAt = new Map<number, string>();
|
||||
for (const r of ranges) {
|
||||
for (let p = r.start; p < r.end; p++) {
|
||||
urlAt.set(p, r.url);
|
||||
}
|
||||
}
|
||||
|
||||
let result = "";
|
||||
let visiblePos = 0;
|
||||
let activeUrl: string | null = null;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
// Fast path: only check for escape sequences when we see ESC
|
||||
if (line.charCodeAt(i) === 0x1b) {
|
||||
// ANSI SGR sequence
|
||||
const sgr = line.slice(i).match(SGR_START_RE);
|
||||
if (sgr) {
|
||||
result += sgr[0];
|
||||
i += sgr[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Existing OSC 8 sequence (pass through)
|
||||
const osc = line.slice(i).match(OSC8_START_RE);
|
||||
if (osc) {
|
||||
result += osc[0];
|
||||
i += osc[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Visible character — toggle OSC 8 at range boundaries
|
||||
const targetUrl = urlAt.get(visiblePos) ?? null;
|
||||
if (targetUrl !== activeUrl) {
|
||||
if (activeUrl !== null) {
|
||||
result += "\x1b]8;;\x07";
|
||||
}
|
||||
if (targetUrl !== null) {
|
||||
result += `\x1b]8;;${targetUrl}\x07`;
|
||||
}
|
||||
activeUrl = targetUrl;
|
||||
}
|
||||
|
||||
result += line[i];
|
||||
visiblePos++;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (activeUrl !== null) {
|
||||
result += "\x1b]8;;\x07";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add OSC 8 hyperlinks to rendered lines using a pre-extracted URL list.
|
||||
*
|
||||
* For each line, finds URL-like substrings in the visible text, matches them
|
||||
* against known URLs, and wraps each fragment with OSC 8 escape sequences.
|
||||
* Handles URLs broken across multiple lines by pi-tui's word wrapping.
|
||||
*/
|
||||
export function addOsc8Hyperlinks(lines: string[], urls: string[]): string[] {
|
||||
if (urls.length === 0) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
let pending: { url: string; consumed: number } | null = null;
|
||||
|
||||
return lines.map((line) => {
|
||||
const visible = stripAnsi(line);
|
||||
const result = findUrlRanges(visible, urls, pending);
|
||||
pending = result.pending;
|
||||
return applyOsc8Ranges(line, result.ranges);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user