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
44 changes: 44 additions & 0 deletions frontend/app/view/term/term-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";
import { makeTermLinkHandlers } from "./term-links";

function linkEvent(metaKey = false, ctrlKey = false): MouseEvent {
return { metaKey, ctrlKey, preventDefault: vi.fn(), clientX: 12, clientY: 34 } as unknown as MouseEvent;
}

describe("terminal link handlers", () => {
it.each([
{ isMacOS: true, modifier: "metaKey" },
{ isMacOS: false, modifier: "ctrlKey" },
])("only opens $modifier-clicked links on the corresponding platform", ({ isMacOS }) => {
const openUri = vi.fn();
const handlers = makeTermLinkHandlers(isMacOS, openUri, vi.fn());
const plainClick = linkEvent();
handlers.activate(plainClick, "https://example.com/first");
expect(plainClick.preventDefault).toHaveBeenCalledOnce();
expect(openUri).not.toHaveBeenCalled();

const wrongModifier = linkEvent(!isMacOS, isMacOS);
handlers.activate(wrongModifier, "https://example.com/second");
expect(openUri).not.toHaveBeenCalled();

const rightModifier = linkEvent(isMacOS, !isMacOS);
handlers.activate(rightModifier, "https://example.com/third");
expect(openUri).toHaveBeenCalledExactlyOnceWith("https://example.com/third");
});

it("shows the destination for OSC 8 links but keeps plain URL hover unchanged", () => {
const onHover = vi.fn();
const handlers = makeTermLinkHandlers(false, vi.fn(), onHover);
handlers.hover(linkEvent(), "https://example.com/visible");
handlers.osc8Hover(linkEvent(), "https://example.com/hidden");
handlers.leave();
expect(onHover.mock.calls).toEqual([
["https://example.com/visible", 12, 34, false],
["https://example.com/hidden", 12, 34, true],
[null, 0, 0, false],
]);
});
});
21 changes: 21 additions & 0 deletions frontend/app/view/term/term-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

export function makeTermLinkHandlers(
isMacOS: boolean,
openUri: (uri: string) => void,
onHover: (uri: string | null, x: number, y: number, showUrl: boolean) => void
) {
return {
activate: (event: MouseEvent, uri: string) => {
event.preventDefault();
if (!(isMacOS ? event.metaKey : event.ctrlKey)) {
return;
}
openUri(uri);
},
hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY, false),
osc8Hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY, true),
leave: () => onHover(null, 0, 0, false),
};
}
22 changes: 22 additions & 0 deletions frontend/app/view/term/term-tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { TermLinkTooltipContent } from "./term-tooltip";

describe("TermLinkTooltipContent", () => {
it("keeps the existing modifier-click hint for plain URLs", () => {
const markup = renderToStaticMarkup(<TermLinkTooltipContent />);
expect(markup).toContain("-click to open link");
expect(markup).not.toContain("break-all");
});

it("shows and safely escapes the destination for OSC 8 links", () => {
const markup = renderToStaticMarkup(<TermLinkTooltipContent url="https://example.com/?next=<script>&x=1" />);
expect(markup).toContain("-click to open link");
expect(markup).toContain("https://example.com/?next=&lt;script&gt;&amp;x=1");
expect(markup).toContain("break-all");
expect(markup).not.toContain("<script>");
});
});
20 changes: 16 additions & 4 deletions frontend/app/view/term/term-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ const HoverDelayMs = 600;
const MaxHoverTimeMs = 2200;
const modKey = PLATFORM === PlatformMacOS ? "Cmd" : "Ctrl";

export function TermLinkTooltipContent({ url }: { url?: string }) {
if (!url) {
return <span>{modKey}-click to open link</span>;
}
return (
<div className="max-w-[min(28rem,80vw)]">
<div>{modKey}-click to open link</div>
<div className="break-all">{url}</div>
</div>
);
}

interface TermLinkTooltipProps {
/**
* The live TermWrap instance. Pass the instance directly (not a ref) so
Expand All @@ -91,7 +103,7 @@ interface TermLinkTooltipProps {
* prevents unnecessary re-renders of the parent TerminalView.
*/
export const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }: TermLinkTooltipProps) {
const [mousePos, setMousePos] = React.useState<{ x: number; y: number } | null>(null);
const [mousePos, setMousePos] = React.useState<{ x: number; y: number; url?: string } | null>(null);
const timeoutRef = React.useRef<number | null>(null);
const maxTimeoutRef = React.useRef<number | null>(null);

Expand All @@ -100,7 +112,7 @@ export const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }:
return;
}

termWrap.onLinkHover = (uri: string | null, mouseX: number, mouseY: number) => {
termWrap.onLinkHover = (uri: string | null, mouseX: number, mouseY: number, showUrl: boolean) => {
clearTimeoutRef(timeoutRef);

if (uri == null) {
Expand All @@ -112,7 +124,7 @@ export const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }:
// Show after a short delay so fast mouse movements don't flicker.
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
setMousePos({ x: mouseX, y: mouseY });
setMousePos({ x: mouseX, y: mouseY, url: showUrl ? uri : undefined });
// Auto-dismiss after MaxHoverTimeMs so the tooltip doesn't linger forever.
clearTimeoutRef(maxTimeoutRef);
maxTimeoutRef.current = window.setTimeout(() => {
Expand All @@ -130,5 +142,5 @@ export const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }:
};
}, [termWrap]);

return <TermTooltip mousePos={mousePos} content={<span>{modKey}-click to open link</span>} />;
return <TermTooltip mousePos={mousePos} content={<TermLinkTooltipContent url={mousePos?.url} />} />;
});
48 changes: 19 additions & 29 deletions frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
isClaudeCodeCommand,
type ShellIntegrationStatus,
} from "./osc-handlers";
import { makeTermLinkHandlers } from "./term-links";
import {
bufferLinesToText,
createTempFileFromBlob,
Expand Down Expand Up @@ -104,7 +105,7 @@ export class TermWrap {
claudeCodeActiveAtom: jotai.PrimitiveAtom<boolean>;
nodeModel: BlockNodeModel; // this can be null
hoveredLinkUri: string | null = null;
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number, showUrl: boolean) => void;

// Paste deduplication
// xterm.js paste() method triggers onData event, which can cause duplicate sends
Expand Down Expand Up @@ -143,41 +144,30 @@ export class TermWrap {
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
this.claudeCodeActiveAtom = jotai.atom(false);
this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
this.terminal = new Terminal(options);
const linkHandlers = makeTermLinkHandlers(
PLATFORM === PlatformMacOS,
(uri) => fireAndForget(() => openLink(uri)),
(uri, x, y, showUrl) => {
this.hoveredLinkUri = uri;
this.onLinkHover?.(uri, x, y, showUrl);
}
);
this.terminal = new Terminal({
...options,
linkHandler: {
activate: linkHandlers.activate,
hover: linkHandlers.osc8Hover,
leave: linkHandlers.leave,
},
});
this.fitAddon = new FitAddon();
this.serializeAddon = new SerializeAddon();
this.searchAddon = new SearchAddon();
this.terminal.loadAddon(this.searchAddon);
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(this.serializeAddon);
this.terminal.loadAddon(
new WebLinksAddon(
(e, uri) => {
e.preventDefault();
switch (PLATFORM) {
case PlatformMacOS:
if (e.metaKey) {
fireAndForget(() => openLink(uri));
}
break;
default:
if (e.ctrlKey) {
fireAndForget(() => openLink(uri));
}
break;
}
},
{
hover: (e, uri) => {
this.hoveredLinkUri = uri;
this.onLinkHover?.(uri, e.clientX, e.clientY);
},
leave: () => {
this.hoveredLinkUri = null;
this.onLinkHover?.(null, 0, 0);
},
}
)
new WebLinksAddon(linkHandlers.activate, { hover: linkHandlers.hover, leave: linkHandlers.leave })
);
this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? "webgl" : "dom");
// Register OSC handlers
Expand Down
Loading