fix: improve mobile link modifier handling (#5597)

This commit is contained in:
Carlos Baraza
2026-06-18 16:48:43 -07:00
committed by GitHub
parent 688137c916
commit d46349ce82
39 changed files with 914 additions and 143 deletions
@@ -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)
}
@@ -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 (
<View style={styles.keyRow}>
{BROWSER_KEYS.map((key) => (
<Pressable
key={key}
style={({ pressed }) => [
styles.keyButton,
pressed && styles.keyButtonPressed,
disabled && styles.disabled
]}
disabled={disabled}
onPress={() => onKeypress(key)}
>
<Text style={[styles.keyButtonText, disabled && styles.disabledText]}>
{key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key}
</Text>
</Pressable>
))}
</View>
)
}
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
}
})
+26 -43
View File
@@ -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<string | null>(null)
const [dialog, setDialog] = useState<BrowserDialogState | null>(null)
const [pointerModifiers, setPointerModifiers] = useState<BrowserPointerModifier[]>([])
const [zoom, setZoom] = useState<BrowserZoomState>(DEFAULT_ZOOM)
const [layout, setLayout] = useState<BrowserTouchLayout | null>(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 }] }
]}
>
<View style={styles.keyRow}>
{['Enter', 'Backspace', 'Tab', 'Escape'].map((key) => (
<Pressable
key={key}
style={({ pressed }) => [
styles.keyButton,
pressed && styles.keyButtonPressed,
controlsDisabled && styles.disabled
]}
disabled={controlsDisabled}
onPress={() => void sendKeypress(key)}
>
<Text style={[styles.keyButtonText, controlsDisabled && styles.disabledText]}>
{key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key}
</Text>
</Pressable>
))}
</View>
<MobileBrowserPointerModifiers
disabled={controlsDisabled}
selectedModifiers={pointerModifiers}
onToggle={togglePointerModifier}
/>
<MobileBrowserKeyRow
disabled={controlsDisabled}
onKeypress={(key) => void sendKeypress(key)}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.keyboardInput}
@@ -1628,29 +1634,6 @@ const styles = StyleSheet.create({
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
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
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -0,0 +1,95 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export type BrowserPointerModifier = 'cmd' | 'ctrl' | 'alt' | 'shift'
const BROWSER_POINTER_MODIFIERS: { id: BrowserPointerModifier; label: string }[] = [
{ id: 'cmd', label: 'Cmd' },
{ id: 'ctrl', label: 'Ctrl' },
{ id: 'alt', label: 'Alt' },
{ id: 'shift', label: 'Shift' }
]
type Props = {
disabled: boolean
selectedModifiers: BrowserPointerModifier[]
onToggle: (modifier: BrowserPointerModifier) => void
}
export function MobileBrowserPointerModifiers({
disabled,
selectedModifiers,
onToggle
}: Props): React.JSX.Element {
return (
<View style={styles.modifierRow}>
{BROWSER_POINTER_MODIFIERS.map((modifier) => {
const selected = selectedModifiers.includes(modifier.id)
return (
<Pressable
key={modifier.id}
style={({ pressed }) => [
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`}
>
<Text
style={[
styles.keyButtonText,
selected && styles.keyButtonTextSelected,
disabled && styles.disabledText
]}
>
{modifier.label}
</Text>
</Pressable>
)
})}
</View>
)
}
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
}
})
+17 -2
View File
@@ -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<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(function
cols,
rows,
initialData,
oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
@@ -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',
+12 -23
View File
@@ -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)
}
@@ -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'
)
)
}
+14 -2
View File
@@ -274,6 +274,8 @@ export const XTERM_HTML = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE html>
function logFeedAndEvict() {
linesEverWritten++;
if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1;
if (selMode === 'select' && sel && isBufferFull()) {
sel.anchor.row -= 1;
sel.focus.row -= 1;
@@ -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
@@ -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<Record<string, unknown>>
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()
@@ -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);
@@ -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({})
+43 -6
View File
@@ -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<string, unknown>) : 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<BrowserClickPoint> {
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<unknown> {
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
})
}
+45 -2
View File
@@ -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<string, { scrollback: string; cwd: string }>
coldRestoreCache: Map<string, { scrollback: string; cwd: string; oscLinks?: unknown[] }>
}
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<string, { scrollback: string; cwd: string }>
coldRestoreCache: Map<string, { scrollback: string; cwd: string; oscLinks?: unknown[] }>
}
await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
+9 -2
View File
@@ -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<string, { scrollback: string; cwd: string }>()
private coldRestoreCache = new Map<string, ColdRestorePayload>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
// 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 }
}
+46
View File
@@ -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', () => {
+15 -5
View File
@@ -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()
}
@@ -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<string>()
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
}
+10
View File
@@ -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())
})
+1
View File
@@ -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,
+8
View File
@@ -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,
+5
View File
@@ -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,
@@ -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
+6 -2
View File
@@ -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
+7 -2
View File
@@ -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 }
)
})
+2 -1
View File
@@ -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
})
}
}
+2
View File
@@ -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[]
}
}
+9 -2
View File
@@ -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<unknown> {
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
)
}
+12 -1
View File
@@ -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
}
+11 -1
View File
@@ -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({
+9
View File
@@ -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', {
@@ -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
@@ -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 }
@@ -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 {
@@ -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<MouseEvent, 'metaKey' | 'ctrlKey'> &
Partial<Pick<MouseEvent, 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
Partial<Pick<MouseEvent, 'button' | 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
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.
@@ -47,11 +47,13 @@ function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] {
return links
}
function isTerminalLinkActivation(
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey'> | 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,
@@ -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(
+21
View File
@@ -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'
)
)
}