From 331b728b8dbeab3ee2819d93d180deef40ebde32 Mon Sep 17 00:00:00 2001 From: Phineas1500 <41450967+Phineas1500@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:09:07 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + src/tui/components/assistant-message.ts | 18 +- src/tui/components/hyperlink-markdown.ts | 37 ++++ src/tui/osc8-hyperlinks.test.ts | 151 +++++++++++++++ src/tui/osc8-hyperlinks.ts | 231 +++++++++++++++++++++++ 5 files changed, 434 insertions(+), 4 deletions(-) create mode 100644 src/tui/components/hyperlink-markdown.ts create mode 100644 src/tui/osc8-hyperlinks.test.ts create mode 100644 src/tui/osc8-hyperlinks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95de94f20..0650d9343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/tui/components/assistant-message.ts b/src/tui/components/assistant-message.ts index 95cae114c..89b97fc33 100644 --- a/src/tui/components/assistant-message.ts +++ b/src/tui/components/assistant-message.ts @@ -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); } } diff --git a/src/tui/components/hyperlink-markdown.ts b/src/tui/components/hyperlink-markdown.ts new file mode 100644 index 000000000..6eb0c9fa5 --- /dev/null +++ b/src/tui/components/hyperlink-markdown.ts @@ -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(); + } +} diff --git a/src/tui/osc8-hyperlinks.test.ts b/src/tui/osc8-hyperlinks.test.ts new file mode 100644 index 000000000..eefb63178 --- /dev/null +++ b/src/tui/osc8-hyperlinks.test.ts @@ -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]( "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`); + } + }); +}); diff --git a/src/tui/osc8-hyperlinks.ts b/src/tui/osc8-hyperlinks.ts new file mode 100644 index 000000000..673c21ad7 --- /dev/null +++ b/src/tui/osc8-hyperlinks.ts @@ -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(); + + // Markdown link hrefs: [text](url), with optional <...> and optional title. + const mdLinkRe = /\[(?:[^\]]*)\]\(\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*]+>?(?:\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(); + 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); + }); +}