From d46349ce82fc6696e65e75c5c7df2590395a8522 Mon Sep 17 00:00:00 2001 From: Carlos Baraza Date: Fri, 19 Jun 2026 01:48:43 +0200 Subject: [PATCH] fix: improve mobile link modifier handling (#5597) --- .../app/h/[hostId]/session/[worktreeId].tsx | 7 +- mobile/src/browser/MobileBrowserKeyRow.tsx | 64 +++++++++++ mobile/src/browser/MobileBrowserPane.tsx | 69 +++++------- .../browser/MobileBrowserPointerModifiers.tsx | 95 ++++++++++++++++ mobile/src/terminal/TerminalWebView.tsx | 19 +++- .../terminal/terminal-accessory-keys.test.ts | 4 + .../src/terminal/terminal-accessory-keys.ts | 35 +++--- .../src/terminal/terminal-osc-link-ranges.ts | 18 ++++ mobile/src/terminal/terminal-webview-html.ts | 16 ++- .../src/terminal/terminal-webview-messages.ts | 2 + .../terminal-webview-tap-routing.test.ts | 61 +++++++++-- .../src/terminal/terminal-webview-url-tap.ts | 47 +++++++- src/main/browser/agent-browser-bridge.test.ts | 49 +++++++++ src/main/browser/agent-browser-bridge.ts | 49 +++++++-- src/main/daemon/daemon-pty-adapter.test.ts | 47 +++++++- src/main/daemon/daemon-pty-adapter.ts | 11 +- src/main/daemon/headless-emulator.test.ts | 46 ++++++++ src/main/daemon/headless-emulator.ts | 20 +++- src/main/daemon/headless-osc-link-ranges.ts | 90 ++++++++++++++++ src/main/daemon/history-manager.test.ts | 10 ++ src/main/daemon/history-manager.ts | 1 + src/main/daemon/history-reader.test.ts | 8 ++ src/main/daemon/history-reader.ts | 5 + ...rminal-history-incremental-restore.test.ts | 17 +++ src/main/daemon/types.ts | 8 +- src/main/ipc/pty.test.ts | 9 +- src/main/ipc/pty.ts | 3 +- src/main/providers/types.ts | 2 + src/main/runtime/orca-runtime-browser.ts | 11 +- src/main/runtime/orca-runtime.ts | 13 ++- .../runtime/rpc/methods/browser-extras.ts | 12 ++- src/main/runtime/rpc/methods/terminal.ts | 9 ++ .../rpc/terminal-subscribe-buffer.test.ts | 24 ++++- .../terminal-link-handlers.test.ts | 101 ++++++++++++++++-- .../terminal-pane/terminal-link-open-hints.ts | 8 +- .../terminal-osc-link-routing.ts | 23 ++-- .../terminal-url-link-hit-testing.ts | 19 ++-- .../src/lib/pane-manager/pane-lifecycle.ts | 4 +- src/shared/terminal-osc-link-ranges.ts | 21 ++++ 39 files changed, 914 insertions(+), 143 deletions(-) create mode 100644 mobile/src/browser/MobileBrowserKeyRow.tsx create mode 100644 mobile/src/browser/MobileBrowserPointerModifiers.tsx create mode 100644 mobile/src/terminal/terminal-osc-link-ranges.ts create mode 100644 src/main/daemon/headless-osc-link-ranges.ts create mode 100644 src/shared/terminal-osc-link-ranges.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 6a66420f8c6..d2fe6201f05 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -88,6 +88,7 @@ import { type TerminalModes, type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' +import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' import { getDefaultTerminalAccessoryBuiltInIds, @@ -1306,6 +1307,7 @@ export default function SessionScreen() { typeof data.serialized === 'string' && data.serialized.length > 0 ? data.serialized : '' + const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined const ref = getTerminalRef(handle) // Why: previously we set `initializedHandlesRef` even when the // WebView wasn't mounted yet (ref=null). The init message went @@ -1320,7 +1322,7 @@ export default function SessionScreen() { }) return } - ref.init(cols, rows, initialData) + ref.init(cols, rows, initialData, false, oscLinks) initializedHandlesRef.current.add(handle) if (data.displayMode) { setTerminalModes((prev) => @@ -1424,8 +1426,9 @@ export default function SessionScreen() { const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 const serialized = typeof data.serialized === 'string' ? data.serialized : null + const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined if (serialized != null) { - getTerminalRef(handle)?.init(cols, rows, serialized, true) + getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks) } else { getTerminalRef(handle)?.resize(cols, rows) } diff --git a/mobile/src/browser/MobileBrowserKeyRow.tsx b/mobile/src/browser/MobileBrowserKeyRow.tsx new file mode 100644 index 00000000000..6cd43e3371a --- /dev/null +++ b/mobile/src/browser/MobileBrowserKeyRow.tsx @@ -0,0 +1,64 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +const BROWSER_KEYS = ['Enter', 'Backspace', 'Tab', 'Escape'] as const + +type Props = { + disabled: boolean + onKeypress: (key: string) => void +} + +export function MobileBrowserKeyRow({ disabled, onKeypress }: Props): React.JSX.Element { + return ( + + {BROWSER_KEYS.map((key) => ( + [ + styles.keyButton, + pressed && styles.keyButtonPressed, + disabled && styles.disabled + ]} + disabled={disabled} + onPress={() => onKeypress(key)} + > + + {key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key} + + + ))} + + ) +} + +const styles = StyleSheet.create({ + keyRow: { + flexDirection: 'row', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingTop: spacing.xs + }, + keyButton: { + minHeight: 30, + minWidth: 42, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.sm + }, + keyButtonPressed: { + backgroundColor: colors.borderSubtle + }, + keyButtonText: { + color: colors.textSecondary, + fontSize: 12, + fontFamily: typography.monoFamily + }, + disabled: { + opacity: 0.35 + }, + disabledText: { + color: colors.textMuted + } +}) diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 7fac1f31ef2..7e34ed76c69 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -36,6 +36,11 @@ import { MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS, buildMobileBrowserScreencastRequest } from './browser-screencast-request' +import { + MobileBrowserPointerModifiers, + type BrowserPointerModifier +} from './MobileBrowserPointerModifiers' +import { MobileBrowserKeyRow } from './MobileBrowserKeyRow' import { clampBrowserZoomState, computeBrowserFrameGeometry, @@ -150,6 +155,7 @@ export function MobileBrowserPane({ const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [dialog, setDialog] = useState(null) + const [pointerModifiers, setPointerModifiers] = useState([]) const [zoom, setZoom] = useState(DEFAULT_ZOOM) const [layout, setLayout] = useState(null) const [appActive, setAppActive] = useState(AppState.currentState === 'active') @@ -652,6 +658,7 @@ export function MobileBrowserPane({ x: point.x, y: point.y, button, + modifiers: pointerModifiers, ...(button === 'left' ? { radius: computeBrowserTouchClickRadiusCss( @@ -665,7 +672,7 @@ export function MobileBrowserPane({ }, { suppressError: true, timeoutMs: 5_000 } ) - if (clickResult !== null) { + if (clickResult !== null || pointerModifiers.length > 0) { return } try { @@ -687,9 +694,17 @@ export function MobileBrowserPane({ // actionable failures still surface through navigation/stream errors. } }, - [client, pageParams, sendBrowserRequest] + [client, pageParams, pointerModifiers, sendBrowserRequest] ) + const togglePointerModifier = useCallback((modifier: BrowserPointerModifier) => { + setPointerModifiers((current) => + current.includes(modifier) + ? current.filter((candidate) => candidate !== modifier) + : [...current, modifier] + ) + }, []) + const sendWheel = useCallback( (point: BrowserPoint, screenDx: number, screenDy: number) => { const base = pageParams() @@ -1223,24 +1238,15 @@ export function MobileBrowserPane({ { paddingBottom: bottomInset, transform: [{ translateY: -keyboardLift }] } ]} > - - {['Enter', 'Backspace', 'Tab', 'Escape'].map((key) => ( - [ - styles.keyButton, - pressed && styles.keyButtonPressed, - controlsDisabled && styles.disabled - ]} - disabled={controlsDisabled} - onPress={() => void sendKeypress(key)} - > - - {key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key} - - - ))} - + + void sendKeypress(key)} + /> void +} + +export function MobileBrowserPointerModifiers({ + disabled, + selectedModifiers, + onToggle +}: Props): React.JSX.Element { + return ( + + {BROWSER_POINTER_MODIFIERS.map((modifier) => { + const selected = selectedModifiers.includes(modifier.id) + return ( + [ + styles.keyButton, + selected && styles.keyButtonSelected, + pressed && !selected && styles.keyButtonPressed, + disabled && styles.disabled + ]} + disabled={disabled} + onPress={() => onToggle(modifier.id)} + accessibilityRole="button" + accessibilityState={{ selected, disabled }} + accessibilityLabel={`${modifier.label} click modifier`} + > + + {modifier.label} + + + ) + })} + + ) +} + +const styles = StyleSheet.create({ + modifierRow: { + flexDirection: 'row', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingTop: spacing.xs + }, + keyButton: { + minHeight: 30, + minWidth: 42, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.sm + }, + keyButtonPressed: { + backgroundColor: colors.borderSubtle + }, + keyButtonSelected: { + backgroundColor: colors.textPrimary + }, + keyButtonText: { + color: colors.textSecondary, + fontSize: 12, + fontFamily: typography.monoFamily + }, + keyButtonTextSelected: { + color: colors.bgBase + }, + disabled: { + opacity: 0.35 + }, + disabledText: { + color: colors.textMuted + } +}) diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 8c6084a0255..a45188d7712 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -4,11 +4,13 @@ import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import { colors } from '../theme/mobile-theme' +import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' import { XTERM_HTML } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' +type TerminalOscLinks = TerminalOscLinkRange[] export type TerminalModes = { bracketedPasteMode: boolean @@ -46,7 +48,13 @@ export type TerminalSelectionEvents = { export type TerminalWebViewHandle = { write: (data: string) => void - init: (cols: number, rows: number, initialData?: string, preserveScroll?: boolean) => void + init: ( + cols: number, + rows: number, + initialData?: string, + preserveScroll?: boolean, + oscLinks?: TerminalOscLinks + ) => void resize: (cols: number, rows: number) => void // Why: reflow the local xterm buffer (scrollback included) to a new width // after a server-side PTY reflow, so older wrapped lines rewrap to match the @@ -276,7 +284,13 @@ export const TerminalWebView = forwardRef(function write(data: string) { postMessage({ type: 'write', data }) }, - init(cols: number, rows: number, initialData?: string, preserveScroll?: boolean) { + init( + cols: number, + rows: number, + initialData?: string, + preserveScroll?: boolean, + oscLinks?: TerminalOscLinks + ) { // Why: arm a fresh ready promise BEFORE posting init. The WebView // resolves it via the 'ready' notify at the end of its rAF chain. // Resolve any prior in-flight ready first so awaiters from the @@ -298,6 +312,7 @@ export const TerminalWebView = forwardRef(function cols, rows, initialData, + oscLinks, terminalTheme, fontScale: textScale, preserveScroll diff --git a/mobile/src/terminal/terminal-accessory-keys.test.ts b/mobile/src/terminal/terminal-accessory-keys.test.ts index b46f3ee8ec4..b7a3a1015de 100644 --- a/mobile/src/terminal/terminal-accessory-keys.test.ts +++ b/mobile/src/terminal/terminal-accessory-keys.test.ts @@ -45,6 +45,10 @@ describe('TERMINAL_ACCESSORY_KEYS', () => { expect(new Set(ids).size).toBe(ids.length) }) + it('keeps all built-in terminal keys byte-backed', () => { + expect(TERMINAL_ACCESSORY_KEYS.every((key) => key.bytes.length > 0)).toBe(true) + }) + it('keeps repeat behavior explicit for built-in terminal keys', () => { const repeatableIds = new Set([ 'backspace', diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index cac5731f3c0..49aaba134d5 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -294,27 +294,23 @@ function buildShortcutBytes(key: string, modifiers: TerminalShortcutModifier[]): return buildCsiTildeShortcut(csiTilde, modifiers) } if (key === 'tab') { - if ( - hasModifier(modifiers, 'shift') && - !hasModifier(modifiers, 'ctrl') && - !hasModifier(modifiers, 'alt') - ) { + if (modifiers.includes('shift') && !modifiers.includes('ctrl') && !modifiers.includes('alt')) { return `${ESC}[Z` } const bytes = '\t' - return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes } if (key === 'escape') { const bytes = ESC - return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes } if (key === 'enter') { const bytes = '\r' - return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes } if (key === 'backspace') { - const bytes = hasModifier(modifiers, 'ctrl') ? '\b' : '\x7f' - return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + const bytes = modifiers.includes('ctrl') ? '\b' : '\x7f' + return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes } if (isPrintableShortcutKey(key)) { return buildPrintableShortcutBytes(key, modifiers) @@ -326,16 +322,16 @@ function buildPrintableShortcutBytes( key: string, modifiers: TerminalShortcutModifier[] ): string | null { - const shifted = hasModifier(modifiers, 'shift') ? applyShift(key) : key + const shifted = modifiers.includes('shift') ? applyShift(key) : key let bytes = shifted - if (hasModifier(modifiers, 'ctrl')) { + if (modifiers.includes('ctrl')) { const ctrlBytes = controlBytesForPrintable(shifted) if (ctrlBytes == null) { return null } bytes = ctrlBytes } - return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes } function buildCsiFinalShortcut(final: string, modifiers: TerminalShortcutModifier[]): string { @@ -350,13 +346,13 @@ function buildCsiTildeShortcut(code: number, modifiers: TerminalShortcutModifier function csiModifierParameter(modifiers: TerminalShortcutModifier[]): number { let parameter = 1 - if (hasModifier(modifiers, 'shift')) { + if (modifiers.includes('shift')) { parameter += 1 } - if (hasModifier(modifiers, 'alt')) { + if (modifiers.includes('alt')) { parameter += 2 } - if (hasModifier(modifiers, 'ctrl')) { + if (modifiers.includes('ctrl')) { parameter += 4 } return parameter @@ -413,10 +409,3 @@ function displayKeyLabel(key: string): string { } return key.length === 1 && key >= 'a' && key <= 'z' ? key.toUpperCase() : key } - -function hasModifier( - modifiers: TerminalShortcutModifier[], - modifier: TerminalShortcutModifier -): boolean { - return modifiers.includes(modifier) -} diff --git a/mobile/src/terminal/terminal-osc-link-ranges.ts b/mobile/src/terminal/terminal-osc-link-ranges.ts new file mode 100644 index 00000000000..5f7f18f82d1 --- /dev/null +++ b/mobile/src/terminal/terminal-osc-link-ranges.ts @@ -0,0 +1,18 @@ +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' + +export type { TerminalOscLinkRange } + +export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] { + return ( + Array.isArray(value) && + value.every( + (entry) => + entry != null && + typeof entry === 'object' && + Number.isInteger((entry as TerminalOscLinkRange).row) && + Number.isInteger((entry as TerminalOscLinkRange).startCol) && + Number.isInteger((entry as TerminalOscLinkRange).endCol) && + typeof (entry as TerminalOscLinkRange).uri === 'string' + ) + ) +} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index afc5bf05cbb..9f8b886a711 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -274,6 +274,8 @@ export const XTERM_HTML = ` var trackedMouseTrackingMode = 'none'; var sgrMouseMode = false; var sgrMousePixelsMode = false; + var initialOscLinks = [], initialOscLinkRowOffset = 0; + var initialOscLinkEvictionReady = false; var mouseModeScanTail = ''; var handledMessageIds = []; // Why: after init() the initial scrollback applyFitScale may have run @@ -654,7 +656,7 @@ export const XTERM_HTML = ` pumpWrites(terminalGeneration); } - function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll) { + function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; // Why: a width-reflow re-stream rewraps the same content at new cols. // Distance-from-bottom (rows) is the only stable anchor across reflow, @@ -687,6 +689,9 @@ export const XTERM_HTML = ` // mirrored modes aligned with exactly what this mobile xterm replays. updateMouseModeFromData(replayData); activeAltScreenSnapshot = isAltScreenActive(replayData); + initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = false; var oldTerm = term; var oldSurface = surface; var nextSurface = null; @@ -753,6 +758,9 @@ export const XTERM_HTML = ` if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {} } + captureInitialOscLinkTexts(); + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = true; applyFitScale('init-replay'); notify({ type: 'ready', cols: cols, rows: rows }); }); @@ -855,7 +863,7 @@ export const XTERM_HTML = ` if (handledMessageIds.length > 256) handledMessageIds.shift(); } if (msg.type === 'init') { - init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll); + init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks); } else if (msg.type === 'set-font-scale') { // Why: ignore RN echoing back the value a pinch just set (msg.fontScale === // currentTextScale) so the post-pinch state isn't reset; only apply changes. @@ -880,6 +888,9 @@ export const XTERM_HTML = ` trackedMouseTrackingMode = 'none'; sgrMouseMode = false; sgrMousePixelsMode = false; + initialOscLinks = []; + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = false; if (term) { term.clear(); term.reset(); } emitModesIfChanged(); resetEvictionCounter(); @@ -977,6 +988,7 @@ export const XTERM_HTML = ` function logFeedAndEvict() { linesEverWritten++; + if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; if (selMode === 'select' && sel && isBufferFull()) { sel.anchor.row -= 1; sel.focus.row -= 1; diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index 67d1fceff37..f38c5a1dfbb 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -1,4 +1,5 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' export type TerminalWebViewCommand = | { type: 'write'; id?: number; data: string } @@ -8,6 +9,7 @@ export type TerminalWebViewCommand = cols: number rows: number initialData?: string + oscLinks?: TerminalOscLinkRange[] terminalTheme?: RuntimeMobileTerminalTheme fontScale?: number // Why: width-reflow re-streams replay the same content rewrapped at new diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts index 1638111459d..a4d18c6e56e 100644 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts @@ -19,8 +19,7 @@ function bodyMarkup(): string { } // Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells. -function makeTerminal(line: string) { - const lines = [line] +function makeTerminal(lineRef: { current: string }) { return { cols: 80, rows: 24, @@ -32,11 +31,11 @@ function makeTerminal(line: string) { active: { viewportY: 0, baseY: 0, - length: lines.length, + length: 1, cursorY: 0, type: 'normal' as const, getLine(row: number) { - const text = lines[row] + const text = row === 0 ? lineRef.current : undefined if (text === undefined) { return null } @@ -73,11 +72,17 @@ function makeTerminal(line: string) { type Posted = Array> -function boot(line: string): { posted: Posted } { +type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string } + +function boot( + line: string, + oscLinks?: OscLinkRange[] +): { posted: Posted; setLine: (line: string) => void } { const posted: Posted = [] + const lineRef = { current: line } const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown } w.Terminal = function () { - return makeTerminal(line) + return makeTerminal(lineRef) } w.ReactNativeWebView = { postMessage(s: string) { @@ -89,10 +94,15 @@ function boot(line: string): { posted: Posted } { new Function(iifeSource())() window.dispatchEvent( new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 80, rows: 24, initialData: '' }) + data: JSON.stringify({ type: 'init', cols: 80, rows: 24, initialData: '', oscLinks }) }) ) - return { posted } + return { + posted, + setLine: (nextLine: string) => { + lineRef.current = nextLine + } + } } function fireTouch(type: string, touches: Array<{ x: number; y: number }>): void { @@ -114,6 +124,7 @@ describe('terminal WebView tap routing', () => { const URL_LINE = 'visit https://example.com/foo now' const tapX = 12 * 8 * 0.3125 const tapY = 2 + const screenXForCol = (col: number): number => col * 8 * 0.3125 beforeEach(() => { Object.defineProperty(window, 'innerWidth', { value: 200, configurable: true }) @@ -152,6 +163,40 @@ describe('terminal WebView tap routing', () => { expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo') }) + it('opens first-load OSC links from snapshot metadata on the exact cell range', async () => { + const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] + const { posted } = boot('issue #1234 done', oscLinks) + await settle() + + fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) + fireTouch('touchend', []) + + expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234') + }) + + it('does not open snapshot OSC links from adjacent terminal cells', async () => { + const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] + const { posted } = boot('issue #1234 done', oscLinks) + await settle() + + fireTouch('touchstart', [{ x: screenXForCol(12), y: tapY }]) + fireTouch('touchend', []) + + expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() + }) + + it('does not open stale snapshot OSC links after the row text changes', async () => { + const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] + const { posted, setLine } = boot('issue #1234 done', oscLinks) + await settle() + setLine('issue plain done') + + fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) + fireTouch('touchend', []) + + expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() + }) + it('does not post open-url for a scroll gesture past the tap slop', async () => { const { posted } = boot(URL_LINE) await settle() diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index c84f172e99f..7f9ad82d643 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -59,20 +59,57 @@ export const URL_TAP_WEBVIEW_JS = ` } function oscLinkAtViewportPoint(clientX, clientY) { try { - var svc = oscLinkService(); - if (!svc || !svc.getLinkData) return null; var cell = viewportToCell(clientX, clientY); if (!cell) return null; var line = term.buffer.active.getLine(cell.row); if (!line) return null; - var bufCell = line.getCell(cell.col); - var urlId = bufCell && bufCell.extended && bufCell.extended.urlId; - if (!urlId) return null; + var urlId = oscLinkIdAtCell(line, cell.col); + if (!urlId) return initialOscLinkAtCell(cell.row, cell.col); + var svc = oscLinkService(); + if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col); var data = svc.getLinkData(urlId); var uri = data && data.uri; return uri && /^https?:/i.test(uri) ? uri : null; } catch (e) { return null; } } + function initialOscLinkAtCell(row, col) { + for (var i = 0; i < initialOscLinks.length; i++) { + var link = initialOscLinks[i]; + if (!link || typeof link.uri !== 'string' || !/^https?:/i.test(link.uri)) continue; + if (link.row < initialOscLinkRowOffset) continue; + var shiftedRow = link.row - initialOscLinkRowOffset; + if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return link.uri; + } + return null; + } + function captureInitialOscLinkTexts() { + if (!Array.isArray(initialOscLinks)) return; + for (var i = 0; i < initialOscLinks.length; i++) { + var link = initialOscLinks[i]; + if (!link || typeof link.text === 'string') continue; + link.text = initialOscLinkTextAtRow(link, link.row); + } + } + function initialOscLinkTextStillMatches(link, row) { + if (typeof link.text !== 'string') return false; + return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text; + } + function initialOscLinkTextAtRow(link, row) { + try { + var lineText = getLineText(row); + var start = cellColToStringIndex(row, link.startCol); + var end = cellColToStringIndex(row, link.endCol); + return lineText.slice(start, end); + } catch (e) { + return ''; + } + } + function oscLinkIdAtCell(line, col) { + try { + var bufCell = line.getCell(col); + return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0; + } catch (e) { return 0; } + } function notifyTerminalSurfaceTap(originX, originY) { var tappedUrl = oscLinkAtViewportPoint(originX, originY) || urlAtViewportPoint(originX, originY); diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index 99185f653c5..d54816d28f3 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -455,6 +455,55 @@ describe('AgentBrowserBridge', () => { expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 10, y: 20 }) }) + it('passes mobile click modifiers through to CDP mouse events', async () => { + const wc = mockWebContents(100) + wc.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Runtime.evaluate') { + return { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } } + } + return {} + }) + webContentsFromIdMock.mockReturnValue(wc) + + await bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18, ['cmd', 'shift']) + + const mouseCalls = wc.debugger.sendCommand.mock.calls.filter( + (call) => call[0] === 'Input.dispatchMouseEvent' + ) + expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', modifiers: 12 }) + expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', modifiers: 12 }) + }) + + it('keeps adjusted mobile tap coordinates but uses CDP for modifier clicks', async () => { + const wc = mockWebContents(100) + wc.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Runtime.evaluate') { + return { result: { value: { x: 12, y: 34, adjusted: true, handled: false } } } + } + return {} + }) + webContentsFromIdMock.mockReturnValue(wc) + + await expect( + bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18, ['cmd']) + ).resolves.toEqual({ + clicked: { x: 12, y: 34, button: 'left', adjusted: true, handled: false } + }) + + const evaluateCall = wc.debugger.sendCommand.mock.calls.find( + (call) => call[0] === 'Runtime.evaluate' + ) + expect((evaluateCall?.[1] as { expression?: string } | undefined)?.expression).toContain( + 'const allowDomActivation = false' + ) + const mouseCalls = wc.debugger.sendCommand.mock.calls.filter( + (call) => call[0] === 'Input.dispatchMouseEvent' + ) + expect(mouseCalls).toHaveLength(2) + expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', x: 12, y: 34, modifiers: 4 }) + expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 12, y: 34, modifiers: 4 }) + }) + it('drops empty command queues after direct CDP commands finish', async () => { const wc = mockWebContents(100) wc.debugger.sendCommand.mockResolvedValue({}) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 5f7a33b9155..29090f19837 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -81,6 +81,8 @@ type ResolvedBrowserCommandTarget = { webContentsId: number } +export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' + type AgentBrowserExecOptions = { envOverrides?: NodeJS.ProcessEnv timeoutMs?: number @@ -233,6 +235,25 @@ function cdpMouseButtonMask(button: CdpMouseButton): number { return 1 } +function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { + if (!modifiers || modifiers.length === 0) { + return 0 + } + let mask = 0 + for (const modifier of modifiers) { + if (modifier === 'alt') { + mask |= 1 + } else if (modifier === 'ctrl') { + mask |= 2 + } else if (modifier === 'cmd') { + mask |= 4 + } else if (modifier === 'shift') { + mask |= 8 + } + } + return mask +} + function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { const point = value && typeof value === 'object' ? (value as Record) : null const x = point?.x @@ -248,11 +269,17 @@ function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserCli return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } } -function mobileTouchClickExpression(x: number, y: number, radius: number): string { +function mobileTouchClickExpression( + x: number, + y: number, + radius: number, + allowDomActivation: boolean +): string { return `(() => { const inputX = ${JSON.stringify(x)}; const inputY = ${JSON.stringify(y)}; const radius = ${JSON.stringify(radius)}; + const allowDomActivation = ${JSON.stringify(allowDomActivation)}; const selector = [ 'a[href]', 'button', @@ -343,9 +370,12 @@ function mobileTouchClickExpression(x: number, y: number, radius: number): strin break; } } - if (best && dispatchClick(best.target, best.x, best.y)) { + if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { return { x: best.x, y: best.y, adjusted: true, handled: true }; } + if (best) { + return { x: best.x, y: best.y, adjusted: true, handled: false }; + } return { x: inputX, y: inputY, adjusted: false, handled: false }; })()` } @@ -354,7 +384,8 @@ async function resolveMobileTouchClickPoint( dbg: WebContents['debugger'], x: number, y: number, - radius?: number + radius: number | undefined, + allowDomActivation: boolean ): Promise { const fallback = { x, y, adjusted: false, handled: false } if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { @@ -362,7 +393,7 @@ async function resolveMobileTouchClickPoint( } try { const result = await dbg.sendCommand('Runtime.evaluate', { - expression: mobileTouchClickExpression(x, y, radius), + expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), returnByValue: true, silent: true }) @@ -839,7 +870,8 @@ export class AgentBrowserBridge { button?: string, worktreeId?: string, browserPageId?: string, - radius?: number + radius?: number, + modifiers?: BrowserMouseModifier[] ): Promise { return this.enqueueTargetedCommand( worktreeId, @@ -854,12 +886,15 @@ export class AgentBrowserBridge { } const cdpButton = normalizeCdpMouseButton(button) const buttons = cdpMouseButtonMask(cdpButton) + const cdpModifiers = cdpMouseModifierMask(modifiers) const lease = acquireElectronDebugger(wc) try { wc.focus() const point = cdpButton === 'left' - ? await resolveMobileTouchClickPoint(wc.debugger, x, y, radius) + ? // Why: DOM activation cannot carry Cmd/Ctrl/Alt/Shift, so modifier + // clicks use only the adjusted point and let CDP dispatch the event. + await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) : { x, y, adjusted: false, handled: false } // Why: mobile taps should land as one atomic input operation. Sending // move/down/up through separate CLI calls visibly hovers targets and can @@ -873,6 +908,7 @@ export class AgentBrowserBridge { y: point.y, button: cdpButton, buttons, + modifiers: cdpModifiers, clickCount: 1 }) await wc.debugger.sendCommand('Input.dispatchMouseEvent', { @@ -881,6 +917,7 @@ export class AgentBrowserBridge { y: point.y, button: cdpButton, buttons: 0, + modifiers: cdpModifiers, clickCount: 1 }) } diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 862540075bd..6ad7c739ceb 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -810,6 +810,49 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + it('returns cold restore OSC link ranges from checkpoint history', async () => { + const sessionId = 'cold-restore-osc-links' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + const oscLinks = [{ row: 0, startCol: 0, endCol: 5, uri: 'https://example.com/issue/1234' }] + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/myapp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync( + join(sessionDir, 'checkpoint.json'), + JSON.stringify({ + snapshotAnsi: '#1234\r\n', + scrollbackAnsi: '', + oscLinks, + rehydrateSequences: '', + cwd: '/projects/myapp', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + scrollbackLines: 0, + checkpointedAt: '2026-04-15T11:00:00Z' + }) + ) + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore?.oscLinks).toEqual(oscLinks) + }) + it('re-anchors a cold-restored session with a full checkpoint on the first tick', async () => { const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS @@ -914,7 +957,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) const internals = historyAdapter as unknown as { - coldRestoreCache: Map + coldRestoreCache: Map } await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) @@ -944,7 +987,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) const internals = historyAdapter as unknown as { - coldRestoreCache: Map + coldRestoreCache: Map } await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 59fee26d343..aa656851f96 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -23,6 +23,13 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' + +type ColdRestorePayload = { + scrollback: string + cwd: string + oscLinks?: TerminalOscLinkRange[] +} export type DaemonPtyAdapterOptions = { socketPath: string @@ -72,7 +79,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: React StrictMode double-mounts: mount → cold restore → unmount → // mount → ??? The sticky cache returns the same cold restore data on the // second mount until the renderer explicitly acknowledges it. - private coldRestoreCache = new Map() + private coldRestoreCache = new Map() private activeSessionIds = new Set() private dirtySessionVersions = new Map() // Why: a cold-restored session is a fresh shell whose on-disk checkpoint and @@ -221,7 +228,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.add(sessionId) } if (scrollback) { - const coldRestore = { scrollback, cwd: restoreInfo.cwd } + const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } this.coldRestoreCache.set(sessionId, coldRestore) return { id: sessionId, pid, coldRestore } } diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 14e591d4924..c2ab445cacd 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -48,6 +48,52 @@ describe('HeadlessEmulator', () => { const snapshot = emulator.getSnapshot() expect(snapshot.snapshotAnsi).toContain('red text') }) + + it('captures OSC 8 link ranges in snapshot metadata', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b]8;;https://news.ycombinator.com\x07Hacker News\x1b]8;;\x07') + + expect(emulator.getSnapshot().oscLinks).toEqual([ + { + row: 0, + startCol: 0, + endCol: 11, + uri: 'https://news.ycombinator.com' + } + ]) + }) + + it('captures scrollback OSC 8 ranges in unrestricted snapshots', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 2, scrollback: 10 }) + await emulator.write('\x1b]8;;https://example.com/old\x07old\x1b]8;;\x07\r\nplain\r\nvisible') + + expect(emulator.getSnapshot().oscLinks).toContainEqual({ + row: 0, + startCol: 0, + endCol: 3, + uri: 'https://example.com/old' + }) + expect( + emulator + .getSnapshot({ scrollbackRows: 0 }) + .oscLinks?.some((link) => link.uri === 'https://example.com/old') + ).toBe(false) + }) + + it('projects restored OSC 8 ranges into serialized snapshot windows', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('issue #1234 done') + emulator.setRestoredOscLinks([ + { row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' } + ]) + + expect(emulator.getSnapshot().oscLinks).toContainEqual({ + row: 0, + startCol: 6, + endCol: 11, + uri: 'https://example.com/issue/1234' + }) + }) }) describe('OSC-7 CWD tracking', () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e2e722de729..9ccc53c69f0 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -2,8 +2,10 @@ import './xterm-env-polyfill' import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { extractLastOscTitle } from '../../shared/agent-detection' +import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { parseFileUriPath } from './osc7-file-uri' import type { TerminalSnapshot, TerminalModes } from './types' +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' export type HeadlessEmulatorOptions = { cols: number @@ -11,10 +13,6 @@ export type HeadlessEmulatorOptions = { scrollback?: number } -export type HeadlessSnapshotOptions = { - scrollbackRows?: number -} - type TerminalWithSynchronousWrite = Terminal & { _core?: { writeSync?: (data: string) => void @@ -38,6 +36,7 @@ export class HeadlessEmulator { private mouseTrackingMode: MouseTrackingMode = 'none' private sgrMouseMode = false private sgrMousePixelsMode = false + private restoredOscLinks: TerminalOscLinkRange[] = [] private disposed = false constructor(opts: HeadlessEmulatorOptions) { @@ -122,10 +121,11 @@ export class HeadlessEmulator { if (this.disposed) { return } + this.restoredOscLinks = [] this.terminal.resize(cols, rows) } - getSnapshot(opts: HeadlessSnapshotOptions = {}): TerminalSnapshot { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot { const modes = this.getModes() const snapshotAnsi = this.normalizeSnapshotAnsiForModes( this.serializer.serialize({ scrollback: opts.scrollbackRows }), @@ -134,6 +134,11 @@ export class HeadlessEmulator { return { snapshotAnsi, scrollbackAnsi: '', + oscLinks: collectHeadlessOscLinkRanges( + this.terminal, + opts.scrollbackRows, + this.restoredOscLinks + ), rehydrateSequences: this.buildRehydrateSequences(modes), cwd: this.cwd, modes, @@ -169,7 +174,12 @@ export class HeadlessEmulator { this.lastTitle = title } + setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void { + this.restoredOscLinks = links?.slice() ?? [] + } + clearScrollback(): void { + this.restoredOscLinks = [] this.terminal.clear() } diff --git a/src/main/daemon/headless-osc-link-ranges.ts b/src/main/daemon/headless-osc-link-ranges.ts new file mode 100644 index 00000000000..418a0c65166 --- /dev/null +++ b/src/main/daemon/headless-osc-link-ranges.ts @@ -0,0 +1,90 @@ +import type { Terminal } from '@xterm/headless' +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' + +type TerminalWithOscLinks = Terminal & { + _core?: { + _oscLinkService?: { + getLinkData: (linkId: number) => { uri?: string } | undefined + } + } +} + +type CellWithOscLink = { + extended?: { urlId?: number } + hasExtendedAttrs?: () => boolean +} + +export function collectHeadlessOscLinkRanges( + terminal: Terminal, + scrollbackRows: number | undefined, + restoredLinks: TerminalOscLinkRange[] = [] +): TerminalOscLinkRange[] { + // Why: headless xterm exposes OSC 8 metadata only via this private service. + // Keep this boundary explicit so xterm upgrades are audited here. + const service = (terminal as TerminalWithOscLinks)._core?._oscLinkService + if (!service) { + return [] + } + const buffer = terminal.buffer.active + const startRow = + scrollbackRows === undefined ? 0 : Math.max(0, buffer.length - terminal.rows - scrollbackRows) + const ranges: TerminalOscLinkRange[] = [] + for (let row = startRow; row < buffer.length; row += 1) { + const line = buffer.getLine(row) + if (!line) { + continue + } + const lineLength = Math.min(terminal.cols, line.length) + let currentUrlId = 0 + let currentStart = -1 + for (let col = 0; col <= lineLength; col += 1) { + const urlId = col < lineLength ? getOscLinkIdAtCell(line, col) : 0 + if (urlId === currentUrlId) { + continue + } + if (currentUrlId && currentStart >= 0) { + const uri = service.getLinkData(currentUrlId)?.uri + if (uri) { + ranges.push({ row: row - startRow, startCol: currentStart, endCol: col, uri }) + } + } + currentUrlId = urlId + currentStart = urlId ? col : -1 + } + } + for (const link of restoredLinks) { + if (link.row < startRow || link.row >= buffer.length) { + continue + } + const startCol = Math.max(0, Math.min(terminal.cols, link.startCol)) + const endCol = Math.max(0, Math.min(terminal.cols, link.endCol)) + if (startCol >= endCol) { + continue + } + ranges.push({ + row: link.row - startRow, + startCol, + endCol, + uri: link.uri + }) + } + return dedupeOscLinkRanges(ranges) +} + +function dedupeOscLinkRanges(ranges: TerminalOscLinkRange[]): TerminalOscLinkRange[] { + const seen = new Set() + return ranges.filter((range) => { + const key = `${range.row}:${range.startCol}:${range.endCol}:${range.uri}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function getOscLinkIdAtCell(line: { getCell: (col: number) => unknown }, col: number): number { + const cell = line.getCell(col) as CellWithOscLink | undefined + // Why: OSC link IDs live in extended cell attrs; missing attrs means no link. + return cell?.hasExtendedAttrs?.() && cell.extended?.urlId ? cell.extended.urlId : 0 +} diff --git a/src/main/daemon/history-manager.test.ts b/src/main/daemon/history-manager.test.ts index bfca0b2cbc3..affb2c70b8e 100644 --- a/src/main/daemon/history-manager.test.ts +++ b/src/main/daemon/history-manager.test.ts @@ -125,6 +125,16 @@ describe('HistoryManager', () => { expect(data.rehydrateSequences).toBe('\x1b[?2004h\x1b[?1h') }) + it('preserves OSC link ranges in checkpoint', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] + + await mgr.checkpoint('sess-1', makeSnapshot({ oscLinks })) + + const data = JSON.parse(readFileSync(sessionPath(dir, 'sess-1', 'checkpoint.json'), 'utf-8')) + expect(data.oscLinks).toEqual(oscLinks) + }) + it('ignores checkpoint for unknown sessions', async () => { await mgr.checkpoint('nonexistent', makeSnapshot()) }) diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 2c3a1f87f0e..0c9322a4572 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -201,6 +201,7 @@ export class HistoryManager { const checkpointFile: TerminalCheckpointFile = { snapshotAnsi: snapshot.snapshotAnsi, scrollbackAnsi: snapshot.scrollbackAnsi, + oscLinks: snapshot.oscLinks, rehydrateSequences: snapshot.rehydrateSequences, cwd: effectiveCwd, cols: snapshot.cols, diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 40fbc9812f9..71c696b639d 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -118,6 +118,14 @@ describe('HistoryReader', () => { expect(info!.rehydrateSequences).toBe('\x1b[?2004h') }) + it('restores OSC link ranges from checkpoint', () => { + const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] + writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint({ oscLinks })) + + const info = reader.detectColdRestore('sess-1') + expect(info!.oscLinks).toEqual(oscLinks) + }) + it('returns null for clean shutdown (endedAt is set)', () => { writeSessionWithCheckpoint( dir, diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 0fb00a1eac4..05c7646f5da 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -2,6 +2,7 @@ import { join } from 'path' import { readFileSync, existsSync, readdirSync } from 'fs' import type { SessionMeta } from './history-manager' import type { TerminalCheckpointFile, TerminalModes } from './types' +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import { getHistorySessionDirName } from './history-paths' import { decodeTerminalHistoryLog } from './terminal-history-log' import { HeadlessEmulator } from './headless-emulator' @@ -9,6 +10,7 @@ import { HeadlessEmulator } from './headless-emulator' export type ColdRestoreInfo = { snapshotAnsi: string scrollbackAnsi: string + oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string cwd: string cols: number @@ -136,6 +138,7 @@ export class HistoryReader { if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) { return null } + emulator.setRestoredOscLinks(checkpoint.oscLinks) } for (const batch of log.batches) { for (const record of batch.records) { @@ -169,6 +172,7 @@ export class HistoryReader { snapshot: { snapshotAnsi: string scrollbackAnsi: string + oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string cols: number rows: number @@ -188,6 +192,7 @@ export class HistoryReader { return { snapshotAnsi: snapshot.snapshotAnsi, scrollbackAnsi, + oscLinks: snapshot.oscLinks, rehydrateSequences: snapshot.rehydrateSequences, cwd: cwd ?? meta.cwd, cols: snapshot.cols, diff --git a/src/main/daemon/terminal-history-incremental-restore.test.ts b/src/main/daemon/terminal-history-incremental-restore.test.ts index ab7d969a0ac..a2b94fb64b6 100644 --- a/src/main/daemon/terminal-history-incremental-restore.test.ts +++ b/src/main/daemon/terminal-history-incremental-restore.test.ts @@ -72,6 +72,23 @@ describe('incremental terminal history restore', () => { expect(restore!.scrollbackAnsi).toContain('from tail after checkpoint') }) + it('preserves checkpoint OSC link ranges while replaying the log tail', async () => { + await manager.checkpoint( + SESSION_ID, + snapshotOf(['\x1b]8;;https://example.com/issue/1234\x07#1234\x1b]8;;\x07\r\n']) + ) + await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'tail\r\n' }]) + + const restore = reader.detectColdRestore(SESSION_ID) + expect(restore).not.toBeNull() + expect(restore!.oscLinks).toContainEqual({ + row: 0, + startCol: 0, + endCol: 5, + uri: 'https://example.com/issue/1234' + }) + }) + it('ignores a stale log whose generation predates the checkpoint', async () => { await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'stale tail\r\n' }]) // Simulate a crash between checkpoint rename and log reset: write the diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 609b418493d..5251b7c82ce 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,3 +1,5 @@ +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' + // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' @@ -5,9 +7,9 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 16 +export const PROTOCOL_VERSION = 17 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -21,6 +23,7 @@ export type TerminalSnapshot = { /** Scrollback portion only (rows above the visible viewport). Write this * to preserve history without interfering with TUI repaints. */ scrollbackAnsi: string + oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string cwd: string | null modes: TerminalModes @@ -47,6 +50,7 @@ export type TerminalModes = { export type TerminalCheckpointFile = { snapshotAnsi: string scrollbackAnsi: string + oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string cwd: string | null cols: number diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 9c875a1be28..d978cac64ac 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -5690,7 +5690,12 @@ describe('registerPtyHandlers', () => { }) it('seeds headless terminal state with cold-restore cwd metadata', async () => { - const coldRestore = { scrollback: 'restored history\r\n', cwd: '/projects/restored' } + const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }] + const coldRestore = { + scrollback: 'restored history\r\n', + cwd: '/projects/restored', + oscLinks + } setLocalPtyProvider({ spawn: vi.fn(async () => ({ id: 'pty-cold-restore', coldRestore })), write: vi.fn(), @@ -5720,7 +5725,7 @@ describe('registerPtyHandlers', () => { 'pty-cold-restore', 'restored history\r\n', undefined, - { cwd: '/projects/restored' } + { cwd: '/projects/restored', oscLinks } ) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9fadd52cc20..c6d22cc3240 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2554,7 +2554,8 @@ export function registerPtyHandlers( result.coldRestore.scrollback.length > 0 ) { runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, { - cwd: result.coldRestore.cwd + cwd: result.coldRestore.cwd, + oscLinks: result.coldRestore.oscLinks }) } } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index ceef328528c..1a11df9e1af 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -19,6 +19,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-histo import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -92,6 +93,7 @@ export type PtySpawnResult = { coldRestore?: { scrollback: string cwd: string + oscLinks?: TerminalOscLinkRange[] } } diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 8a93c9beeae..0f6c6e3d893 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -1033,7 +1033,13 @@ export class RuntimeBrowserCommands { } async browserMouseClick( - params: { x: number; y: number; button?: string; radius?: number } & BrowserCommandTargetParams + params: { + x: number + y: number + button?: string + radius?: number + modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[] + } & BrowserCommandTargetParams ): Promise { const target = await this.resolveBrowserCommandTarget(params) return this.requireAgentBrowserBridge().mouseClick( @@ -1042,7 +1048,8 @@ export class RuntimeBrowserCommands { params.button, target.worktreeId, target.browserPageId, - clampOptionalNumber(params.radius, 0, 64) + clampOptionalNumber(params.radius, 0, 64), + params.modifiers ) } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9a8bc2d0a04..376d3462d31 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9,6 +9,7 @@ import { } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import type { AgentStatus } from '../../shared/agent-detection' +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusIpcPayload, @@ -897,6 +898,7 @@ type RuntimeHeadlessTerminal = { type HeadlessSeedMetadata = { cwd?: string | null + oscLinks?: TerminalOscLinkRange[] } type RuntimePtyController = { @@ -4144,6 +4146,7 @@ export class OrcaRuntimeService { lastTitle?: string seq?: number source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } @@ -4159,6 +4162,7 @@ export class OrcaRuntimeService { lastTitle?: string seq?: number source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -4227,6 +4231,9 @@ export class OrcaRuntimeService { if (metadata.cwd !== undefined) { state.emulator.setCwd(metadata.cwd) } + if (metadata.oscLinks !== undefined) { + state.emulator.setRestoredOscLinks(metadata.oscLinks) + } }) .catch(() => { // Seeding is best-effort; live data will continue to populate the @@ -4391,6 +4398,7 @@ export class OrcaRuntimeService { lastTitle?: string seq?: number source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { @@ -4403,6 +4411,7 @@ export class OrcaRuntimeService { rows: number cwd?: string | null lastTitle?: string + oscLinks?: TerminalOscLinkRange[] } | null = null try { // Why: read-fallback wants visible alt-screen content (e.g. an active @@ -4485,6 +4494,7 @@ export class OrcaRuntimeService { lastTitle?: string seq?: number source?: 'headless' + oscLinks?: TerminalOscLinkRange[] } | null> { const state = this.headlessTerminals.get(ptyId) if (!state) { @@ -4510,7 +4520,8 @@ export class OrcaRuntimeService { cwd: snapshot.cwd, lastTitle: snapshot.lastTitle, seq: state.outputSequence, - source: 'headless' + source: 'headless', + oscLinks: snapshot.oscLinks } : null } diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts index 36fabdf9dde..dc47a1b0820 100644 --- a/src/main/runtime/rpc/methods/browser-extras.ts +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { BrowserTarget, OptionalFiniteNumber } from '../schemas' import { @@ -21,7 +22,16 @@ import { Viewport } from './browser-schemas' -const MouseClick = MouseXY.merge(MouseButton).extend({ radius: OptionalFiniteNumber }) +const MouseModifiers = z + .unknown() + .transform((v) => (Array.isArray(v) ? v : undefined)) + .pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()])) + .optional() + +const MouseClick = MouseXY.merge(MouseButton).extend({ + radius: OptionalFiniteNumber, + modifiers: MouseModifiers +}) export const BROWSER_EXTRA_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 24bad057142..64fdd9b0e03 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -13,6 +13,7 @@ import { type TerminalStreamFrame } from '../../../../shared/terminal-stream-protocol' import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' +import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' // Why: when a mobile client subscribes the server resizes the PTY to phone // dims and serializes the buffer. Sending only the visible screen meant @@ -45,6 +46,7 @@ type SnapshotFrameOptions = { truncated?: boolean truncatedByByteBudget?: boolean source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] } type SerializedSnapshot = { @@ -53,6 +55,7 @@ type SerializedSnapshot = { rows: number seq?: number source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] scrollbackRows: number truncatedByByteBudget: boolean } | null @@ -328,6 +331,7 @@ function sendSnapshotFrames( reason: options.reason, seq: options.seq, source: options.source, + oscLinks: options.oscLinks, truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) @@ -406,6 +410,7 @@ async function sendMobileResizeRestream( reason: event.reason, seq: event.seq ?? serialized.seq, source: serialized.source, + oscLinks: serialized.oscLinks, truncated: false, truncatedByByteBudget: serialized.truncatedByByteBudget, data: serialized.data @@ -1131,6 +1136,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ displayMode, seq: serialized?.seq, source: serialized?.source, + oscLinks: serialized?.oscLinks, truncated: false, truncatedByByteBudget: serialized?.truncatedByByteBudget, data: serialized?.data ?? '' @@ -1302,6 +1308,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read), truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, + oscLinks: serialized?.oscLinks, data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') }) // Why: baseline for resize re-stream gating; the client already @@ -1461,6 +1468,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ lines: read.tail, truncated: isTerminalReadPayloadIncomplete(read), serialized: serialized?.data, + oscLinks: serialized?.oscLinks, cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, displayMode, @@ -1661,6 +1669,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ seq, truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read), truncatedByByteBudget: serialized?.truncatedByByteBudget, + oscLinks: serialized?.oscLinks, data: serialized?.data ?? '' }) console.log('[mobile-terminal-stream] snapshot', { diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 09bfd278cdc..76aa0b34bb0 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -428,13 +428,23 @@ describe('terminal subscribe buffering', () => { seq: number }) => void) | undefined - const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const restreamResolves: ((value: { + data: string + cols: number + rows: number + oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[] + }) => void)[] = [] const serializeTerminalBuffer = vi .fn() .mockResolvedValueOnce({ data: 'initial', cols: 80, rows: 24 }) .mockImplementation( () => - new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + new Promise<{ + data: string + cols: number + rows: number + oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[] + }>((resolve) => { restreamResolves.push(resolve) }) ) @@ -488,7 +498,8 @@ describe('terminal subscribe buffering', () => { resizeListener?.({ cols: 100, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 3 }) await vi.waitFor(() => expect(restreamResolves).toHaveLength(2)) - restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24 }) + const newerOscLinks = [{ row: 0, startCol: 4, endCol: 9, uri: 'https://example.com' }] + restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24, oscLinks: newerOscLinks }) await vi.waitFor(() => expect( binaryFrames.some((frame) => { @@ -509,6 +520,13 @@ describe('terminal subscribe buffering', () => { .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) expect(snapshotData).toEqual(['newer']) + const snapshotStart = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) + expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({ + kind: 'resized', + oscLinks: newerOscLinks + }) runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 0ffbb70ce14..417fa613bea 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -156,11 +156,70 @@ describe('isTerminalLinkActivation', () => { }) describe('handleOscLink', () => { - it('ignores http links without the platform modifier', () => { + it('routes http links on ordinary click', () => { setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) - handleOscLink('https://example.com', { metaKey: false, ctrlKey: false }, deps) expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(preventDefault).toHaveBeenCalled() + }) + + it('ignores non-primary OSC link clicks', () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + handleOscLink( + 'https://example.com', + { + button: 1, + metaKey: false, + ctrlKey: false, + preventDefault + }, + deps + ) + handleOscLink( + 'https://example.com', + { + button: 2, + metaKey: false, + ctrlKey: false, + preventDefault + }, + deps + ) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('does not steal macOS ctrl-click context-menu gestures for OSC links', () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + handleOscLink( + 'https://example.com', + { + button: 0, + metaKey: false, + ctrlKey: true, + preventDefault + }, + deps + ) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() }) it('routes to the system browser when openLinksInApp is off', () => { @@ -387,14 +446,10 @@ describe('handleOscLink', () => { ) }) - it('opens local file URL links in Orca when the platform modifier is pressed', async () => { + it('opens local file URL links in Orca on ordinary click', async () => { setPlatform('Windows') handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps) - // Without modifier, nothing happens - expect(openFilePathMock).not.toHaveBeenCalled() - - handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: true }, deps) // openDetectedFilePath is async (fire-and-forget), so flush the microtask queue // before asserting on positive behavior. @@ -1634,7 +1689,7 @@ describe('createFilePathLinkProvider range bounds', () => { disposable.dispose() }) - it('opens regular URLs from a direct modifier-click fallback when xterm did not handle them', async () => { + it('opens regular URLs from a direct ordinary-click fallback when xterm did not handle them', async () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false } const rows = [ @@ -1648,7 +1703,7 @@ describe('createFilePathLinkProvider range bounds', () => { mouseUp({ button: 0, - metaKey: true, + metaKey: false, ctrlKey: false, shiftKey: false, defaultPrevented: false, @@ -1669,6 +1724,34 @@ describe('createFilePathLinkProvider range bounds', () => { expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp) }) + it('does not steal macOS ctrl-click context-menu gestures in the URL fallback', async () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: false } + const rows = [makeBufferLine('Open https://github.com/stablyai/orca/pull/2914')] + const { terminal, element } = makeFallbackTerminal(rows) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseUp = getRegisteredBubbleMouseUpHandler(element) + const preventDefault = vi.fn() + + mouseUp({ + button: 0, + metaKey: false, + ctrlKey: true, + shiftKey: false, + defaultPrevented: false, + clientX: 90, + clientY: 25, + preventDefault, + stopPropagation: vi.fn() + } as unknown as MouseEvent) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + expect(terminal.clearSelection).not.toHaveBeenCalled() + + disposable.dispose() + }) + it('asks for the first-use preference from the direct URL click fallback', async () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false, openLinksInAppPreferencePrompted: false } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 6a56ab0b868..246333c7cbd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -12,8 +12,8 @@ export function getTerminalOrcaFileOpenHint(): string { return isMacPlatform() ? '⌘+click to open in Orca' : 'Ctrl+click to open in Orca' } -// Why: local .html/.htm links keep the ordinary Orca browser route, with the -// same Shift+modifier escape hatch to the system default browser as URL links. +// Why: detected local .html/.htm file paths keep the same modifier gate as +// other file-path links, with Shift+modifier as the system-browser escape hatch. export function getTerminalHtmlFileOpenHint(): string { return isMacPlatform() ? '⌘+click to open or ⇧⌘+click for default browser' @@ -22,8 +22,8 @@ export function getTerminalHtmlFileOpenHint(): string { export function getTerminalUrlOpenHint(): string { return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click for system browser' - : 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ? 'click to open or ⇧+click for system browser' + : 'click to open or Shift+click for system browser' } export function getTerminalUrlSystemBrowserHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 4c1d0582faf..9d588b7857a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -1,7 +1,6 @@ import { resolveTerminalFileLinkText } from '@/lib/terminal-links' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { LinkHandlerDeps } from './terminal-link-handlers' -import { isTerminalLinkActivation } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' import { @@ -10,7 +9,19 @@ import { } from './terminal-url-link-hit-testing' type TerminalLinkEvent = Pick & - Partial> + Partial> + +function isPrimaryOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { + if (!event) { + return false + } + if ('button' in event && event.button !== undefined && event.button !== 0) { + return false + } + // Why: macOS Ctrl-click is a context-menu gesture even when Chromium reports + // it as button 0; ordinary OSC links should not steal that secondary action. + return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) +} export function handleOscLink( rawText: string, @@ -20,13 +31,11 @@ export function handleOscLink( requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } ): void { - if (!isTerminalLinkActivation(event)) { + if (!isPrimaryOscLinkActivation(event)) { return } - - // Why: xterm renders URL links as clickable anchors. Once Orca decides to - // handle a modified click itself, we must suppress the browser's default - // anchor navigation or Electron will still launch the system browser. + // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress + // default anchor navigation so link-routing settings can choose the target. // Note: we intentionally do NOT stopPropagation here — xterm's // SelectionService listens for mouseup on ownerDocument to clear the // pending drag-select state initiated by the mousedown of the same click. diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index fc491aca662..96358d592ff 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -47,11 +47,13 @@ function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] { return links } -function isTerminalLinkActivation( - event: Pick | undefined -): boolean { - const isMac = navigator.userAgent.includes('Mac') - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) +function isPrimaryHttpLinkFallbackActivation(event: MouseEvent): boolean { + if (event.defaultPrevented || event.button !== 0) { + return false + } + // Why: URL links now open on ordinary clicks, but macOS Ctrl-click must stay + // available for context menus even when Chromium reports it as button 0. + return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) } function getTerminalScreenElement(terminal: Terminal): HTMLElement | null { @@ -91,7 +93,7 @@ export function installHttpLinkClickFallback( deps: UrlLinkClickFallbackDeps ): IDisposable { const handleMouseUp = (event: MouseEvent): void => { - if (event.defaultPrevented || event.button !== 0 || !isTerminalLinkActivation(event)) { + if (!isPrimaryHttpLinkFallbackActivation(event)) { return } @@ -101,9 +103,8 @@ export function installHttpLinkClickFallback( } // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves Cmd/Ctrl-click when the hover link was - // never established, while defaultPrevented avoids double-opening links - // that xterm already handled. + // direct mouseup fallback preserves ordinary link clicks when the hover link + // was never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index f24043f2d8c..01711ba1719 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -40,8 +40,8 @@ import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus' function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') - ? '⌘+click to open or ⇧⌘+click for system browser' - : 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ? 'click to open or ⇧+click for system browser' + : 'click to open or Shift+click for system browser' } export function createPaneDOM( diff --git a/src/shared/terminal-osc-link-ranges.ts b/src/shared/terminal-osc-link-ranges.ts new file mode 100644 index 00000000000..051940352d0 --- /dev/null +++ b/src/shared/terminal-osc-link-ranges.ts @@ -0,0 +1,21 @@ +export type TerminalOscLinkRange = { + row: number + startCol: number + endCol: number + uri: string +} + +export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] { + return ( + Array.isArray(value) && + value.every( + (entry) => + entry != null && + typeof entry === 'object' && + Number.isInteger((entry as TerminalOscLinkRange).row) && + Number.isInteger((entry as TerminalOscLinkRange).startCol) && + Number.isInteger((entry as TerminalOscLinkRange).endCol) && + typeof (entry as TerminalOscLinkRange).uri === 'string' + ) + ) +}