diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json
index 82f1f4ab5fc..f3f66700bc1 100644
--- a/config/tsconfig.tc.web.json
+++ b/config/tsconfig.tc.web.json
@@ -9,6 +9,7 @@
"../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
+ "../src/main/ipc/worktree-metadata-merge.ts",
"../src/main/wsl.ts"
],
"compilerOptions": {
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 6a66420f8c6..d2fe6201f05 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -88,6 +88,7 @@ import {
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
+import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
getDefaultTerminalAccessoryBuiltInIds,
@@ -1306,6 +1307,7 @@ export default function SessionScreen() {
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
: ''
+ const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
const ref = getTerminalRef(handle)
// Why: previously we set `initializedHandlesRef` even when the
// WebView wasn't mounted yet (ref=null). The init message went
@@ -1320,7 +1322,7 @@ export default function SessionScreen() {
})
return
}
- ref.init(cols, rows, initialData)
+ ref.init(cols, rows, initialData, false, oscLinks)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
setTerminalModes((prev) =>
@@ -1424,8 +1426,9 @@ export default function SessionScreen() {
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const serialized = typeof data.serialized === 'string' ? data.serialized : null
+ const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
if (serialized != null) {
- getTerminalRef(handle)?.init(cols, rows, serialized, true)
+ getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks)
} else {
getTerminalRef(handle)?.resize(cols, rows)
}
diff --git a/mobile/src/browser/MobileBrowserKeyRow.tsx b/mobile/src/browser/MobileBrowserKeyRow.tsx
new file mode 100644
index 00000000000..6cd43e3371a
--- /dev/null
+++ b/mobile/src/browser/MobileBrowserKeyRow.tsx
@@ -0,0 +1,64 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+const BROWSER_KEYS = ['Enter', 'Backspace', 'Tab', 'Escape'] as const
+
+type Props = {
+ disabled: boolean
+ onKeypress: (key: string) => void
+}
+
+export function MobileBrowserKeyRow({ disabled, onKeypress }: Props): React.JSX.Element {
+ return (
+
+ {BROWSER_KEYS.map((key) => (
+ [
+ styles.keyButton,
+ pressed && styles.keyButtonPressed,
+ disabled && styles.disabled
+ ]}
+ disabled={disabled}
+ onPress={() => onKeypress(key)}
+ >
+
+ {key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key}
+
+
+ ))}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ keyRow: {
+ flexDirection: 'row',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ paddingTop: spacing.xs
+ },
+ keyButton: {
+ minHeight: 30,
+ minWidth: 42,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: spacing.sm
+ },
+ keyButtonPressed: {
+ backgroundColor: colors.borderSubtle
+ },
+ keyButtonText: {
+ color: colors.textSecondary,
+ fontSize: 12,
+ fontFamily: typography.monoFamily
+ },
+ disabled: {
+ opacity: 0.35
+ },
+ disabledText: {
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx
index 7fac1f31ef2..7e34ed76c69 100644
--- a/mobile/src/browser/MobileBrowserPane.tsx
+++ b/mobile/src/browser/MobileBrowserPane.tsx
@@ -36,6 +36,11 @@ import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest
} from './browser-screencast-request'
+import {
+ MobileBrowserPointerModifiers,
+ type BrowserPointerModifier
+} from './MobileBrowserPointerModifiers'
+import { MobileBrowserKeyRow } from './MobileBrowserKeyRow'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
@@ -150,6 +155,7 @@ export function MobileBrowserPane({
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [dialog, setDialog] = useState(null)
+ const [pointerModifiers, setPointerModifiers] = useState([])
const [zoom, setZoom] = useState(DEFAULT_ZOOM)
const [layout, setLayout] = useState(null)
const [appActive, setAppActive] = useState(AppState.currentState === 'active')
@@ -652,6 +658,7 @@ export function MobileBrowserPane({
x: point.x,
y: point.y,
button,
+ modifiers: pointerModifiers,
...(button === 'left'
? {
radius: computeBrowserTouchClickRadiusCss(
@@ -665,7 +672,7 @@ export function MobileBrowserPane({
},
{ suppressError: true, timeoutMs: 5_000 }
)
- if (clickResult !== null) {
+ if (clickResult !== null || pointerModifiers.length > 0) {
return
}
try {
@@ -687,9 +694,17 @@ export function MobileBrowserPane({
// actionable failures still surface through navigation/stream errors.
}
},
- [client, pageParams, sendBrowserRequest]
+ [client, pageParams, pointerModifiers, sendBrowserRequest]
)
+ const togglePointerModifier = useCallback((modifier: BrowserPointerModifier) => {
+ setPointerModifiers((current) =>
+ current.includes(modifier)
+ ? current.filter((candidate) => candidate !== modifier)
+ : [...current, modifier]
+ )
+ }, [])
+
const sendWheel = useCallback(
(point: BrowserPoint, screenDx: number, screenDy: number) => {
const base = pageParams()
@@ -1223,24 +1238,15 @@ export function MobileBrowserPane({
{ paddingBottom: bottomInset, transform: [{ translateY: -keyboardLift }] }
]}
>
-
- {['Enter', 'Backspace', 'Tab', 'Escape'].map((key) => (
- [
- styles.keyButton,
- pressed && styles.keyButtonPressed,
- controlsDisabled && styles.disabled
- ]}
- disabled={controlsDisabled}
- onPress={() => void sendKeypress(key)}
- >
-
- {key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key}
-
-
- ))}
-
+
+ void sendKeypress(key)}
+ />
void
+}
+
+export function MobileBrowserPointerModifiers({
+ disabled,
+ selectedModifiers,
+ onToggle
+}: Props): React.JSX.Element {
+ return (
+
+ {BROWSER_POINTER_MODIFIERS.map((modifier) => {
+ const selected = selectedModifiers.includes(modifier.id)
+ return (
+ [
+ styles.keyButton,
+ selected && styles.keyButtonSelected,
+ pressed && !selected && styles.keyButtonPressed,
+ disabled && styles.disabled
+ ]}
+ disabled={disabled}
+ onPress={() => onToggle(modifier.id)}
+ accessibilityRole="button"
+ accessibilityState={{ selected, disabled }}
+ accessibilityLabel={`${modifier.label} click modifier`}
+ >
+
+ {modifier.label}
+
+
+ )
+ })}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ modifierRow: {
+ flexDirection: 'row',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ paddingTop: spacing.xs
+ },
+ keyButton: {
+ minHeight: 30,
+ minWidth: 42,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: spacing.sm
+ },
+ keyButtonPressed: {
+ backgroundColor: colors.borderSubtle
+ },
+ keyButtonSelected: {
+ backgroundColor: colors.textPrimary
+ },
+ keyButtonText: {
+ color: colors.textSecondary,
+ fontSize: 12,
+ fontFamily: typography.monoFamily
+ },
+ keyButtonTextSelected: {
+ color: colors.bgBase
+ },
+ disabled: {
+ opacity: 0.35
+ },
+ disabledText: {
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx
index 8c6084a0255..a45188d7712 100644
--- a/mobile/src/terminal/TerminalWebView.tsx
+++ b/mobile/src/terminal/TerminalWebView.tsx
@@ -4,11 +4,13 @@ import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
+import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import { XTERM_HTML } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
+type TerminalOscLinks = TerminalOscLinkRange[]
export type TerminalModes = {
bracketedPasteMode: boolean
@@ -46,7 +48,13 @@ export type TerminalSelectionEvents = {
export type TerminalWebViewHandle = {
write: (data: string) => void
- init: (cols: number, rows: number, initialData?: string, preserveScroll?: boolean) => void
+ init: (
+ cols: number,
+ rows: number,
+ initialData?: string,
+ preserveScroll?: boolean,
+ oscLinks?: TerminalOscLinks
+ ) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
@@ -276,7 +284,13 @@ export const TerminalWebView = forwardRef(function
write(data: string) {
postMessage({ type: 'write', data })
},
- init(cols: number, rows: number, initialData?: string, preserveScroll?: boolean) {
+ init(
+ cols: number,
+ rows: number,
+ initialData?: string,
+ preserveScroll?: boolean,
+ oscLinks?: TerminalOscLinks
+ ) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
// Resolve any prior in-flight ready first so awaiters from the
@@ -298,6 +312,7 @@ export const TerminalWebView = forwardRef(function
cols,
rows,
initialData,
+ oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
diff --git a/mobile/src/terminal/terminal-accessory-keys.test.ts b/mobile/src/terminal/terminal-accessory-keys.test.ts
index b46f3ee8ec4..b7a3a1015de 100644
--- a/mobile/src/terminal/terminal-accessory-keys.test.ts
+++ b/mobile/src/terminal/terminal-accessory-keys.test.ts
@@ -45,6 +45,10 @@ describe('TERMINAL_ACCESSORY_KEYS', () => {
expect(new Set(ids).size).toBe(ids.length)
})
+ it('keeps all built-in terminal keys byte-backed', () => {
+ expect(TERMINAL_ACCESSORY_KEYS.every((key) => key.bytes.length > 0)).toBe(true)
+ })
+
it('keeps repeat behavior explicit for built-in terminal keys', () => {
const repeatableIds = new Set([
'backspace',
diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts
index cac5731f3c0..49aaba134d5 100644
--- a/mobile/src/terminal/terminal-accessory-keys.ts
+++ b/mobile/src/terminal/terminal-accessory-keys.ts
@@ -294,27 +294,23 @@ function buildShortcutBytes(key: string, modifiers: TerminalShortcutModifier[]):
return buildCsiTildeShortcut(csiTilde, modifiers)
}
if (key === 'tab') {
- if (
- hasModifier(modifiers, 'shift') &&
- !hasModifier(modifiers, 'ctrl') &&
- !hasModifier(modifiers, 'alt')
- ) {
+ if (modifiers.includes('shift') && !modifiers.includes('ctrl') && !modifiers.includes('alt')) {
return `${ESC}[Z`
}
const bytes = '\t'
- return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
+ return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (key === 'escape') {
const bytes = ESC
- return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
+ return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (key === 'enter') {
const bytes = '\r'
- return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
+ return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (key === 'backspace') {
- const bytes = hasModifier(modifiers, 'ctrl') ? '\b' : '\x7f'
- return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
+ const bytes = modifiers.includes('ctrl') ? '\b' : '\x7f'
+ return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (isPrintableShortcutKey(key)) {
return buildPrintableShortcutBytes(key, modifiers)
@@ -326,16 +322,16 @@ function buildPrintableShortcutBytes(
key: string,
modifiers: TerminalShortcutModifier[]
): string | null {
- const shifted = hasModifier(modifiers, 'shift') ? applyShift(key) : key
+ const shifted = modifiers.includes('shift') ? applyShift(key) : key
let bytes = shifted
- if (hasModifier(modifiers, 'ctrl')) {
+ if (modifiers.includes('ctrl')) {
const ctrlBytes = controlBytesForPrintable(shifted)
if (ctrlBytes == null) {
return null
}
bytes = ctrlBytes
}
- return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
+ return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
function buildCsiFinalShortcut(final: string, modifiers: TerminalShortcutModifier[]): string {
@@ -350,13 +346,13 @@ function buildCsiTildeShortcut(code: number, modifiers: TerminalShortcutModifier
function csiModifierParameter(modifiers: TerminalShortcutModifier[]): number {
let parameter = 1
- if (hasModifier(modifiers, 'shift')) {
+ if (modifiers.includes('shift')) {
parameter += 1
}
- if (hasModifier(modifiers, 'alt')) {
+ if (modifiers.includes('alt')) {
parameter += 2
}
- if (hasModifier(modifiers, 'ctrl')) {
+ if (modifiers.includes('ctrl')) {
parameter += 4
}
return parameter
@@ -413,10 +409,3 @@ function displayKeyLabel(key: string): string {
}
return key.length === 1 && key >= 'a' && key <= 'z' ? key.toUpperCase() : key
}
-
-function hasModifier(
- modifiers: TerminalShortcutModifier[],
- modifier: TerminalShortcutModifier
-): boolean {
- return modifiers.includes(modifier)
-}
diff --git a/mobile/src/terminal/terminal-osc-link-ranges.ts b/mobile/src/terminal/terminal-osc-link-ranges.ts
new file mode 100644
index 00000000000..5f7f18f82d1
--- /dev/null
+++ b/mobile/src/terminal/terminal-osc-link-ranges.ts
@@ -0,0 +1,18 @@
+import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
+
+export type { TerminalOscLinkRange }
+
+export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] {
+ return (
+ Array.isArray(value) &&
+ value.every(
+ (entry) =>
+ entry != null &&
+ typeof entry === 'object' &&
+ Number.isInteger((entry as TerminalOscLinkRange).row) &&
+ Number.isInteger((entry as TerminalOscLinkRange).startCol) &&
+ Number.isInteger((entry as TerminalOscLinkRange).endCol) &&
+ typeof (entry as TerminalOscLinkRange).uri === 'string'
+ )
+ )
+}
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index 9b7f0cf13ce..9f8b886a711 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -47,7 +47,7 @@ export const XTERM_HTML = `
-
+