mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
fix: improve mobile link modifier handling (#5597)
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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({})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user