Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/src/core/export-html/template.css
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,10 @@
text-decoration: underline;
}

.markdown-content .markdown-link-url {
color: var(--mdLinkUrl);
}

.markdown-content code {
background: rgba(128, 128, 128, 0.2);
color: var(--mdCode);
Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/src/core/export-html/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -1752,7 +1752,11 @@
link(token) {
const href = sanitizeMarkdownUrl(token.href);
if (href === null) {
return this.parser.parseInline(token.tokens);
// Not a link (a local file, another scheme): its text, and where it goes as text, as the terminal shows it.
const label = this.parser.parseInline(token.tokens);
return token.text === token.href
? label
: label + ' <span class="markdown-link-url">(' + escapeHtml(token.href) + ')</span>';
}
let out = '<a href="' + escapeHtml(href) + '"';
if (token.title) {
Expand Down
23 changes: 23 additions & 0 deletions packages/coding-agent/test/export-html-session-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,29 @@ describe("HTML export of a session file", () => {
expect(script).not.toContain("<img");
});

// Issue #297: a link the page does not link to shows where it goes, as text.
it("shows the URL of a link it does not link to after its text", () => {
const file = viewer.safeMarkedParse("[Open image](file:///C:/Temp/example.png)");
expect(file).not.toContain("<a");
expect(file).toContain('Open image <span class="markdown-link-url">(file:///C:/Temp/example.png)</span>');

const script = viewer.safeMarkedParse('[Run it](javascript:alert("<b>x</b>"))');
expect(script).not.toContain("<a");
expect(script).not.toContain("<b>");
expect(script).toContain(
'Run it <span class="markdown-link-url">(javascript:alert(&quot;&lt;b&gt;x&lt;/b&gt;&quot;))</span>',
);

const bare = viewer.safeMarkedParse("<file:///tmp/x.png>");
expect(bare).not.toContain("<a");
expect(bare).not.toContain("markdown-link-url");
expect(bare).toContain("file:///tmp/x.png");

const web = viewer.safeMarkedParse("[GitHub](https://github.com)");
expect(web).toContain('<a href="https://github.com">GitHub</a>');
expect(web).not.toContain("markdown-link-url");
});

it("allows the page no remote loads", () => {
const policy = html.match(/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/)?.[1];
expect(policy).toBeDefined();
Expand Down
38 changes: 21 additions & 17 deletions packages/tui/src/components/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
INTERNED_LINE_LENGTH,
internLine,
isWebLink,
localFileLinkTarget,
type SanitizedTerminalText,
sanitizeTerminalText,
sanitizeTerminalTextFrom,
Expand Down Expand Up @@ -2287,24 +2288,27 @@ export class Markdown implements Component {
case "link": {
const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
const styledLink = this.theme.link(this.theme.underline(linkText));
if (getCapabilities().hyperlinks) {
// OSC 8: render as a clickable hyperlink. The URL is not printed inline,
// so we always show only the link text regardless of whether it matches href.
// Only a web or mail link is clickable: a click on a local path or another scheme (file:, an app's
// handler, or a target read as an option by `open`) could run a program instead of showing a page.
result += (isWebLink(token.href) ? hyperlink(styledLink, token.href) : styledLink) + stylePrefix;
} else {
// Fallback: print URL in parentheses when text differs from href.
// Compare raw token.text (not styled) against href for the equality check.
// For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
// but href="mailto:foo@bar.com").
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
if (token.text === token.href || token.text === hrefForComparison) {
result += styledLink + stylePrefix;
} else {
result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;
}
const hyperlinks = getCapabilities().hyperlinks;
if (hyperlinks && isWebLink(token.href)) {
// OSC 8: a web or mail link is drawn as its text only, a hyperlink that a browser or mail program opens.
result += hyperlink(styledLink, token.href) + stylePrefix;
break;
}
// Any other link, and every link without hyperlinks, shows its URL in parentheses after its text (unless
// the text is the URL), so where it goes is never hidden. Compare raw token.text (not styled) against href.
// For mailto: links strip the prefix (autolinked emails use text="foo@bar.com" but
// href="mailto:foo@bar.com").
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
const shown =
token.text === token.href || token.text === hrefForComparison
? styledLink
: styledLink + this.theme.linkUrl(` (${token.href})`);
// A local file (localFileLinkTarget()) is a hyperlink, text and URL together: a click opens what a click on
// the printed URL opens (in fullscreen mode, through TuiAltScreen's openUrl). Any other link is not: a click
// on a relative path or another scheme (an app's handler, a target `open` reads as an option) could run a
// program.
const target = hyperlinks ? localFileLinkTarget(token.href) : undefined;
result += (target === undefined ? shown : hyperlink(shown, target)) + stylePrefix;
break;
}

Expand Down
27 changes: 27 additions & 0 deletions packages/tui/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,33 @@ export function isWebLink(url: string): boolean {
return /^(?:https?|mailto):/i.test(url);
}

/** Longest URL drawn as a hyperlink: VTE ignores longer ones (2083 bytes). */
const MAX_HYPERLINK_URL_LENGTH = 2048;

/**
* What a link to `url` may be a hyperlink (OSC 8) to, when `url` is a `file:` URL of an absolute path on this computer
* (`file:///…`, `file://localhost/…`): the URL as parsed, with spaces and other characters percent-encoded. Such a link is
* drawn with its URL printed after its text, as Pi draws the paths tools read and write, so the hyperlink opens what a
* click on the printed URL opens. Undefined for anything else: a file on another computer (`file://server/share`, or
* `file:////server/share`, a share Windows would sign in to), a URL without `//` (`file:C:/x`), one with an encoded
* separator (`%2f`, `%5c`) or control character, one with control or format characters (which can make the printed URL
* read as another), or one too long for terminals to keep.
*/
export function localFileLinkTarget(url: string): string | undefined {
if (!/^file:\/\/(?:localhost)?\//i.test(url) || /[\p{Cc}\p{Cf}]/u.test(url) || !URL.canParse(url)) return undefined;
const parsed = new URL(url);
if (
parsed.protocol !== "file:" ||
parsed.hostname !== "" ||
parsed.pathname.startsWith("//") ||
/%(?:2f|5c|[01][0-9a-f]|7f|c2%[89][0-9a-f])/i.test(parsed.href) ||
parsed.href.length > MAX_HYPERLINK_URL_LENGTH
) {
return undefined;
}
return parsed.href;
}

function extractAnsiCodes(str: string): string {
let result = "";
let i = str.indexOf("\x1b");
Expand Down
Loading
Loading