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 = ` - +