fix(terminal): open hard-wrapped HTTP links exactly once (#8339)

* fix(terminal): open hard-wrapped HTTP links exactly once

* fix(terminal): narrow wrapped URL click handling

* fix(terminal): harden wrapped URL activation

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SeongHoon Ryu
2026-07-12 21:36:13 -07:00
committed by GitHub
co-authored by Orca Jinwoo-H
parent e3c47eff17
commit a67cbf2d24
12 changed files with 943 additions and 58 deletions
@@ -0,0 +1,128 @@
import type { IBufferLine } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links'
import {
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS,
TERMINAL_HTTP_URL_MAX_LENGTH
} from './terminal-http-link-limits'
function bufferLine(text: string, onTranslateWithColumns: () => void): IBufferLine {
return {
isWrapped: false,
length: text.length,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = text.length,
outColumns?: number[]
) => {
if (outColumns) {
onTranslateWithColumns()
}
outColumns?.push(...Array.from({ length: text.length + 1 }, (_value, index) => index))
return text.slice(startColumn, endColumn)
}
} as IBufferLine
}
function bufferLineWithCellColumns(text: string): IBufferLine {
const columns: number[] = []
let column = 0
for (const character of text) {
columns.push(column)
column += character === '你' ? 2 : 1
}
columns.push(column)
return {
isWrapped: false,
length: column,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = text.length,
outColumns?: number[]
) => {
outColumns?.push(...columns)
return text.slice(startColumn, endColumn)
}
} as IBufferLine
}
describe('hard-wrapped terminal HTTP candidate bounds', () => {
it('builds each required column map once and rejects an overlength candidate early', () => {
const onTranslateWithColumns = vi.fn()
const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) =>
bufferLine(`| ${index === 0 ? 'http://x' : 'aaaaaaaa'} |`, onTranslateWithColumns)
)
expect(
buildHardWrappedHttpLogicalLineCandidates(
{ getLine: (y) => rows[y] },
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS
)
).toEqual([])
expect(onTranslateWithColumns).toHaveBeenCalledTimes(
Math.floor(TERMINAL_HTTP_URL_MAX_LENGTH / 'aaaaaaaa'.length) + 1
)
})
it('does not build column maps while rejecting rows without an HTTP scheme', () => {
const onTranslateWithColumns = vi.fn()
const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, () =>
bufferLine(`| ${'a'.repeat(500)} |`, onTranslateWithColumns)
)
expect(
buildHardWrappedHttpLogicalLineCandidates(
{ getLine: (y) => rows[y] },
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS
)
).toEqual([])
expect(onTranslateWithColumns).not.toHaveBeenCalled()
})
it('reconstructs framed URL rows aligned by cells when a fragment has wide characters', () => {
const rowTexts = ['|http://a/ab|', '|a你你你你你|', '|tailzzzzzzz|']
const rows = rowTexts.map(bufferLineWithCellColumns)
const candidates = buildHardWrappedHttpLogicalLineCandidates(
{ getLine: (y) => rows[y] },
rows.length
)
expect(candidates[0]?.text).toBe('http://a/aba你你你你你tailzzzzzzz')
})
it('reconstructs a maximum-length wide-character URL beyond the ASCII row bound', () => {
const scheme = 'http://'
const continuation = '你'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - scheme.length)
const rows = [
bufferLineWithCellColumns(`|${scheme}|`),
...continuation
.match(/.{1,3}/g)!
.map((fragment) =>
bufferLineWithCellColumns(`|${fragment}${' '.repeat(7 - fragment.length * 2)}|`)
)
]
expect(rows.length).toBeGreaterThan(Math.ceil(TERMINAL_HTTP_URL_MAX_LENGTH / scheme.length))
expect(
buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text
).toBe(scheme + continuation)
})
it('reconstructs a maximum-length URL with one-character continuation rows', () => {
const firstFragment = 'http://a'
const continuation = '/'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - firstFragment.length)
const rows = [
bufferLineWithCellColumns(`|${firstFragment}|`),
...continuation.split('').map((fragment) => bufferLineWithCellColumns(`|${fragment} |`))
]
const fullUrl = firstFragment + continuation
expect(() => new URL(fullUrl)).not.toThrow()
expect(
buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text
).toBe(fullUrl)
})
})
@@ -0,0 +1,148 @@
import type { IBufferLine } from '@xterm/xterm'
import {
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS,
TERMINAL_HTTP_URL_MAX_LENGTH
} from './terminal-http-link-limits'
import { translateLineWithColumns, type WrappedLogicalLine } from './wrapped-terminal-link-ranges'
const HTTP_SCHEME_PATTERN = /https?:\/\//i
const HTTP_SCHEME_START_PATTERN = /^https?:\/\//i
const HTTP_FRAGMENT_PATTERN = /^[^\s"'!*(){}|\\^<>`│┃║╎╏┆┇┊┋]*/
const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/
const NON_LAYOUT_SUFFIX_PATTERN = /[^\s│┃║╎╏┆┇┊┋|]/
const HARD_WRAP_CONTINUATION_SUFFIX_PATTERN = /[/?&=#%+:-]$/
const MIN_HARD_WRAPPED_HTTP_ROWS = 3
const MIN_HARD_WRAP_FILL_RATIO = 0.8
type TranslatedLine = ReturnType<typeof translateLineWithColumns>
function buildCandidateFromStart(
buffer: { getLine(y: number): IBufferLine | undefined },
startY: number,
currentY: number,
translatedLines: Map<number, TranslatedLine>
): WrappedLogicalLine | null {
const startLine = buffer.getLine(startY)
if (!startLine) {
return null
}
const cachedStart = translatedLines.get(startY)
const startText = cachedStart?.text ?? startLine.translateToString(false)
const schemeIndex = startText.search(HTTP_SCHEME_PATTERN)
if (schemeIndex === -1 || !VERTICAL_LAYOUT_FRAME_PATTERN.test(startText.slice(0, schemeIndex))) {
return null
}
const translatedStart = cachedStart ?? translateLineWithColumns(startLine)
translatedLines.set(startY, translatedStart)
const schemeColumn = translatedStart.columns[schemeIndex]
if (schemeColumn === undefined) {
return null
}
const continuationPrefix = translatedStart.text.slice(0, schemeIndex)
let text = ''
let rightFrameColumn: number | null = null
let previousRowCanContinue = true
let startRowFilled = false
const rows: WrappedLogicalLine['rows'] = []
for (let rowY = startY; rowY < startY + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS; rowY++) {
if (rowY > startY && !previousRowCanContinue) {
break
}
const line = buffer.getLine(rowY)
if (!line) {
break
}
const translated =
rowY === startY
? translatedStart
: (translatedLines.get(rowY) ?? translateLineWithColumns(line))
translatedLines.set(rowY, translated)
if (rowY > startY && translated.text.slice(0, schemeIndex) !== continuationPrefix) {
break
}
const fragment = translated.text.slice(schemeIndex).match(HTTP_FRAGMENT_PATTERN)?.[0] ?? ''
if (!fragment || (rowY > startY && HTTP_SCHEME_START_PATTERN.test(fragment))) {
break
}
const fragmentEnd = schemeIndex + fragment.length
const layoutSuffix = translated.text.slice(fragmentEnd)
const rightFrameOffset = layoutSuffix.search(VERTICAL_LAYOUT_FRAME_PATTERN)
const currentRightFrameIndex = rightFrameOffset === -1 ? -1 : fragmentEnd + rightFrameOffset
const currentRightFrameColumn = translated.columns[currentRightFrameIndex]
if (
currentRightFrameIndex === -1 ||
currentRightFrameColumn === undefined ||
(rightFrameColumn !== null && currentRightFrameColumn !== rightFrameColumn) ||
NON_LAYOUT_SUFFIX_PATTERN.test(layoutSuffix)
) {
break
}
rightFrameColumn ??= currentRightFrameColumn
if (text.length + fragment.length > TERMINAL_HTTP_URL_MAX_LENGTH) {
return null
}
rows.push({
y: rowY,
text: fragment,
sourceText: translated.text,
columns: translated.columns.slice(schemeIndex, fragmentEnd + 1),
startIndex: text.length,
isWrapped: line.isWrapped,
lineLength: line.length
})
text += fragment
const contentWidth = currentRightFrameColumn - schemeColumn
const fragmentWidth = translated.columns[fragmentEnd]! - schemeColumn
const fillsRow = contentWidth > 0 && fragmentWidth / contentWidth >= MIN_HARD_WRAP_FILL_RATIO
if (rowY === startY) {
startRowFilled = fillsRow
}
previousRowCanContinue = HARD_WRAP_CONTINUATION_SUFFIX_PATTERN.test(fragment) || fillsRow
}
if (rows.length > 1 && (rows.length < MIN_HARD_WRAPPED_HTTP_ROWS || !startRowFilled)) {
// Why: a short complete URL can legitimately end in `/`; one adjacent
// framed token is not enough evidence that the URL continued onto it.
rows.splice(1)
text = rows[0]?.text ?? ''
}
if (rows.at(-1)?.y === undefined || rows.at(-1)!.y < currentY) {
return null
}
return {
text,
rows,
fingerprint: `hard-http:${rows.map((row) => `${row.y}:${row.sourceText}`).join('\0')}`
}
}
export function buildHardWrappedHttpLogicalLineCandidates(
buffer: { getLine(y: number): IBufferLine | undefined },
bufferLineNumber: number
): WrappedLogicalLine[] {
const currentY = bufferLineNumber - 1
const currentLine = buffer.getLine(currentY)
if (!currentLine || !VERTICAL_LAYOUT_FRAME_PATTERN.test(currentLine.translateToString(false))) {
// Why: cursor-positioned hard wraps require a stable vertical frame; most
// terminal clicks can avoid the bounded backward scan entirely.
return []
}
const candidates: WrappedLogicalLine[] = []
const translatedLines = new Map<number, TranslatedLine>()
const minY = Math.max(0, currentY - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + 1)
for (let startY = currentY; startY >= minY; startY--) {
const candidate = buildCandidateFromStart(buffer, startY, currentY, translatedLines)
if (candidate) {
candidates.push(candidate)
}
}
return candidates.sort(
(left, right) => right.rows.length - left.rows.length || right.text.length - left.text.length
)
}
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('isTerminalHttpLinkActivation', () => {
it('leaves Alt+Cmd gestures to the child TUI on macOS', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
expect(
isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: false } as MouseEvent)
).toBe(true)
expect(
isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: true } as MouseEvent)
).toBe(false)
})
it('leaves Alt+Ctrl gestures to the child TUI on other platforms', () => {
vi.stubGlobal('navigator', { userAgent: 'Windows' })
expect(
isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: false } as MouseEvent)
).toBe(true)
expect(
isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: true } as MouseEvent)
).toBe(false)
})
})
@@ -0,0 +1,7 @@
import { isTerminalLinkActivation } from './terminal-link-activation'
export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean {
// Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY,
// so plain HTTP link handling must leave those gestures to the child TUI.
return Boolean(event && !event.altKey && isTerminalLinkActivation(event))
}
@@ -0,0 +1,5 @@
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
// Why: punctuation can form a valid one-character continuation row, so the
// URL length limit is also the only complete finite bound on framed rows.
export const TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS = TERMINAL_HTTP_URL_MAX_LENGTH
@@ -0,0 +1,59 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
export function installTerminalLinkPtyMouseSuppression(
terminal: Terminal,
shouldSuppressMouseEvent: (event: MouseEvent) => boolean
): IDisposable {
const terminalElement = terminal.element
const ownerDocument = terminalElement?.ownerDocument
const ownerWindow = ownerDocument?.defaultView
let previousMouseEventsRequireAlt: boolean | null = null
let restoreQueued = false
const restore = (): void => {
restoreQueued = false
if (previousMouseEventsRequireAlt === null) {
return
}
terminal.options.mouseEventsRequireAlt = previousMouseEventsRequireAlt
previousMouseEventsRequireAlt = null
ownerDocument?.removeEventListener('mouseup', queueRestore)
ownerWindow?.removeEventListener('blur', restore)
}
const queueRestore = (): void => {
if (restoreQueued || previousMouseEventsRequireAlt === null) {
return
}
restoreQueued = true
queueMicrotask(restore)
}
const handleMouseDown = (event: MouseEvent): void => {
if (
event.button !== 0 ||
!isTerminalHttpLinkActivation(event) ||
!shouldSuppressMouseEvent(event)
) {
return
}
restore()
previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt)
// Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware
// TUI, letting the terminal and the child process both open the URL.
terminal.options.mouseEventsRequireAlt = true
ownerDocument?.addEventListener('mouseup', queueRestore)
ownerWindow?.addEventListener('blur', restore)
}
terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
terminalElement?.addEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS)
return {
dispose: () => {
restore()
terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
terminalElement?.removeEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS)
}
}
}
@@ -0,0 +1,29 @@
import type { Terminal } from '@xterm/xterm'
export function getTerminalBufferPositionForMouseEvent(
terminal: Terminal,
event: MouseEvent
): { x: number; y: number } | null {
const screenElement = terminal.element?.querySelector('.xterm-screen')
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
return null
}
const rect = screenElement.getBoundingClientRect()
const relativeX = event.clientX - rect.left
const relativeY = event.clientY - rect.top
if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) {
return null
}
const cellWidth = rect.width / terminal.cols
const cellHeight = rect.height / terminal.rows
if (cellWidth <= 0 || cellHeight <= 0) {
return null
}
return {
x: Math.floor(relativeX / cellWidth) + 1,
y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1
}
}
@@ -0,0 +1,429 @@
import type { IBufferLine, Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
import { handleTerminalWebLinkClick } from './terminal-web-link-click'
import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing'
const COLS = 157
const ROWS = 59
const INDENT = ''
const FULL_URL = [
'http://127.0.0.1:8765/orca-double-open-repro-wrapped/',
Array.from({ length: 79 }, (_value, index) => `seg${String(index + 1).padStart(4, '0')}`).join(
'/'
),
'?marker=wrap-test&n=001&pad=',
'x'.repeat(120)
].join('')
const URL_ROWS = Array.from({ length: Math.ceil(FULL_URL.length / COLS) }, (_value, index) =>
FULL_URL.slice(index * COLS, (index + 1) * COLS)
)
const FRAMED_ROW_STARTS = [
0,
FULL_URL.indexOf('seg0008/'),
FULL_URL.indexOf('seg0022/'),
FULL_URL.indexOf('seg0036/'),
FULL_URL.indexOf('seg0050/'),
FULL_URL.indexOf('seg0064/'),
FULL_URL.indexOf('seg0078/'),
FULL_URL.indexOf('test&n=001'),
FULL_URL.length - 19
]
const FRAMED_URL_ROWS = FRAMED_ROW_STARTS.map((start, index) =>
FULL_URL.slice(start, FRAMED_ROW_STARTS[index + 1])
)
const openUrlMock = vi.fn()
type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined]
function makeBufferLine(
fragment: string,
options: { cols?: number; prefix?: string; suffix?: string; isWrapped?: boolean } = {}
): IBufferLine {
const cols = options.cols ?? COLS
const prefix = options.prefix ?? INDENT
const suffix = options.suffix ?? ''
const text = `${prefix}${fragment}`.padEnd(cols - suffix.length) + suffix
return {
isWrapped: options.isWrapped ?? false,
length: cols,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = text.length,
outColumns?: number[]
) => {
if (outColumns) {
outColumns.splice(
0,
outColumns.length,
...Array.from(
{ length: endColumn - startColumn + 1 },
(_value, index) => index + startColumn
)
)
}
return text.slice(startColumn, endColumn)
}
} as IBufferLine
}
function makeTerminal(options?: {
cols?: number
rows?: number
urlRows?: string[]
linePrefix?: string
lineSuffix?: string
softWrapped?: boolean
}): {
terminal: Terminal
registrations: ListenerRegistration[]
clearSelection: ReturnType<typeof vi.fn>
} {
const cols = options?.cols ?? COLS
const rows = options?.rows ?? ROWS
const urlRows = options?.urlRows ?? URL_ROWS
const registrations: ListenerRegistration[] = []
const ownerWindow = {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const ownerDocument = {
defaultView: ownerWindow,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const screen = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: cols * 10, height: rows * 10 })
}
const element = {
ownerDocument,
querySelector: vi.fn(() => screen),
addEventListener: vi.fn(
(name: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => {
registrations.push([name, listener, options])
}
),
removeEventListener: vi.fn()
}
const clearSelection = vi.fn()
return {
terminal: {
cols,
rows,
options: { mouseEventsRequireAlt: false },
element,
buffer: {
active: {
viewportY: 0,
getLine: (y: number) =>
urlRows[y] &&
makeBufferLine(urlRows[y], {
cols,
prefix: options?.linePrefix,
suffix: options?.lineSuffix,
isWrapped: (options?.softWrapped ?? true) && y > 0
})
}
},
clearSelection
} as unknown as Terminal,
registrations,
clearSelection
}
}
function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent {
let defaultPrevented = false
return {
button: 0,
metaKey: true,
ctrlKey: false,
altKey: options.altKey ?? false,
shiftKey: true,
get defaultPrevented() {
return defaultPrevented
},
clientX: 150,
clientY: row * 10 + 5,
preventDefault: vi.fn(() => {
defaultPrevented = true
})
} as unknown as MouseEvent
}
describe('hard-wrapped terminal HTTP clicks', () => {
beforeEach(() => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } })
registerHttpLinkStoreAccessor(() => ({
settings: { openLinksInApp: false },
setActiveWorktree: vi.fn(),
createBrowserTab: vi.fn()
}))
openUrlMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('opens the full logical URL once when WebLinksAddon reports only the first row', () => {
const { terminal, registrations, clearSelection } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const event = mouseEventForRow(0)
expect(
handleTerminalWebLinkClick(URL_ROWS[0], event, {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
const fallback = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1]
expect(fallback).toBeDefined()
fallback!(event)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
expect(new URL(URL_ROWS[0]).pathname).toHaveLength(136)
expect(`${new URL(FULL_URL).pathname}${new URL(FULL_URL).search}`).toHaveLength(811)
expect(event.preventDefault).toHaveBeenCalled()
expect(clearSelection).toHaveBeenCalled()
disposable.dispose()
})
it('opens the same full URL from a continuation-row fallback click', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const event = mouseEventForRow(3)
const fallback = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1]
fallback!(event)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
disposable.dispose()
})
it('reconstructs a URL split across cursor-positioned rows inside a TUI frame', () => {
const { terminal } = makeTerminal({
cols: 135,
urlRows: FRAMED_URL_ROWS,
linePrefix: ' │ ',
lineSuffix: '│ ',
softWrapped: false
})
const event = mouseEventForRow(0)
expect(
handleTerminalWebLinkClick(FRAMED_URL_ROWS[0], event, {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
expect(new URL(FRAMED_URL_ROWS[0]).pathname).toHaveLength(88)
})
it('reconstructs a URL that fills each cursor-positioned TUI row up to its frame', () => {
const cols = 135
const linePrefix = ' │ '
const lineSuffix = '│ '
const contentWidth = cols - linePrefix.length - lineSuffix.length
const fullWidthRows = Array.from(
{ length: Math.ceil(FULL_URL.length / contentWidth) },
(_value, index) => FULL_URL.slice(index * contentWidth, (index + 1) * contentWidth)
)
const { terminal } = makeTerminal({
cols,
urlRows: fullWidthRows,
linePrefix,
lineSuffix,
softWrapped: false
})
expect(
handleTerminalWebLinkClick(fullWidthRows[0], mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
})
it('reconstructs supported URLs spanning more than twenty framed rows', () => {
const cols = 80
const linePrefix = ' │ '
const lineSuffix = '│ '
const contentWidth = cols - linePrefix.length - lineSuffix.length
const longUrl = `http://example.com/${'a'.repeat(contentWidth * 20)}`
const urlRows = Array.from(
{ length: Math.ceil(longUrl.length / contentWidth) },
(_value, index) => longUrl.slice(index * contentWidth, (index + 1) * contentWidth)
)
const { terminal } = makeTerminal({
cols,
urlRows,
linePrefix,
lineSuffix,
softWrapped: false
})
expect(urlRows).toHaveLength(21)
expect(
handleTerminalWebLinkClick(urlRows[0], mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledWith(longUrl)
})
it('keeps nested HTTP URLs inside a wrapped query parameter', () => {
const cols = 80
const linePrefix = ' │ '
const lineSuffix = '│ '
const contentWidth = cols - linePrefix.length - lineSuffix.length
const firstRow = `http://example.com/${'a'.repeat(contentWidth - 'http://example.com/'.length)}`
const nestedQuery = 'segment?redirect=https://nested.example/path'
const secondRow = `${nestedQuery}${'b'.repeat(contentWidth - nestedQuery.length)}`
const urlRows = [firstRow, secondRow, 'tail']
const fullUrl = urlRows.join('')
const { terminal } = makeTerminal({
cols,
urlRows,
linePrefix,
lineSuffix,
softWrapped: false
})
expect(
handleTerminalWebLinkClick(firstRow, mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledWith(fullUrl)
})
it('does not append an unrelated aligned TUI row to a complete URL', () => {
const { terminal, registrations } = makeTerminal({
cols: 135,
urlRows: ['http://example.com/', 'next-token'],
linePrefix: ' │ ',
lineSuffix: '│ ',
softWrapped: false
})
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
expect(
handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledOnce()
expect(openUrlMock).toHaveBeenCalledWith('http://example.com/')
openUrlMock.mockReset()
const fallback = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1]
fallback!(mouseEventForRow(1))
expect(openUrlMock).not.toHaveBeenCalled()
disposable.dispose()
})
it('does not join a complete URL to multiple unrelated framed rows', () => {
const unrelatedFilledRow = 'a'.repeat(103)
const { terminal } = makeTerminal({
cols: 110,
urlRows: ['http://example.com/', unrelatedFilledRow, 'unrelated'],
linePrefix: ' │ ',
lineSuffix: '│ ',
softWrapped: false
})
expect(
handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledOnce()
expect(openUrlMock).toHaveBeenCalledWith('http://example.com/')
})
it('does not suppress a modifier-click when the buffer position is not an HTTP link', () => {
const { terminal, registrations } = makeTerminal({ urlRows: ['not-a-link'] })
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
mouseDown!(mouseEventForRow(0))
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
disposable.dispose()
})
it('leaves Alt-modified link gestures to the child TUI', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
const event = mouseEventForRow(0, { altKey: true })
mouseDown!(event)
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
expect(
handleTerminalWebLinkClick(URL_ROWS[0], event, {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(false)
expect(openUrlMock).not.toHaveBeenCalled()
disposable.dispose()
})
it('temporarily suppresses PTY mouse reporting at an HTTP link position', async () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
const mouseUp = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options !== undefined
)?.[1]
mouseDown!(mouseEventForRow(0))
expect(terminal.options.mouseEventsRequireAlt).toBe(true)
mouseUp!(mouseEventForRow(0))
await Promise.resolve()
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
disposable.dispose()
})
})
@@ -1,8 +1,15 @@
import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm'
import { openHttpLink } from '@/lib/http-link-routing'
import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing'
import { isTerminalLinkActivation } from './terminal-link-activation'
import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
import {
buildCandidateLogicalLinesForBufferPosition,
dedupeLogicalLines
} from './terminal-file-link-hit-testing'
import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression'
import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position'
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
type UrlLinkHitTestDeps = {
worktreeId: string
@@ -26,7 +33,7 @@ type ParsedTerminalHttpLink = {
}
const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] {
const links: ParsedTerminalHttpLink[] = []
@@ -56,7 +63,7 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean {
// Why: desktop terminal links require an intentional Cmd/Ctrl gesture so
// plain clicks remain available for cursor placement and selection. Mobile
// tap routing is handled separately under mobile/src/terminal.
return isTerminalLinkActivation(event)
return isTerminalHttpLinkActivation(event)
}
function* iterateTerminalHttpUrlCandidates(
@@ -185,56 +192,40 @@ function isAsciiWordCode(code: number): boolean {
)
}
function getTerminalScreenElement(terminal: Terminal): HTMLElement | null {
return terminal.element?.querySelector('.xterm-screen') ?? null
}
function getBufferPositionForTerminalMouseEvent(
export function openHttpLinkAtTerminalMouseEvent(
terminal: Terminal,
event: MouseEvent
): { x: number; y: number } | null {
const screenElement = getTerminalScreenElement(terminal)
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
return null
event: MouseEvent,
deps: UrlLinkHitTestDeps
): boolean {
if (event.button !== 0 || !isTerminalHttpLinkActivation(event)) {
return false
}
const rect = screenElement.getBoundingClientRect()
const relativeX = event.clientX - rect.left
const relativeY = event.clientY - rect.top
if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) {
return null
}
const cellWidth = rect.width / terminal.cols
const cellHeight = rect.height / terminal.rows
if (cellWidth <= 0 || cellHeight <= 0) {
return null
}
return {
x: Math.floor(relativeX / cellWidth) + 1,
y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
if (!position) {
return false
}
return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps)
}
export function installHttpLinkClickFallback(
terminal: Terminal,
deps: UrlLinkClickFallbackDeps
): IDisposable {
const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression(terminal, (event) => {
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
return Boolean(
position && findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)
)
})
const handleMouseUp = (event: MouseEvent): void => {
if (!isDesktopHttpLinkFallbackActivation(event)) {
return
}
const position = getBufferPositionForTerminalMouseEvent(terminal, event)
if (!position) {
return
}
// Why: xterm's WebLinksAddon only activates after hover state exists. This
// direct mouseup fallback preserves modifier-clicks when the hover link was
// never established, while defaultPrevented avoids duplicate opens.
const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, {
const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, {
worktreeId: deps.worktreeId,
forceSystemBrowser: event.shiftKey,
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
@@ -249,6 +240,7 @@ export function installHttpLinkClickFallback(
terminalElement?.addEventListener('mouseup', handleMouseUp)
return {
dispose: () => {
ptyMouseSuppression.dispose()
terminalElement?.removeEventListener('mouseup', handleMouseUp)
}
}
@@ -260,10 +252,30 @@ export function openHttpLinkAtBufferPosition(
terminalColumns: number,
deps: UrlLinkHitTestDeps
): boolean {
const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y)
if (logicalLines.length === 0) {
const url = findHttpLinkAtBufferPosition(buffer, position, terminalColumns)
if (!url) {
return false
}
openTerminalHttpLink(url, deps)
return true
}
function findHttpLinkAtBufferPosition(
buffer: { getLine(y: number): IBufferLine | undefined },
position: { x: number; y: number },
terminalColumns: number
): string | null {
const nativeWrappedLogicalLine = buildWrappedLogicalLine(buffer, position.y)
const logicalLines = dedupeLogicalLines([
...(nativeWrappedLogicalLine && nativeWrappedLogicalLine.rows.length > 1
? [nativeWrappedLogicalLine]
: []),
...buildHardWrappedHttpLogicalLineCandidates(buffer, position.y),
...buildCandidateLogicalLinesForBufferPosition(buffer, position.y)
])
if (logicalLines.length === 0) {
return null
}
for (const logicalLine of logicalLines) {
for (const parsed of extractTerminalHttpLinks(logicalLine.text)) {
@@ -271,12 +283,11 @@ export function openHttpLinkAtBufferPosition(
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
continue
}
openTerminalHttpLink(parsed.url, deps)
return true
return parsed.url
}
}
return false
return null
}
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
@@ -0,0 +1,50 @@
import type { Terminal } from '@xterm/xterm'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
import { handleOscLink } from './terminal-osc-link-routing'
import {
openHttpLinkAtTerminalMouseEvent,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
type TerminalWebLinkClickDeps = Pick<
LinkHandlerDeps,
'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath'
> & {
terminal: Terminal | null
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
export function handleTerminalWebLinkClick(
url: string,
event: MouseEvent | undefined,
deps: TerminalWebLinkClickDeps
): boolean {
if (!event || !isTerminalHttpLinkActivation(event)) {
return false
}
let handled: boolean
if (
deps.terminal &&
openHttpLinkAtTerminalMouseEvent(deps.terminal, event, {
worktreeId: deps.worktreeId,
forceSystemBrowser: Boolean(event.shiftKey),
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})
) {
// Why: WebLinksAddon only knows the physical row; Orca's logical hit-test
// preserves the complete URL rendered across hard-wrapped TUI rows.
event.preventDefault()
handled = true
} else {
handled = handleOscLink(url, event, deps)
}
if (handled) {
// Why: link navigation can steal focus before xterm's mouseup cleanup;
// clearing selection also detaches its pending drag-selection listeners.
deps.terminal?.clearSelection()
}
return handled
}
@@ -31,6 +31,7 @@ import {
import { createTerminalHandleLinkProvider } from './terminal-handle-links'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { handleOscLink } from './terminal-osc-link-routing'
import { handleTerminalWebLinkClick } from './terminal-web-link-click'
import {
installHttpLinkClickFallback,
type TerminalLinkRoutingPreferenceRequester
@@ -1480,28 +1481,16 @@ export function useTerminalPaneLifecycle({
terminalTuiScrollSensitivity: () =>
normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity),
onLinkClick: (event, url) => {
if (!event) {
return
}
const activePane = managerRef.current?.getActivePane()
const handled = handleOscLink(url, event, {
handleTerminalWebLinkClick(url, event, {
...linkDeps,
terminal: activePane?.terminal ?? null,
startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd,
runtimeEnvironmentId: activePane
? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
: null,
requestOpenLinksInAppPreference
})
// Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus
// away from the terminal before the click's mouseup reaches
// ownerDocument. That leaves xterm's SelectionService drag-select
// mousemove listener attached, so subsequent mouse motion extends a
// phantom selection until the next click/Esc. Explicitly clearing the
// selection also detaches those listeners (see
// SelectionService._removeMouseDownListeners).
if (handled) {
managerRef.current?.getActivePane()?.terminal.clearSelection()
}
},
formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint),
// Why: TerminalPane instances stay mounted for hidden visited worktrees
@@ -65,7 +65,7 @@ function translateLineWithCells(line: IBufferLine): { text: string; columns: num
return { text, columns }
}
function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } {
export function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } {
const columns: number[] = []
const text = (line as TerminalBufferLineWithColumns).translateToString(
false,