Merge pull request #419 from HuanLinOTO/feat/terminal-ctrl-click-url

feat(terminal): open URLs with Ctrl+Click (Cmd+Click on mac)
This commit is contained in:
多玩幻灵qwq
2026-08-27 21:44:32 +08:00
committed by GitHub
parent d71261ace3
commit 0314fd9b93
3 changed files with 522 additions and 0 deletions
+41
View File
@@ -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
}
+194
View File
@@ -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 `<https://example.com>` 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
}
+287
View File
@@ -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('<https://example.com>'.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('<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')
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('<https://example.com>')).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()
})
})