From 0314fd9b93c5f55eb68f98480d84500d67a75b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9A=E7=8E=A9=E5=B9=BB=E7=81=B5qwq?= Date: Thu, 27 Aug 2026 21:44:32 +0800 Subject: [PATCH] Merge pull request #419 from HuanLinOTO/feat/terminal-ctrl-click-url feat(terminal): open URLs with Ctrl+Click (Cmd+Click on mac) --- src/client/TerminalView.tsx | 41 +++++ src/client/terminal-links.ts | 194 +++++++++++++++++++++++ tests/terminal-links.spec.ts | 287 +++++++++++++++++++++++++++++++++++ 3 files changed, 522 insertions(+) create mode 100644 src/client/terminal-links.ts create mode 100644 tests/terminal-links.spec.ts diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index a226fc9..eba3d93 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -42,6 +42,11 @@ import { api, type SessionScope, type TerminalDepsStatus } from './api.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts' import { resolveTerminalFont } from './terminal-font.ts' +import { + buildTerminalLinks, + shouldActivateTerminalLink, + openTerminalUrl, +} from './terminal-links.ts' import css from './sidebar.module.css' /** How many consecutive unreasoned failures before showing the error banner. */ @@ -129,6 +134,41 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: }) const fit = new FitAddon() term.loadAddon(fit) + // Ctrl+Click (Cmd+Click on mac) opens http(s) URLs printed in the + // pty stream — a plain click is left for xterm's text-selection + // gesture. Only http(s) is dispatched; file:// / mailto: / etc. are + // underlined for visibility but rejected at activation. See + // terminal-links.ts for the line scanner, modifier gate and scheme + // guard. + const linkProvider = term.registerLinkProvider({ + provideLinks: (lineNumber, callback) => { + // xterm's `provideLinks` hands us a 1-based buffer line number + // (its own built-in ILinkProvider does `buffer.lines.get(e - 1)`, + // i.e. the public `bufferLineNumber` is 1-based while `getLine` + // takes a 0-based index). Passing `lineNumber` straight through + // would fetch the row *below* the one xterm asked us to scan, so + // the URL text would come from the wrong row while `range.y` still + // pointed at the requested row — links landed one line too high. + const line = term.buffer.active.getLine(lineNumber - 1) + if (line === undefined) { + callback(undefined) + return + } + const descriptors = buildTerminalLinks(line.translateToString(true), lineNumber) + if (descriptors.length === 0) { + callback(undefined) + return + } + callback(descriptors.map(descriptor => ({ + range: descriptor.range, + text: descriptor.text, + activate: (event) => { + if (!shouldActivateTerminalLink(event)) return + openTerminalUrl(descriptor.text) + }, + }))) + }, + }) // Re-theme in place when the app's scheme flips (tokens + palette). const applyTheme = (): void => { term.options.theme = xtermTheme() @@ -313,6 +353,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: socket.send(JSON.stringify({ type: 'park' })) } socket?.close() + linkProvider.dispose() term.dispose() connectRef.current = null } diff --git a/src/client/terminal-links.ts b/src/client/terminal-links.ts new file mode 100644 index 0000000..37834b9 --- /dev/null +++ b/src/client/terminal-links.ts @@ -0,0 +1,194 @@ +/** + * Terminal URL hyperlinks: xterm's `registerLinkProvider` is fed per-line + * link descriptors built from the pty stream, so URLs printed by any tool + * become hoverable / clickable spans. + * + * To stay out of the user's way (a terminal's primary interaction is text + * selection, not browsing), a click only activates when the user holds + * Ctrl (Win/Linux) or Cmd (mac) — a plain click is left for xterm's + * normal selection handling. Only http(s) URLs are dispatched to the + * browser; other schemes (`file://`, `mailto:`, `javascript:`, …) are + * underlined for visibility but rejected at activation, so a `file://` + * URL printed by a tool stays inert instead of being handed to + * `window.open`. + * + * Kept as a pure module (no xterm import) so the regex, the line + * scanner, the modifier gate and the scheme guard are unit-testable + * without mounting a terminal. `buildTerminalLinks` returns plain-object + * descriptors whose shape matches xterm's `ILink` minus the `activate` + * callback — the caller attaches `activate` (which closes over the + * event modifier check + `openTerminalUrl`) so this module never + * imports xterm types. + */ + +/** Scheme allowlist for activation. Only http(s) is opened externally. */ +const OPENABLE_SCHEMES = new Set(['http:', 'https:']) + +/** + * The URL pattern used to scan each terminal line. + * + * A word boundary (`\b`) guards the leading scheme so `notttps://…` does + * not match. The character class excludes ASCII whitespace and the + * wrapping punctuation that shells commonly emit around URLs (quotes, + * angle brackets, brackets/braces, pipes, backslashes, backticks) so a + * URL printed as `"https://example.com"` or `` does + * not drag the wrapping character into the link target. + * + * Carries the `g` flag so `findTerminalUrlsInLine` can iterate every + * match on a line; callers must reset `lastIndex` before reuse (the + * helper does this defensively). + */ +export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>\[\]{}|\\^`]+/gi + +/** A URL match in a terminal line: the text and its 0-based start offset. */ +export interface TerminalUrlMatch { + /** 0-based start index of the URL within the line string. */ + start: number + /** The matched URL text. */ + text: string +} + +/** + * Strip trailing closing parens that are not balanced by an opening paren + * earlier in the URL. + * + * The regex's character class keeps `(` and `)` (Wikipedia-style URLs + * carry real parens: `…/Python_(programming_language)`), so a URL wrapped + * in parens by a shell (`(https://example.com)`) captures the trailing + * `)`. Balanced pairs are kept intact; only the unmatched excess is + * trimmed, so: + * - `https://example.com)` → `https://example.com` + * - `https://en.wikipedia.org/wiki/Python_(programming_language)` → unchanged + * - `https://example.com/(` → unchanged (more openers than closers; the + * trailing `(` is the URL's own, not a wrapper) + */ +function trimUnbalancedTrailingParens(url: string): string { + let opens = 0 + let closers = 0 + for (let i = 0; i < url.length; i += 1) { + const ch = url[i] + if (ch === '(') opens += 1 + else if (ch === ')') closers += 1 + } + const excess = closers - opens + if (excess <= 0) return url + let end = url.length + let stripped = 0 + while (end > 0 && url[end - 1] === ')' && stripped < excess) { + end -= 1 + stripped += 1 + } + return url.slice(0, end) +} + +/** + * Find every http(s) URL in a line of terminal text, in source order + * with 0-based start offsets. A link provider maps these to buffer + * ranges for xterm's `registerLinkProvider`. + * + * Trailing unmatched closing parens are trimmed from each match (see + * {@link trimUnbalancedTrailingParens}), so a URL wrapped in parens by + * a shell opens without the trailing `)`, while Wikipedia-style URLs + * with balanced parens stay intact. + * + * Resets the regex's `lastIndex` before and after the scan so a + * previous partial iteration can't desynchronize a later one (the + * regex carries the `g` flag and is module-shared). + */ +export function findTerminalUrlsInLine(line: string): TerminalUrlMatch[] { + TERMINAL_URL_REGEX.lastIndex = 0 + const matches: TerminalUrlMatch[] = [] + let m: RegExpExecArray | null + while ((m = TERMINAL_URL_REGEX.exec(line)) !== null) { + const trimmed = trimUnbalancedTrailingParens(m[0]) + if (trimmed.length > 0) { + matches.push({ start: m.index, text: trimmed }) + } + } + TERMINAL_URL_REGEX.lastIndex = 0 + return matches +} + +/** + * A buffer range in xterm's coordinate system. xterm's cell `x` is + * 1-based (1..cols) and `y` is the buffer line number; the `end` is + * inclusive (the last cell covered by the link). Structurally + * compatible with xterm's `IBufferRange` so the caller can pass it + * straight through without importing xterm types here. + */ +export interface TerminalLinkRange { + start: { x: number; y: number } + end: { x: number; y: number } +} + +/** + * A link descriptor: a buffer range plus the URL text. The caller + * (the link provider in `TerminalView`) attaches the `activate` + * callback, which closes over the modifier gate and `openTerminalUrl`. + */ +export interface TerminalLinkDescriptor { + range: TerminalLinkRange + text: string +} + +/** + * Build link descriptors for every URL found in a terminal line. + * + * @param lineText - the line's text (e.g. from + * `IBufferLine.translateToString(true)`). + * @param lineNumber - the buffer line number xterm passed to + * `ILinkProvider.provideLinks` (used as the `y` of every range; URLs + * never span wrapped lines because each wrapped row is its own + * buffer line). + * @returns descriptors in source order; empty when the line has no URL. + */ +export function buildTerminalLinks(lineText: string, lineNumber: number): TerminalLinkDescriptor[] { + return findTerminalUrlsInLine(lineText).map(({ start, text: url }) => ({ + range: { + // xterm's cell `x` is 1-based; `start` is 0-based from + // translateToString, so the first URL character sits at x = start + 1. + start: { x: start + 1, y: lineNumber }, + // `end` is inclusive: the last URL character is at 0-based + // (start + url.length - 1), which is 1-based (start + url.length). + end: { x: start + url.length, y: lineNumber }, + }, + text: url, + })) +} + +/** + * Decide whether a click on a terminal link should activate (open the URL). + * + * Mirrors what every modern terminal does (Windows Terminal, iTerm2, the + * VSCode integrated terminal): a plain click stays a text-selection + * gesture, and only Ctrl (Win/Linux) or Cmd (mac) hands the URL to the + * browser. The modifier is read off the activating `MouseEvent`, not + * tracked separately, so a key-up between hover and click never + * desynchronizes the gate. + */ +export function shouldActivateTerminalLink(event: MouseEvent): boolean { + return event.ctrlKey || event.metaKey +} + +/** + * Open a URL matched in the terminal, with a scheme guard so a printed + * `file://` or anything that slipped past the regex cannot reach + * `window.open`. The URL is constructed via `new URL(...)` which throws + * on malformed input; the catch makes the function total so the xterm + * handler never throws into the terminal's event loop. + * + * @returns `true` when the URL was dispatched to `window.open`, `false` + * when it was rejected (bad URL, disallowed scheme, no `window`). + */ +export function openTerminalUrl(uri: string): boolean { + if (typeof window === 'undefined') return false + let url: URL + try { + url = new URL(uri) + } catch { + return false + } + if (!OPENABLE_SCHEMES.has(url.protocol)) return false + window.open(url.toString(), '_blank', 'noopener,noreferrer') + return true +} diff --git a/tests/terminal-links.spec.ts b/tests/terminal-links.spec.ts new file mode 100644 index 0000000..daf106a --- /dev/null +++ b/tests/terminal-links.spec.ts @@ -0,0 +1,287 @@ +// @vitest-environment jsdom +/** + * Terminal URL hyperlink helpers: the regex that scopes xterm's + * `registerLinkProvider`, the per-line scanner that turns matches into + * buffer ranges, the modifier gate that keeps a plain click a + * text-selection gesture, and the scheme guard that routes only http(s) + * to `window.open`. Pure-module unit tests — no xterm mount. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + TERMINAL_URL_REGEX, + findTerminalUrlsInLine, + buildTerminalLinks, + shouldActivateTerminalLink, + openTerminalUrl, +} from '../src/client/terminal-links.ts' + +/** Reset the regex's lastIndex between tests (it carries the `g` flag). */ +function reset(): void { + TERMINAL_URL_REGEX.lastIndex = 0 +} + +afterEach(() => { + reset() + vi.restoreAllMocks() +}) + +/** Build a fake MouseEvent with only the modifier fields the gate reads. */ +function fakeEvent(mods: { ctrlKey?: boolean; metaKey?: boolean } = {}): MouseEvent { + return { + ctrlKey: mods.ctrlKey ?? false, + metaKey: mods.metaKey ?? false, + } as MouseEvent +} + +describe('TERMINAL_URL_REGEX', () => { + it('matches a bare http(s) URL', () => { + expect('https://example.com/path?x=1#frag'.match(TERMINAL_URL_REGEX)) + .toEqual(['https://example.com/path?x=1#frag']) + expect('http://localhost:3000/'.match(TERMINAL_URL_REGEX)) + .toEqual(['http://localhost:3000/']) + }) + + it('does not match a scheme that is not http(s)', () => { + expect('file:///etc/passwd'.match(TERMINAL_URL_REGEX)).toBeNull() + expect('mailto:foo@bar.com'.match(TERMINAL_URL_REGEX)).toBeNull() + expect('ssh://host/path'.match(TERMINAL_URL_REGEX)).toBeNull() + expect('javascript:alert(1)'.match(TERMINAL_URL_REGEX)).toBeNull() + }) + + it('does not match a scheme glued to a word character (word-boundary guard)', () => { + // Regression guard: a tool printing `nothttps://example.com` should not + // surface a clickable link — the word boundary rejects the embedded scheme. + expect('nothttps://example.com'.match(TERMINAL_URL_REGEX)).toBeNull() + expect('xhttps://example.com'.match(TERMINAL_URL_REGEX)).toBeNull() + }) + + it('matches when preceded by a non-word character (quote, bracket, whitespace)', () => { + expect('"https://example.com"'.match(TERMINAL_URL_REGEX)) + .toEqual(['https://example.com']) + expect(''.match(TERMINAL_URL_REGEX)) + .toEqual(['https://example.com']) + expect('[see https://example.com for more]'.match(TERMINAL_URL_REGEX)) + .toEqual(['https://example.com']) + }) + + it('excludes trailing wrapping punctuation from the matched URL', () => { + // Shells commonly wrap URLs in quotes / angle brackets / parentheses; + // the closing wrapper must not become part of the link target. The + // character class itself rejects quotes, angle brackets, square and + // curly braces; round parens are kept (Wikipedia URLs carry real + // parens) and trimmed by `findTerminalUrlsInLine` instead (covered + // in its own describe block below). + expect('"https://example.com"'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com') + expect(''.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com') + expect('[https://example.com]'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com') + expect('{https://example.com}'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com') + expect('`https://example.com`'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com') + }) + + it('stops at whitespace (a URL never contains a space)', () => { + expect('run https://example.com now'.match(TERMINAL_URL_REGEX)) + .toEqual(['https://example.com']) + }) + + it('finds multiple URLs on the same line (global flag)', () => { + const matches = 'see https://a.com and http://b.com/path'.match(TERMINAL_URL_REGEX) + expect(matches).toEqual(['https://a.com', 'http://b.com/path']) + }) + + it('keeps the path, query and fragment intact', () => { + const url = 'https://example.com/a/b/c?d=1&e=2#section-3' + expect(url.match(TERMINAL_URL_REGEX)?.[0]).toBe(url) + }) + + it('does not match plain text without a scheme', () => { + expect('example.com'.match(TERMINAL_URL_REGEX)).toBeNull() + expect('see the docs at example.com/path'.match(TERMINAL_URL_REGEX)).toBeNull() + }) +}) + +describe('findTerminalUrlsInLine', () => { + it('returns an empty array when the line has no URL', () => { + expect(findTerminalUrlsInLine('just some text')).toEqual([]) + expect(findTerminalUrlsInLine('')).toEqual([]) + expect(findTerminalUrlsInLine('file:///etc/passwd')).toEqual([]) + }) + + it('returns the URL text and its 0-based start offset', () => { + expect(findTerminalUrlsInLine('see https://example.com now')) + .toEqual([{ start: 4, text: 'https://example.com' }]) + expect(findTerminalUrlsInLine('https://example.com')) + .toEqual([{ start: 0, text: 'https://example.com' }]) + }) + + it('returns every URL on the line in source order', () => { + expect(findTerminalUrlsInLine('https://a.com and http://b.com/path')).toEqual([ + { start: 0, text: 'https://a.com' }, + { start: 18, text: 'http://b.com/path' }, + ]) + }) + + it('trims wrapping punctuation from the matched text', () => { + expect(findTerminalUrlsInLine('"https://example.com"')).toEqual([ + { start: 1, text: 'https://example.com' }, + ]) + expect(findTerminalUrlsInLine('')).toEqual([ + { start: 1, text: 'https://example.com' }, + ]) + }) + + it('strips trailing closing parens not balanced by an opening paren', () => { + // Shell-wrapped URLs: `(https://example.com)` — the regex captures the + // trailing `)` (round parens are kept so Wikipedia-style URLs work), + // and findTerminalUrlsInLine trims the unmatched excess. + expect(findTerminalUrlsInLine('(https://example.com)')).toEqual([ + { start: 1, text: 'https://example.com' }, + ]) + expect(findTerminalUrlsInLine('see (https://example.com) here')).toEqual([ + { start: 5, text: 'https://example.com' }, + ]) + }) + + it('keeps balanced parens inside Wikipedia-style URLs intact', () => { + const url = 'https://en.wikipedia.org/wiki/Python_(programming_language)' + expect(findTerminalUrlsInLine(`see ${url}`)).toEqual([{ start: 4, text: url }]) + // Trailing balanced closer is part of the URL, not a wrapper. + expect(findTerminalUrlsInLine(url)).toEqual([{ start: 0, text: url }]) + }) + + it('strips only the unmatched excess when the URL has both its own parens and a wrapper closer', () => { + // URL has 1 opening and 1 closing paren of its own, plus a wrapper + // closing paren: `https://en.wikipedia.org/wiki/Foo_(bar))` + // → opens=1, closers=2, excess=1 → strip one trailing `)`. + expect(findTerminalUrlsInLine('https://en.wikipedia.org/wiki/Foo_(bar))')).toEqual([ + { start: 0, text: 'https://en.wikipedia.org/wiki/Foo_(bar)' }, + ]) + }) + + it('does not carry lastIndex state across calls (the g flag is module-shared)', () => { + // First call leaves lastIndex set; a second call on a different line + // must still scan from the start. + findTerminalUrlsInLine('https://a.com') + expect(findTerminalUrlsInLine('https://b.com')).toEqual([ + { start: 0, text: 'https://b.com' }, + ]) + // And a third call after a partial match must also reset cleanly. + TERMINAL_URL_REGEX.exec('https://c.com') // leaves lastIndex past the match + expect(findTerminalUrlsInLine('https://d.com')).toEqual([ + { start: 0, text: 'https://d.com' }, + ]) + }) +}) + +describe('buildTerminalLinks', () => { + it('returns an empty array when the line has no URL', () => { + expect(buildTerminalLinks('plain text', 5)).toEqual([]) + expect(buildTerminalLinks('', 0)).toEqual([]) + }) + + it('builds a descriptor with xterm 1-based x coords and inclusive end', () => { + // Line: "see https://example.com now" + // 0123456789... + // URL starts at 0-based index 4, length 19 ('https://example.com'). + // xterm x is 1-based: start cell = 4+1 = 5, last cell = 4+19 = 23. + expect(buildTerminalLinks('see https://example.com now', 7)).toEqual([ + { + range: { start: { x: 5, y: 7 }, end: { x: 23, y: 7 } }, + text: 'https://example.com', + }, + ]) + }) + + it('places a URL at the start of the line at x=1', () => { + expect(buildTerminalLinks('https://example.com', 3)).toEqual([ + { + range: { start: { x: 1, y: 3 }, end: { x: 19, y: 3 } }, + text: 'https://example.com', + }, + ]) + }) + + it('uses the buffer line number passed in as the range y', () => { + const links = buildTerminalLinks('https://example.com', 42) + expect(links[0]?.range.start.y).toBe(42) + expect(links[0]?.range.end.y).toBe(42) + }) + + it('builds one descriptor per URL on a multi-URL line, in source order', () => { + const links = buildTerminalLinks('https://a.com http://b.com', 1) + expect(links).toHaveLength(2) + expect(links[0]?.text).toBe('https://a.com') + expect(links[1]?.text).toBe('http://b.com') + // Non-overlapping ranges, in order. + expect(links[0]!.range.end.x).toBeLessThan(links[1]!.range.start.x) + }) +}) + +describe('shouldActivateTerminalLink', () => { + it('returns false for a plain click (no modifier)', () => { + expect(shouldActivateTerminalLink(fakeEvent())).toBe(false) + }) + + it('returns true when Ctrl is held (Win/Linux convention)', () => { + expect(shouldActivateTerminalLink(fakeEvent({ ctrlKey: true }))).toBe(true) + }) + + it('returns true when Cmd is held (mac convention)', () => { + expect(shouldActivateTerminalLink(fakeEvent({ metaKey: true }))).toBe(true) + }) + + it('returns true when both modifiers are held (defensive — some remotes send both)', () => { + expect(shouldActivateTerminalLink(fakeEvent({ ctrlKey: true, metaKey: true }))).toBe(true) + }) + + it('returns false when only Shift / Alt are held (those are not open modifiers)', () => { + expect(shouldActivateTerminalLink(fakeEvent())).toBe(false) + // Construct with shiftKey/altKey only; the gate ignores them. + const event = { ctrlKey: false, metaKey: false, shiftKey: true, altKey: true } as MouseEvent + expect(shouldActivateTerminalLink(event)).toBe(false) + }) +}) + +describe('openTerminalUrl', () => { + it('opens an http URL in a new tab with noopener,noreferrer', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openTerminalUrl('http://example.com')).toBe(true) + expect(open).toHaveBeenCalledWith('http://example.com/', '_blank', 'noopener,noreferrer') + }) + + it('opens an https URL', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openTerminalUrl('https://example.com/path?x=1')).toBe(true) + expect(open).toHaveBeenCalledWith( + 'https://example.com/path?x=1', + '_blank', + 'noopener,noreferrer', + ) + }) + + it('rejects file:// (printed by tools, never handed to window.open)', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openTerminalUrl('file:///etc/passwd')).toBe(false) + expect(open).not.toHaveBeenCalled() + }) + + it('rejects mailto:', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openTerminalUrl('mailto:foo@bar.com')).toBe(false) + expect(open).not.toHaveBeenCalled() + }) + + it('rejects javascript: (defense-in-depth even though the regex skips it)', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openTerminalUrl('javascript:alert(1)')).toBe(false) + expect(open).not.toHaveBeenCalled() + }) + + it('rejects a malformed URL string without throwing', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + expect(openTerminalUrl('https://')).toBe(false) + expect(openTerminalUrl('not a url at all')).toBe(false) + expect(openTerminalUrl('')).toBe(false) + expect(open).not.toHaveBeenCalled() + }) +}) +