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'
+ )
+ )
+}