Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
778d5ef
plan: clickable terminal URLs with context menu integration
Mar 29, 2026
8f744dc
plan: improve clickable-terminal-urls plan with verified code references
Mar 29, 2026
3abd4ed
plan: refine clickable-terminal-urls plan after code verification
Mar 29, 2026
380e949
plan: fix xterm link provider priority order and test file references
Mar 29, 2026
1fd41c8
test-plan: concrete enumerated test plan for clickable terminal URLs
Mar 29, 2026
1ab57eb
feat: add terminal-hovered-url and url-utils utility modules with tests
Mar 29, 2026
b985ad6
feat: URL click opens browser pane, add hover/leave tracking and URL …
Mar 29, 2026
104e755
feat: add URL context menu items for terminal panes
Mar 29, 2026
5b3c8b2
test: add integration tests for URL click and context menu
Mar 29, 2026
5e07f7b
test: add hidden state cleanup test for hovered URL
Mar 29, 2026
78c1829
fix: update file link test to capture first provider, not last
Mar 29, 2026
5a563cc
refactor: fix lint issues in url-utils and TerminalView cleanup
Mar 29, 2026
0d78a20
fix: balanced parens in URL detection, scheme validation on OSC 8 lin…
Mar 29, 2026
51c4b62
fix: add button guard to prevent right/middle-click link activation
Mar 29, 2026
960aed7
fix: defer terminal pane splits after xterm pointer events
Mar 29, 2026
181f599
fix: type queued terminal pane splits as pane inputs
Mar 29, 2026
6d3cc63
Merge remote-tracking branch 'upstream/main' into clickable-terminal-…
mattleaverton Mar 30, 2026
83b01cb
fix: block non-http schemes in OSC 8 link handler
mattleaverton Mar 30, 2026
b935ea3
Merge remote-tracking branch 'upstream/main' into clickable-terminal-…
mattleaverton Mar 30, 2026
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
279 changes: 279 additions & 0 deletions .plans/clickable-terminal-urls-tests.md

Large diffs are not rendered by default.

489 changes: 489 additions & 0 deletions .plans/clickable-terminal-urls.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ <h2>Task Board</h2>
{ html: ' <span class="t-success">✓</span> Multi-tab terminal management', delay: 50 },
{ html: ' <span class="t-success">✓</span> Claude Code, Codex, Kimi, OpenCode &amp; more', delay: 50 },
{ html: ' <span class="t-success">✓</span> Split panes with browser views', delay: 50 },
{ html: ' <span class="t-success">✓</span> Clickable URLs in terminal with right-click context menu', delay: 50 },
{ html: ' <span class="t-success">✓</span> In-pane terminal search with Ctrl+F', delay: 50 },
{ html: ' <span class="t-success">✓</span> Advanced OSC52 clipboard policy (Ask/Always/Never)', delay: 50 },
{ html: ' <span class="t-success">✓</span> Session history &amp; AI summaries', delay: 50 },
Expand Down
129 changes: 110 additions & 19 deletions src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import {
} from '@/lib/terminal-attach-seq-state'
import { useMobile } from '@/hooks/useMobile'
import { findLocalFilePaths } from '@/lib/path-utils'
import { findUrls } from '@/lib/url-utils'
import { setHoveredUrl, clearHoveredUrl } from '@/lib/terminal-hovered-url'
import { getTabSwitchShortcutDirection, getTabLifecycleAction } from '@/lib/tab-switch-shortcuts'
import {
createTurnCompleteSignalParserState,
Expand Down Expand Up @@ -66,7 +68,7 @@ import { cn } from '@/lib/utils'
import { Terminal } from '@xterm/xterm'
import { Loader2 } from 'lucide-react'
import { ConfirmModal } from '@/components/ui/confirm-modal'
import type { PaneContent, PaneRefreshRequest, TerminalPaneContent } from '@/store/paneTypes'
import type { PaneContent, PaneContentInput, PaneRefreshRequest, TerminalPaneContent } from '@/store/paneTypes'
import '@xterm/xterm/css/xterm.css'
import { createLogger } from '@/lib/client-logger'

Expand Down Expand Up @@ -95,6 +97,13 @@ function resolveMinimumContrastRatio(theme?: { isDark?: boolean } | null): numbe
return theme?.isDark === false ? LIGHT_THEME_MIN_CONTRAST_RATIO : DEFAULT_MIN_CONTRAST_RATIO
}

function deferTerminalPointerMutation(callback: () => void): void {
// xterm link activation runs inside element-level mouse handlers while it may
// still have document-level mouseup/move listeners in flight. Reparenting the
// terminal synchronously can dispose the renderer before those listeners finish.
queueMicrotask(callback)
}

function createNoopRuntime(): TerminalRuntime {
return {
attachAddons: () => {},
Expand Down Expand Up @@ -229,6 +238,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
const mobileCtrlActiveRef = useRef(false)

const containerRef = useRef<HTMLDivElement | null>(null)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const runtimeRef = useRef<TerminalRuntime | null>(null)
const writeQueueRef = useRef<TerminalWriteQueue | null>(null)
Expand Down Expand Up @@ -403,7 +413,13 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter

useEffect(() => {
hiddenRef.current = hidden
}, [hidden])
if (hidden) {
clearHoveredUrl(paneId)
if (wrapperRef.current) {
delete wrapperRef.current.dataset.hoveredUrl
}
}
}, [hidden, paneId])

useEffect(() => {
warnExternalLinksRef.current = settings.terminal.warnExternalLinks
Expand Down Expand Up @@ -966,6 +982,17 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
event.stopPropagation()
}, [])

const queuePaneSplit = useCallback((newContent: PaneContentInput) => {
deferTerminalPointerMutation(() => {
dispatch(splitPane({
tabId,
paneId,
direction: 'horizontal',
newContent,
}))
})
}, [dispatch, paneId, tabId])

useEffect(() => {
return () => {
clearMobileToolbarRepeat()
Expand Down Expand Up @@ -998,11 +1025,27 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
theme: resolvedTheme,
minimumContrastRatio: resolveMinimumContrastRatio(resolvedTheme),
linkHandler: {
activate: (_event: MouseEvent, uri: string) => {
activate: (event: MouseEvent, uri: string) => {
if (event.button !== 0) return
// Only open http/https URLs. Block javascript:, data:, and other
// potentially dangerous schemes from OSC 8 links.
if (!/^https?:\/\//i.test(uri)) return
if (warnExternalLinksRef.current !== false) {
setPendingLinkUriRef.current(uri)
} else {
window.open(uri, '_blank', 'noopener,noreferrer')
queuePaneSplit({ kind: 'browser', url: uri, devToolsOpen: false })
}
},
hover: (_event: MouseEvent, text: string, _range: import('@xterm/xterm').IBufferRange) => {
setHoveredUrl(paneId, text)
if (wrapperRef.current) {
wrapperRef.current.dataset.hoveredUrl = text
}
},
leave: () => {
clearHoveredUrl(paneId)
if (wrapperRef.current) {
delete wrapperRef.current.dataset.hoveredUrl
}
},
},
Expand Down Expand Up @@ -1055,20 +1098,56 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
end: { x: m.endIndex, y: bufferLineNumber },
},
text: m.path,
activate: () => {
dispatch(splitPane({
tabId,
paneId,
direction: 'horizontal',
newContent: {
kind: 'editor',
filePath: m.path,
language: null,
readOnly: false,
content: '',
viewMode: 'source',
},
}))
activate: (event: MouseEvent) => {
if (event && event.button !== 0) return
queuePaneSplit({
kind: 'editor',
filePath: m.path,
language: null,
readOnly: false,
content: '',
viewMode: 'source',
})
},
})))
},
})
: { dispose: () => {} }

// Register custom link provider for clickable URLs in terminal output
const urlLinkDisposable = typeof term.registerLinkProvider === 'function'
? term.registerLinkProvider({
provideLinks(bufferLineNumber: number, callback: (links: import('@xterm/xterm').ILink[] | undefined) => void) {
const bufferLine = term.buffer.active.getLine(bufferLineNumber - 1)
if (!bufferLine) { callback(undefined); return }
const text = bufferLine.translateToString()
const urls = findUrls(text)
if (urls.length === 0) { callback(undefined); return }
callback(urls.map((m) => ({
range: {
start: { x: m.startIndex + 1, y: bufferLineNumber },
end: { x: m.endIndex, y: bufferLineNumber },
},
text: m.url,
activate: (event: MouseEvent) => {
if (event && event.button !== 0) return
if (warnExternalLinksRef.current !== false) {
setPendingLinkUriRef.current(m.url)
} else {
queuePaneSplit({ kind: 'browser', url: m.url, devToolsOpen: false })
}
},
hover: () => {
setHoveredUrl(paneId, m.url)
if (wrapperRef.current) {
wrapperRef.current.dataset.hoveredUrl = m.url
}
},
leave: () => {
clearHoveredUrl(paneId)
if (wrapperRef.current) {
delete wrapperRef.current.dataset.hoveredUrl
}
},
})))
},
Expand Down Expand Up @@ -1194,9 +1273,15 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
})
ro.observe(containerRef.current)

const wrapperEl = wrapperRef.current
return () => {
requestModeBypass.dispose()
filePathLinkDisposable?.dispose()
urlLinkDisposable?.dispose()
clearHoveredUrl(paneId)
if (wrapperEl) {
delete wrapperEl.dataset.hoveredUrl
}
ro.disconnect()
unregisterActions()
unregisterCaptureHandler()
Expand Down Expand Up @@ -2001,6 +2086,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter

return (
<div
ref={wrapperRef}
className={cn('h-full w-full', hidden ? 'tab-hidden' : 'tab-visible relative')}
data-context={ContextIds.Terminal}
data-pane-id={paneId}
Expand Down Expand Up @@ -2120,7 +2206,12 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
confirmLabel="Open link"
onConfirm={() => {
if (pendingLinkUri) {
window.open(pendingLinkUri, '_blank', 'noopener,noreferrer')
dispatch(splitPane({
tabId,
paneId,
direction: 'horizontal',
newContent: { kind: 'browser', url: pendingLinkUri, devToolsOpen: false },
}))
}
setPendingLinkUri(null)
}}
Expand Down
31 changes: 31 additions & 0 deletions src/components/context-menu/ContextMenuProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,29 @@ export function ContextMenuProvider({
return cleanup
}, [view, closeMenu, menuState])

const openUrlInPane = useCallback((tabId: string, paneId: string, url: string) => {
dispatch(splitPaneAction({
tabId,
paneId,
direction: 'horizontal',
newContent: { kind: 'browser', url, devToolsOpen: false },
}))
}, [dispatch])

const openUrlInTab = useCallback((url: string) => {
const id = nanoid()
dispatch(addTab({ id, mode: 'shell' }))
dispatch(initLayout({ tabId: id, content: { kind: 'browser', url, devToolsOpen: false } }))
}, [dispatch])

const openUrlInBrowser = useCallback((url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}, [])

const copyUrlAction = useCallback(async (url: string) => {
await copyText(url)
}, [])

const menuItems = useMemo(() => {
if (!menuState) return []
return buildMenuItems(menuState.target, {
Expand Down Expand Up @@ -960,6 +983,10 @@ export function ContextMenuProvider({
copyAgentChatDiffNew: copyAgentChatDiffNew,
copyAgentChatDiffOld: copyAgentChatDiffOld,
copyAgentChatFilePath: copyAgentChatFilePath,
openUrlInPane,
openUrlInTab,
openUrlInBrowser,
copyUrl: copyUrlAction,
},
})
}, [
Expand Down Expand Up @@ -1016,6 +1043,10 @@ export function ContextMenuProvider({
copyTerminalCwd,
copyMessageText,
copyMessageCode,
openUrlInPane,
openUrlInTab,
openUrlInBrowser,
copyUrlAction,
])

return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/context-menu/context-menu-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type ContextTarget =
| { kind: 'tab-add' }
| { kind: 'pane'; tabId: string; paneId: string }
| { kind: 'pane-divider'; tabId: string; splitId: string }
| { kind: 'terminal'; tabId: string; paneId: string }
| { kind: 'terminal'; tabId: string; paneId: string; hoveredUrl?: string }
| { kind: 'browser'; tabId: string; paneId: string }
| { kind: 'editor'; tabId: string; paneId: string }
| { kind: 'pane-picker'; tabId: string; paneId: string }
Expand Down
7 changes: 6 additions & 1 deletion src/components/context-menu/context-menu-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ export function parseContextTarget(contextId: ContextId, data: ContextDataset):
: null
case ContextIds.Terminal:
return data.tabId && data.paneId
? { kind: 'terminal', tabId: data.tabId, paneId: data.paneId }
? {
kind: 'terminal',
tabId: data.tabId,
paneId: data.paneId,
hoveredUrl: data.hoveredUrl,
}
: null
case ContextIds.Browser:
return data.tabId && data.paneId
Expand Down
34 changes: 34 additions & 0 deletions src/components/context-menu/menu-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export type MenuActions = {
copyAgentChatDiffOld: (clickTarget: HTMLElement | null) => void
copyAgentChatFilePath: (clickTarget: HTMLElement | null) => void
showKeyboardShortcuts: () => void
openUrlInPane: (tabId: string, paneId: string, url: string) => void
openUrlInTab: (url: string) => void
openUrlInBrowser: (url: string) => void
copyUrl: (url: string) => void
}

export type MenuBuildContext = {
Expand Down Expand Up @@ -338,7 +342,37 @@ export function buildMenuItems(target: ContextTarget, ctx: MenuBuildContext): Me
? [buildCopyResumeMenuItem('terminal-copy-resume-command', resumeCandidate, actions, extensions)]
: []
const canRefreshPane = !!paneContent && !!buildPaneRefreshTarget(paneContent)

const urlItems: MenuItem[] = target.hoveredUrl ? [
{
type: 'item',
id: 'url-open-pane',
label: 'Open URL in pane',
onSelect: () => actions.openUrlInPane(target.tabId, target.paneId, target.hoveredUrl!),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate hovered URL before context-menu open-in-pane action

The terminal context menu forwards target.hoveredUrl directly into pane navigation without scheme validation. For OSC8 links, hoveredUrl is populated from linkHandler.hover as raw link text, so a javascript:/data: OSC8 URI can still be opened via “Open URL in pane/new tab,” bypassing the scheme check added to left-click activation and reintroducing unsafe URI handling through the right-click path.

Useful? React with 👍 / 👎.

},
{
type: 'item',
id: 'url-open-tab',
label: 'Open URL in new tab',
onSelect: () => actions.openUrlInTab(target.hoveredUrl!),
},
{
type: 'item',
id: 'url-open-browser',
label: 'Open in external browser',
onSelect: () => actions.openUrlInBrowser(target.hoveredUrl!),
},
{
type: 'item',
id: 'url-copy',
label: 'Copy URL',
onSelect: () => actions.copyUrl(target.hoveredUrl!),
},
{ type: 'separator', id: 'url-sep' },
] : []

return [
...urlItems,
...buildTerminalClipboardItems(terminalActions, hasSelection),
{ type: 'separator', id: 'terminal-clipboard-sep' },
{
Expand Down
15 changes: 15 additions & 0 deletions src/lib/terminal-hovered-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Tracks which URL is currently hovered per terminal pane for context menus.

const hoveredUrls = new Map<string, string>()

export function setHoveredUrl(paneId: string, url: string): void {
hoveredUrls.set(paneId, url)
}

export function clearHoveredUrl(paneId: string): void {
hoveredUrls.delete(paneId)
}

export function getHoveredUrl(paneId: string): string | undefined {
return hoveredUrls.get(paneId)
}
Loading
Loading