diff --git a/index.html b/index.html index c476f13e..37d2217d 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + CloudCLI UI diff --git a/src/components/shell/constants/constants.ts b/src/components/shell/constants/constants.ts index 49dffd50..6880b68c 100644 --- a/src/components/shell/constants/constants.ts +++ b/src/components/shell/constants/constants.ts @@ -1,6 +1,5 @@ import type { ITerminalOptions } from '@xterm/xterm'; -export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device'; export const SHELL_RESTART_DELAY_MS = 200; export const TERMINAL_INIT_DELAY_MS = 100; export const TERMINAL_RESIZE_DELAY_MS = 50; diff --git a/src/components/shell/hooks/useShellConnection.ts b/src/components/shell/hooks/useShellConnection.ts index 918ed76c..f88372c0 100644 --- a/src/components/shell/hooks/useShellConnection.ts +++ b/src/components/shell/hooks/useShellConnection.ts @@ -24,7 +24,6 @@ type UseShellConnectionOptions = { autoConnect: boolean; closeSocket: () => void; clearTerminalScreen: () => void; - setAuthUrl: (nextAuthUrl: string) => void; onOutputRef?: MutableRefObject<(() => void) | null>; }; @@ -49,7 +48,6 @@ export function useShellConnection({ autoConnect, closeSocket, clearTerminalScreen, - setAuthUrl, onOutputRef, }: UseShellConnectionOptions): UseShellConnectionResult { const [isConnected, setIsConnected] = useState(false); @@ -100,14 +98,8 @@ export function useShellConnection({ return; } - if (message.type === 'auth_url' || message.type === 'url_open') { - const nextAuthUrl = typeof message.url === 'string' ? message.url : ''; - if (nextAuthUrl) { - setAuthUrl(nextAuthUrl); - } - } }, - [handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef], + [handleProcessCompletion, onOutputRef, terminalRef], ); const connectWebSocket = useCallback( @@ -133,7 +125,6 @@ export function useShellConnection({ setIsConnected(true); setIsConnecting(false); connectingRef.current = false; - setAuthUrl(''); window.setTimeout(() => { const currentTerminal = terminalRef.current; @@ -196,7 +187,6 @@ export function useShellConnection({ isPlainShellRef, selectedProjectRef, selectedSessionRef, - setAuthUrl, terminalRef, wsRef, ], @@ -225,8 +215,7 @@ export function useShellConnection({ setIsConnecting(false); connectingRef.current = false; forceRestartOnInitRef.current = false; - setAuthUrl(''); - }, [clearTerminalScreen, closeSocket, setAuthUrl]); + }, [clearTerminalScreen, closeSocket]); useEffect(() => { if ( diff --git a/src/components/shell/hooks/useShellRuntime.ts b/src/components/shell/hooks/useShellRuntime.ts index 2176d109..21054dca 100644 --- a/src/components/shell/hooks/useShellRuntime.ts +++ b/src/components/shell/hooks/useShellRuntime.ts @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import type { FitAddon } from '@xterm/addon-fit'; import type { Terminal } from '@xterm/xterm'; + import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types'; -import { copyTextToClipboard } from '../../../utils/clipboard'; + import { useShellConnection } from './useShellConnection'; import { useShellTerminal } from './useShellTerminal'; @@ -22,15 +23,11 @@ export function useShellRuntime({ const fitAddonRef = useRef(null); const wsRef = useRef(null); - const [authUrl, setAuthUrl] = useState(''); - const [authUrlVersion, setAuthUrlVersion] = useState(0); - const selectedProjectRef = useRef(selectedProject); const selectedSessionRef = useRef(selectedSession); const initialCommandRef = useRef(initialCommand); const isPlainShellRef = useRef(isPlainShell); const onProcessCompleteRef = useRef(onProcessComplete); - const authUrlRef = useRef(''); const lastSessionIdRef = useRef(selectedSession?.id ?? null); // Keep mutable values in refs so websocket handlers always read current data. @@ -42,12 +39,6 @@ export function useShellRuntime({ onProcessCompleteRef.current = onProcessComplete; }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]); - const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => { - authUrlRef.current = nextAuthUrl; - setAuthUrl(nextAuthUrl); - setAuthUrlVersion((previous) => previous + 1); - }, []); - const closeSocket = useCallback(() => { const activeSocket = wsRef.current; if (!activeSocket) { @@ -64,32 +55,6 @@ export function useShellRuntime({ wsRef.current = null; }, []); - const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => { - if (!url) { - return false; - } - - const popup = window.open(url, '_blank'); - if (popup) { - try { - popup.opener = null; - } catch { - // Ignore cross-origin restrictions when trying to null opener. - } - return true; - } - - return false; - }, []); - - const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => { - if (!url) { - return false; - } - - return copyTextToClipboard(url); - }, []); - const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({ terminalContainerRef, terminalRef, @@ -98,10 +63,6 @@ export function useShellRuntime({ selectedProject, minimal, isRestarting, - initialCommandRef, - isPlainShellRef, - authUrlRef, - copyAuthUrlToClipboard, closeSocket, }); @@ -118,7 +79,6 @@ export function useShellRuntime({ autoConnect, closeSocket, clearTerminalScreen, - setAuthUrl: setCurrentAuthUrl, onOutputRef, }); @@ -156,11 +116,7 @@ export function useShellRuntime({ isConnected, isInitialized, isConnecting, - authUrl, - authUrlVersion, connectToShell, disconnectFromShell, - openAuthUrlInBrowser, - copyAuthUrlToClipboard, }; } diff --git a/src/components/shell/hooks/useShellTerminal.ts b/src/components/shell/hooks/useShellTerminal.ts index 011e7ee0..076c5b76 100644 --- a/src/components/shell/hooks/useShellTerminal.ts +++ b/src/components/shell/hooks/useShellTerminal.ts @@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; import { Terminal } from '@xterm/xterm'; + import type { Project } from '../../../types/app'; +import { copyTextToClipboard } from '../../../utils/clipboard'; import { - CODEX_DEVICE_AUTH_URL, TERMINAL_INIT_DELAY_MS, TERMINAL_OPTIONS, TERMINAL_RESIZE_DELAY_MS, } from '../constants/constants'; -import { copyTextToClipboard } from '../../../utils/clipboard'; -import { isCodexLoginCommand } from '../utils/auth'; +import { + installMobileTerminalSelection, + type MobileTerminalSelectionManager, +} from '../utils/mobileTerminalSelection'; import { sendSocketMessage } from '../utils/socket'; import { ensureXtermFocusStyles } from '../utils/terminalStyles'; @@ -24,10 +27,6 @@ type UseShellTerminalOptions = { selectedProject: Project | null | undefined; minimal: boolean; isRestarting: boolean; - initialCommandRef: MutableRefObject; - isPlainShellRef: MutableRefObject; - authUrlRef: MutableRefObject; - copyAuthUrlToClipboard: (url?: string) => Promise; closeSocket: () => void; }; @@ -45,14 +44,11 @@ export function useShellTerminal({ selectedProject, minimal, isRestarting, - initialCommandRef, - isPlainShellRef, - authUrlRef, - copyAuthUrlToClipboard, closeSocket, }: UseShellTerminalOptions): UseShellTerminalResult { const [isInitialized, setIsInitialized] = useState(false); const resizeTimeoutRef = useRef(null); + const mobileSelectionRef = useRef(null); const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || ''; const hasSelectedProject = Boolean(selectedProject); @@ -70,6 +66,11 @@ export function useShellTerminal({ }, [terminalRef]); const disposeTerminal = useCallback(() => { + if (mobileSelectionRef.current) { + mobileSelectionRef.current.dispose(); + mobileSelectionRef.current = null; + } + if (terminalRef.current) { terminalRef.current.dispose(); terminalRef.current = null; @@ -80,7 +81,8 @@ export function useShellTerminal({ }, [fitAddonRef, terminalRef]); useEffect(() => { - if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) { + const terminalContainer = terminalContainerRef.current; + if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) { return; } @@ -102,7 +104,28 @@ export function useShellTerminal({ console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); } - nextTerminal.open(terminalContainerRef.current); + nextTerminal.open(terminalContainer); + mobileSelectionRef.current = installMobileTerminalSelection( + nextTerminal, + terminalContainer, + { + onFontSizeChange: (fontSize) => { + nextTerminal.options.fontSize = fontSize; + + const currentFitAddon = fitAddonRef.current; + if (currentFitAddon) { + currentFitAddon.fit(); + sendSocketMessage(wsRef.current, { + type: 'resize', + cols: nextTerminal.cols, + rows: nextTerminal.rows, + }); + } else { + nextTerminal.refresh(0, nextTerminal.rows - 1); + } + }, + }, + ); const copyTerminalSelection = async () => { const selection = nextTerminal.getSelection(); @@ -133,29 +156,9 @@ export function useShellTerminal({ void copyTextToClipboard(selection); }; - terminalContainerRef.current.addEventListener('copy', handleTerminalCopy); + terminalContainer.addEventListener('copy', handleTerminalCopy); nextTerminal.attachCustomKeyEventHandler((event) => { - const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current) - ? CODEX_DEVICE_AUTH_URL - : authUrlRef.current; - - if ( - event.type === 'keydown' && - minimal && - isPlainShellRef.current && - activeAuthUrl && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - event.key?.toLowerCase() === 'c' - ) { - event.preventDefault(); - event.stopPropagation(); - void copyAuthUrlToClipboard(activeAuthUrl); - return false; - } - if ( event.type === 'keydown' && (event.ctrlKey || event.metaKey) && @@ -240,10 +243,10 @@ export function useShellTerminal({ }, TERMINAL_RESIZE_DELAY_MS); }); - resizeObserver.observe(terminalContainerRef.current); + resizeObserver.observe(terminalContainer); return () => { - terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy); + terminalContainer.removeEventListener('copy', handleTerminalCopy); resizeObserver.disconnect(); if (resizeTimeoutRef.current !== null) { window.clearTimeout(resizeTimeoutRef.current); @@ -254,16 +257,12 @@ export function useShellTerminal({ disposeTerminal(); }; }, [ - authUrlRef, closeSocket, - copyAuthUrlToClipboard, disposeTerminal, fitAddonRef, - initialCommandRef, - isPlainShellRef, isRestarting, - minimal, hasSelectedProject, + minimal, selectedProjectKey, terminalContainerRef, terminalRef, diff --git a/src/components/shell/types/types.ts b/src/components/shell/types/types.ts index 72a19785..a164e9cd 100644 --- a/src/components/shell/types/types.ts +++ b/src/components/shell/types/types.ts @@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm'; import type { Project, ProjectSession } from '../../../types/app'; -export type AuthCopyStatus = 'idle' | 'copied' | 'failed'; - export type ShellInitMessage = { type: 'init'; projectPath: string; @@ -54,7 +52,6 @@ export type ShellSharedRefs = { wsRef: MutableRefObject; terminalRef: MutableRefObject; fitAddonRef: MutableRefObject; - authUrlRef: MutableRefObject; selectedProjectRef: MutableRefObject; selectedSessionRef: MutableRefObject; initialCommandRef: MutableRefObject; @@ -69,10 +66,6 @@ export type UseShellRuntimeResult = { isConnected: boolean; isInitialized: boolean; isConnecting: boolean; - authUrl: string; - authUrlVersion: number; connectToShell: (options?: { forceRestart?: boolean }) => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; - openAuthUrlInBrowser: (url?: string) => boolean; - copyAuthUrlToClipboard: (url?: string) => Promise; }; diff --git a/src/components/shell/utils/auth.ts b/src/components/shell/utils/auth.ts index d55041b1..fde9d4bc 100644 --- a/src/components/shell/utils/auth.ts +++ b/src/components/shell/utils/auth.ts @@ -1,17 +1,4 @@ import type { ProjectSession } from '../../../types/app'; -import { CODEX_DEVICE_AUTH_URL } from '../constants/constants'; - -export function isCodexLoginCommand(command: string | null | undefined): boolean { - return typeof command === 'string' && /\bcodex\s+login\b/i.test(command); -} - -export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string { - if (isCodexLoginCommand(command)) { - return CODEX_DEVICE_AUTH_URL; - } - - return authUrl; -} export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null { if (!session) { @@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined return session.__provider === 'cursor' ? session.name || 'Untitled Session' : session.summary || 'New Session'; -} \ No newline at end of file +} diff --git a/src/components/shell/utils/mobileTerminalSelection.ts b/src/components/shell/utils/mobileTerminalSelection.ts new file mode 100644 index 00000000..ef20e066 --- /dev/null +++ b/src/components/shell/utils/mobileTerminalSelection.ts @@ -0,0 +1,1068 @@ +import type { IDisposable, Terminal } from '@xterm/xterm'; + +import { copyTextToClipboard } from '../../../utils/clipboard'; + +type TerminalCoords = { + col: number; + row: number; +}; + +type TouchCoords = { + clientX: number; + clientY: number; +}; + +type CellDimensions = { + width: number; + height: number; +}; + +type DragHandle = 'start' | 'end'; + +type TerminalWithRenderService = Terminal & { + _core?: { + _renderService?: { + dimensions?: { + css?: { + cell?: { + width?: number; + height?: number; + }; + }; + }; + }; + }; +}; + +export type MobileTerminalSelectionManager = { + dispose: () => void; + updateHandles: () => void; +}; + +const LONG_PRESS_MS = 600; +const MOVE_THRESHOLD_PX = 8; +const HANDLE_SIZE_PX = 22; +const FINGER_OFFSET_PX = 40; +const CONTEXT_MENU_GAP_PX = 12; +const CONTEXT_MENU_EDGE_PADDING_PX = 8; +const ZOOM_THROTTLE_MS = 50; +const DEFAULT_MIN_FONT_SIZE = 8; +const DEFAULT_MAX_FONT_SIZE = 48; +// xterm scrolls the viewport 1:1 with the finger and never coasts, so we add +// our own inertial (fling) scrolling: track finger velocity during the drag and +// keep scrolling with friction after release. +const SCROLL_INERTIA_FRICTION = 0.95; // velocity multiplier per ~16ms frame +const SCROLL_INERTIA_MIN_VELOCITY = 0.02; // px/ms below which coasting stops +const SCROLL_INERTIA_MAX_VELOCITY = 5; // px/ms cap to avoid runaway flings +const SCROLL_INERTIA_MAX_IDLE_MS = 90; // ignore a flick if the finger paused before lifting + +type ContextMenuItem = { + label: string; + action: () => void; +}; + +export type MobileTerminalSelectionOptions = { + minFontSize?: number; + maxFontSize?: number; + onFontSizeChange?: (fontSize: number) => void; +}; + +function isTouchSelectionEnvironment(): boolean { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + + return ( + navigator.maxTouchPoints > 0 || + 'ontouchstart' in window || + window.matchMedia?.('(pointer: coarse)').matches === true + ); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function getDistance(start: TouchCoords, end: TouchCoords): number { + return Math.hypot(end.clientX - start.clientX, end.clientY - start.clientY); +} + +class ShellMobileSelectionCore implements MobileTerminalSelectionManager { + private readonly terminal: Terminal; + private readonly terminalContent: HTMLElement; + private readonly overlay: HTMLDivElement; + private readonly startHandle: HTMLDivElement; + private readonly endHandle: HTMLDivElement; + private readonly contextMenu: HTMLDivElement; + private readonly disposables: IDisposable[] = []; + private readonly originalPosition: string; + + private didSetPosition = false; + private isDestroyed = false; + private isSelecting = false; + private isHandleDragging = false; + private dragHandle: DragHandle | null = null; + private selectionStart: TerminalCoords | null = null; + private selectionEnd: TerminalCoords | null = null; + private touchStart: TouchCoords | null = null; + private pendingClearTouch: { point: TouchCoords; moved: boolean } | null = null; + private tapHoldTimeout: number | null = null; + private cellDimensions: CellDimensions = { width: 0, height: 0 }; + private isContextMenuVisible = false; + + private readonly minFontSize: number; + private readonly maxFontSize: number; + private readonly onFontSizeChange: (fontSize: number) => void; + private isPinching = false; + private pinchStartDistance = 0; + private initialFontSize = 0; + private lastZoomTime = 0; + + private viewportElement: HTMLElement | null = null; + private lastScrollTouchY: number | null = null; + private lastScrollTouchTime = 0; + private scrollVelocity = 0; + private inertiaFrame: number | null = null; + + constructor( + terminal: Terminal, + terminalContent: HTMLElement, + options: MobileTerminalSelectionOptions = {}, + ) { + this.terminal = terminal; + this.terminalContent = terminalContent; + this.originalPosition = terminalContent.style.position; + + const minFontSize = Number(options.minFontSize) || DEFAULT_MIN_FONT_SIZE; + const maxFontSize = Number(options.maxFontSize) || DEFAULT_MAX_FONT_SIZE; + this.minFontSize = Math.min(minFontSize, maxFontSize); + this.maxFontSize = Math.max(minFontSize, maxFontSize); + this.onFontSizeChange = + options.onFontSizeChange ?? + ((fontSize) => { + this.terminal.options.fontSize = fontSize; + this.terminal.refresh(0, this.terminal.rows - 1); + }); + + if (window.getComputedStyle(terminalContent).position === 'static') { + terminalContent.style.position = 'relative'; + this.didSetPosition = true; + } + + this.overlay = this.createSelectionOverlay(); + this.startHandle = this.createHandle('start'); + this.endHandle = this.createHandle('end'); + this.contextMenu = this.createContextMenu(); + this.overlay.append(this.startHandle, this.endHandle, this.contextMenu); + this.terminalContent.appendChild(this.overlay); + + this.attachEventListeners(); + this.updateCellDimensions(); + } + + private createSelectionOverlay(): HTMLDivElement { + const overlay = document.createElement('div'); + overlay.className = 'shell-mobile-selection-overlay'; + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.overflow = 'hidden'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '30'; + return overlay; + } + + private createHandle(type: DragHandle): HTMLDivElement { + const handle = document.createElement('div'); + handle.className = `shell-mobile-selection-handle shell-mobile-selection-handle-${type}`; + handle.dataset.handleType = type; + handle.style.position = 'absolute'; + handle.style.width = `${HANDLE_SIZE_PX}px`; + handle.style.height = `${HANDLE_SIZE_PX}px`; + handle.style.borderRadius = '50%'; + handle.style.background = '#3b82f6'; + handle.style.border = '2px solid #fff'; + handle.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)'; + handle.style.display = 'none'; + handle.style.pointerEvents = 'auto'; + handle.style.touchAction = 'none'; + handle.style.zIndex = '31'; + return handle; + } + + private createContextMenu(): HTMLDivElement { + const menu = document.createElement('div'); + menu.className = 'shell-mobile-selection-menu'; + menu.style.position = 'absolute'; + menu.style.display = 'none'; + menu.style.alignItems = 'stretch'; + menu.style.padding = '4px'; + menu.style.gap = '2px'; + menu.style.background = '#1f2937'; + menu.style.border = '1px solid rgba(255,255,255,0.12)'; + menu.style.borderRadius = '10px'; + menu.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)'; + menu.style.pointerEvents = 'auto'; + menu.style.touchAction = 'none'; + menu.style.zIndex = '32'; + menu.style.whiteSpace = 'nowrap'; + menu.style.userSelect = 'none'; + + const items: ContextMenuItem[] = [ + { label: 'Copy', action: () => this.copySelection() }, + { label: 'Select All', action: () => this.selectAllText() }, + ]; + + for (const item of items) { + menu.appendChild(this.createContextMenuButton(item)); + } + + return menu; + } + + private createContextMenuButton(item: ContextMenuItem): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = item.label; + button.style.appearance = 'none'; + button.style.border = 'none'; + button.style.margin = '0'; + button.style.padding = '8px 14px'; + button.style.background = 'transparent'; + button.style.color = '#f9fafb'; + button.style.fontSize = '14px'; + button.style.fontFamily = 'inherit'; + button.style.lineHeight = '1'; + button.style.borderRadius = '6px'; + button.style.cursor = 'pointer'; + button.style.pointerEvents = 'auto'; + button.style.touchAction = 'none'; + + let actionExecuted = false; + const arm = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + actionExecuted = false; + }; + const run = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + if (actionExecuted) { + return; + } + actionExecuted = true; + item.action(); + }; + + button.addEventListener('touchstart', arm, { passive: false }); + button.addEventListener('touchend', run, { passive: false }); + button.addEventListener('mousedown', arm); + button.addEventListener('mouseup', run); + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + + return button; + } + + private attachEventListeners(): void { + if (!this.terminal.element) { + return; + } + + this.terminal.element.addEventListener('touchstart', this.onTerminalTouchStart, { + passive: false, + }); + this.terminal.element.addEventListener('touchmove', this.onTerminalTouchMove, { + passive: false, + }); + this.terminal.element.addEventListener('touchend', this.onTerminalTouchEnd, { + passive: false, + }); + this.terminal.element.addEventListener('touchcancel', this.onTerminalTouchCancel, { + passive: false, + }); + + this.startHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false }); + this.startHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false }); + this.startHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false }); + this.startHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false }); + + this.endHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false }); + this.endHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false }); + this.endHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false }); + this.endHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false }); + + document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true }); + + this.disposables.push( + this.terminal.onSelectionChange(this.onSelectionChange), + this.terminal.onResize(this.onTerminalResize), + this.terminal.onScroll(this.onTerminalScroll), + ); + } + + private onTerminalTouchStart = (event: TouchEvent): void => { + this.cancelInertia(); + this.resetScrollTracking(); + + if (event.touches.length === 2) { + event.preventDefault(); + this.startPinchZoom(event); + return; + } + + if (event.touches.length !== 1) { + this.clearTapHoldTimeout(); + return; + } + + const touch = this.toTouchCoords(event.touches[0]); + this.touchStart = touch; + + if (this.isSelecting) { + this.pendingClearTouch = { point: touch, moved: false }; + return; + } + + this.clearTapHoldTimeout(); + this.tapHoldTimeout = window.setTimeout(() => { + this.tapHoldTimeout = null; + this.startSelection(touch); + }, LONG_PRESS_MS); + }; + + private onTerminalTouchMove = (event: TouchEvent): void => { + if (event.touches.length === 2 && this.isPinching) { + event.preventDefault(); + this.handlePinchZoom(event); + return; + } + + if (event.touches.length !== 1) { + this.clearTapHoldTimeout(); + return; + } + + if (this.isPinching) { + return; + } + + const touch = this.toTouchCoords(event.touches[0]); + const touchStart = this.touchStart; + + if (this.pendingClearTouch) { + this.pendingClearTouch.moved = + this.pendingClearTouch.moved || + getDistance(this.pendingClearTouch.point, touch) > MOVE_THRESHOLD_PX; + return; + } + + if (!touchStart) { + return; + } + + const moved = getDistance(touchStart, touch) > MOVE_THRESHOLD_PX; + if (moved) { + this.clearTapHoldTimeout(); + } + + if (this.isSelecting && !this.isHandleDragging) { + event.preventDefault(); + this.extendSelection(touch); + return; + } + + // Plain one-finger scrolling: xterm moves the viewport itself; we only + // record the finger velocity so we can add inertia when the touch ends. + this.recordScrollSample(touch); + }; + + private onTerminalTouchEnd = (event: TouchEvent): void => { + if (this.isPinching) { + this.endPinchZoom(); + return; + } + + this.clearTapHoldTimeout(); + this.touchStart = null; + + // A long-press selection (or a tap dismissing one) must not let the browser + // synthesize the mouse click that refocuses xterm's hidden textarea — that + // is what pops up the mobile keyboard. A plain tap leaves isSelecting false + // and falls through, so it still focuses the terminal and shows the keyboard. + if (this.isSelecting || this.isHandleDragging) { + event.preventDefault(); + this.blurTerminalInput(); + } + + if (!this.pendingClearTouch) { + this.maybeStartInertia(); + return; + } + + const shouldClear = this.isSelecting && !this.pendingClearTouch.moved && !this.isHandleDragging; + this.pendingClearTouch = null; + + if (shouldClear) { + this.clearSelection(); + } + }; + + private onTerminalTouchCancel = (): void => { + if (this.isPinching) { + this.endPinchZoom(); + } + + this.clearTapHoldTimeout(); + this.touchStart = null; + this.pendingClearTouch = null; + }; + + private onHandleTouchStart = (event: TouchEvent): void => { + event.preventDefault(); + event.stopPropagation(); + + if (event.touches.length !== 1) { + return; + } + + const target = event.currentTarget as HTMLElement; + this.dragHandle = target.dataset.handleType === 'start' ? 'start' : 'end'; + this.isHandleDragging = true; + this.pendingClearTouch = null; + }; + + private onHandleTouchMove = (event: TouchEvent): void => { + if (!this.isHandleDragging || !this.dragHandle || event.touches.length !== 1) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const touch = this.toTouchCoords(event.touches[0]); + const adjustedTouch = { + clientX: touch.clientX, + clientY: touch.clientY - FINGER_OFFSET_PX, + }; + const coords = this.touchToTerminalCoords(adjustedTouch); + if (!coords) { + return; + } + + if (this.dragHandle === 'start') { + this.selectionStart = coords; + } else { + this.selectionEnd = coords; + } + + this.swapHandlesIfNeeded(); + this.updateSelection(); + }; + + private onHandleTouchEnd = (event: TouchEvent): void => { + if (!this.isHandleDragging) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.isHandleDragging = false; + this.dragHandle = null; + }; + + private onSelectionChange = (): void => { + if (!this.isSelecting) { + return; + } + + if (!this.terminal.hasSelection()) { + this.resetSelectionState(); + return; + } + + this.updateHandles(); + }; + + private onTerminalResize = (): void => { + this.updateCellDimensions(); + this.updateHandles(); + }; + + private onTerminalScroll = (): void => { + this.updateHandles(); + }; + + private onDocumentTouchStart = (event: TouchEvent): void => { + if (!this.isSelecting || !event.target) { + return; + } + + if (this.terminalContent.contains(event.target as Node)) { + return; + } + + this.clearSelection(); + }; + + private startSelection(touch: TouchCoords): void { + const coords = this.touchToTerminalCoords(touch); + if (!coords) { + return; + } + + const wordBounds = this.getWordBoundsAt(coords); + this.selectionStart = wordBounds?.start ?? coords; + this.selectionEnd = wordBounds?.end ?? coords; + this.isSelecting = true; + + // Dismiss the mobile keyboard if it was open: selecting text is not typing. + this.blurTerminalInput(); + + this.updateSelection(); + this.showHandles(); + this.showContextMenu(); + } + + private extendSelection(touch: TouchCoords): void { + const coords = this.touchToTerminalCoords(touch); + if (!coords) { + return; + } + + this.selectionEnd = coords; + this.updateSelection(); + } + + private updateSelection(): void { + if (!this.selectionStart || !this.selectionEnd) { + return; + } + + const { start, end } = this.getOrderedSelection(); + const length = this.calculateSelectionLength(start, end); + if (length <= 0) { + return; + } + + this.terminal.select(start.col, start.row, length); + this.updateHandles(); + } + + private calculateSelectionLength(start: TerminalCoords, end: TerminalCoords): number { + if (start.row === end.row) { + return end.col - start.col + 1; + } + + return (end.row - start.row) * this.terminal.cols - start.col + end.col + 1; + } + + private getOrderedSelection(): { start: TerminalCoords; end: TerminalCoords } { + const start = this.selectionStart; + const end = this.selectionEnd; + if (!start || !end) { + throw new Error('Cannot order empty terminal selection'); + } + + if (start.row < end.row || (start.row === end.row && start.col <= end.col)) { + return { start, end }; + } + + return { start: end, end: start }; + } + + private swapHandlesIfNeeded(): void { + if (!this.selectionStart || !this.selectionEnd || !this.dragHandle) { + return; + } + + const { start, end } = this.getOrderedSelection(); + if (start === this.selectionStart && end === this.selectionEnd) { + return; + } + + this.selectionStart = start; + this.selectionEnd = end; + this.dragHandle = this.dragHandle === 'start' ? 'end' : 'start'; + } + + private showHandles(): void { + this.startHandle.style.display = 'block'; + this.endHandle.style.display = 'block'; + this.updateHandles(); + } + + private hideHandles(): void { + this.startHandle.style.display = 'none'; + this.endHandle.style.display = 'none'; + } + + private showContextMenu(): void { + this.contextMenu.style.display = 'flex'; + this.isContextMenuVisible = true; + this.positionContextMenu(); + } + + private hideContextMenu(): void { + this.contextMenu.style.display = 'none'; + this.isContextMenuVisible = false; + } + + private positionContextMenu(): void { + if (!this.isContextMenuVisible) { + return; + } + + const containerRect = this.terminalContent.getBoundingClientRect(); + const menuWidth = this.contextMenu.offsetWidth || 0; + const menuHeight = this.contextMenu.offsetHeight || 0; + + const ordered = + this.selectionStart && this.selectionEnd ? this.getOrderedSelection() : null; + const startPosition = ordered ? this.terminalCoordsToPixels(ordered.start) : null; + const endPosition = ordered ? this.terminalCoordsToPixels(ordered.end) : null; + + let menuX: number; + let menuY: number; + + if (startPosition || endPosition) { + const topY = Math.min( + startPosition?.y ?? endPosition!.y, + endPosition?.y ?? startPosition!.y, + ); + const centerX = + startPosition && endPosition + ? (startPosition.x + endPosition.x) / 2 + : (startPosition ?? endPosition)!.x; + + menuX = centerX - menuWidth / 2; + menuY = topY - menuHeight - CONTEXT_MENU_GAP_PX; + + // Not enough room above the selection: drop below the handles instead. + if (menuY < CONTEXT_MENU_EDGE_PADDING_PX) { + const bottomY = Math.max( + startPosition?.y ?? endPosition!.y, + endPosition?.y ?? startPosition!.y, + ); + menuY = bottomY + this.cellDimensions.height + HANDLE_SIZE_PX + CONTEXT_MENU_GAP_PX; + } + } else { + // Whole-buffer selection (Select All): pin to the bottom center. + menuX = (containerRect.width - menuWidth) / 2; + menuY = containerRect.height - menuHeight - CONTEXT_MENU_GAP_PX; + } + + const maxX = containerRect.width - menuWidth - CONTEXT_MENU_EDGE_PADDING_PX; + const maxY = containerRect.height - menuHeight - CONTEXT_MENU_EDGE_PADDING_PX; + menuX = clamp(menuX, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxX)); + menuY = clamp(menuY, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxY)); + + this.contextMenu.style.left = `${menuX}px`; + this.contextMenu.style.top = `${menuY}px`; + } + + private copySelection(): void { + const selectionText = this.terminal.getSelection(); + if (selectionText) { + void copyTextToClipboard(selectionText); + } + this.clearSelection(); + } + + private selectAllText(): void { + this.terminal.selectAll(); + this.selectionStart = null; + this.selectionEnd = null; + this.isSelecting = true; + this.hideHandles(); + + if (this.terminal.hasSelection()) { + this.showContextMenu(); + } else { + this.clearSelection(); + } + } + + private startPinchZoom(event: TouchEvent): void { + if (event.touches.length !== 2) { + return; + } + + this.clearTapHoldTimeout(); + if (this.isSelecting) { + this.clearSelection(); + } + + this.isPinching = true; + this.initialFontSize = this.terminal.options.fontSize ?? DEFAULT_MIN_FONT_SIZE; + this.pinchStartDistance = this.getTouchDistance(event.touches[0], event.touches[1]); + this.lastZoomTime = 0; + } + + private handlePinchZoom(event: TouchEvent): void { + if (!this.isPinching || event.touches.length !== 2 || this.pinchStartDistance <= 0) { + return; + } + + const now = Date.now(); + if (now - this.lastZoomTime < ZOOM_THROTTLE_MS) { + return; + } + this.lastZoomTime = now; + + const currentDistance = this.getTouchDistance(event.touches[0], event.touches[1]); + const scale = currentDistance / this.pinchStartDistance; + const nextFontSize = clamp( + Math.round(this.initialFontSize * scale), + this.minFontSize, + this.maxFontSize, + ); + + if (nextFontSize !== this.terminal.options.fontSize) { + this.onFontSizeChange(nextFontSize); + } + } + + private endPinchZoom(): void { + this.isPinching = false; + this.pinchStartDistance = 0; + this.initialFontSize = 0; + } + + private getTouchDistance(first: Touch, second: Touch): number { + return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY); + } + + private getViewportElement(): HTMLElement | null { + if (this.viewportElement?.isConnected) { + return this.viewportElement; + } + + this.viewportElement = + this.terminal.element?.querySelector('.xterm-viewport') ?? null; + return this.viewportElement; + } + + private resetScrollTracking(): void { + this.lastScrollTouchY = null; + this.lastScrollTouchTime = 0; + this.scrollVelocity = 0; + } + + private recordScrollSample(touch: TouchCoords): void { + const now = performance.now(); + + if (this.lastScrollTouchY !== null) { + const dt = now - this.lastScrollTouchTime; + if (dt > 0) { + // Positive when the finger moves up, matching how xterm increases + // scrollTop, so the inertia continues in the same direction. + const velocity = (this.lastScrollTouchY - touch.clientY) / dt; + this.scrollVelocity = this.scrollVelocity * 0.4 + velocity * 0.6; + } + } + + this.lastScrollTouchY = touch.clientY; + this.lastScrollTouchTime = now; + } + + private maybeStartInertia(): void { + if (this.isSelecting || this.isHandleDragging || this.isPinching) { + return; + } + + const idle = performance.now() - this.lastScrollTouchTime; + if (idle > SCROLL_INERTIA_MAX_IDLE_MS) { + return; + } + + if (Math.abs(this.scrollVelocity) < SCROLL_INERTIA_MIN_VELOCITY) { + return; + } + + this.startInertia(this.scrollVelocity); + } + + private startInertia(initialVelocity: number): void { + const viewport = this.getViewportElement(); + if (!viewport) { + return; + } + + this.cancelInertia(); + + let velocity = clamp( + initialVelocity, + -SCROLL_INERTIA_MAX_VELOCITY, + SCROLL_INERTIA_MAX_VELOCITY, + ); + let lastFrame = performance.now(); + + const step = (now: number): void => { + const dt = Math.max(1, now - lastFrame); + lastFrame = now; + velocity *= Math.pow(SCROLL_INERTIA_FRICTION, dt / 16); + + if (Math.abs(velocity) < SCROLL_INERTIA_MIN_VELOCITY) { + this.inertiaFrame = null; + return; + } + + const before = viewport.scrollTop; + viewport.scrollTop = before + velocity * dt; + if (viewport.scrollTop === before) { + // Reached the top or bottom of the buffer. + this.inertiaFrame = null; + return; + } + + this.inertiaFrame = window.requestAnimationFrame(step); + }; + + this.inertiaFrame = window.requestAnimationFrame(step); + } + + private cancelInertia(): void { + if (this.inertiaFrame !== null) { + window.cancelAnimationFrame(this.inertiaFrame); + this.inertiaFrame = null; + } + } + + updateHandles(): void { + if (this.isContextMenuVisible) { + this.positionContextMenu(); + } + + if (!this.isSelecting || !this.selectionStart || !this.selectionEnd) { + this.hideHandles(); + return; + } + + const { start, end } = this.getOrderedSelection(); + const startPosition = this.terminalCoordsToPixels(start); + const endPosition = this.terminalCoordsToPixels(end); + + // Keep the full handle inside the overlay (which clips via overflow:hidden) + // so a selection that begins at column 0 doesn't leave the handle clipped + // off the left edge where it can't be tapped. + const maxHandleLeft = Math.max(0, this.terminalContent.clientWidth - HANDLE_SIZE_PX); + + if (startPosition) { + this.startHandle.style.display = 'block'; + this.startHandle.style.left = `${clamp(startPosition.x - HANDLE_SIZE_PX / 2, 0, maxHandleLeft)}px`; + this.startHandle.style.top = `${startPosition.y + this.cellDimensions.height + 4}px`; + } else { + this.startHandle.style.display = 'none'; + } + + if (endPosition) { + this.endHandle.style.display = 'block'; + this.endHandle.style.left = `${clamp(endPosition.x + this.cellDimensions.width - HANDLE_SIZE_PX / 2, 0, maxHandleLeft)}px`; + this.endHandle.style.top = `${endPosition.y + this.cellDimensions.height + 4}px`; + } else { + this.endHandle.style.display = 'none'; + } + } + + private clearSelection(): void { + this.terminal.clearSelection(); + this.resetSelectionState(); + } + + private resetSelectionState(): void { + this.isSelecting = false; + this.isHandleDragging = false; + this.dragHandle = null; + this.selectionStart = null; + this.selectionEnd = null; + this.pendingClearTouch = null; + this.touchStart = null; + this.hideHandles(); + this.hideContextMenu(); + this.clearTapHoldTimeout(); + } + + private touchToTerminalCoords(touch: TouchCoords): TerminalCoords | null { + const screenElement = this.getTerminalScreenElement(); + if (!screenElement) { + return null; + } + + const rect = screenElement.getBoundingClientRect(); + const x = touch.clientX - rect.left; + const y = touch.clientY - rect.top; + if (x < 0 || y < 0 || x > rect.width || y > rect.height) { + return null; + } + + this.updateCellDimensions(); + if (!this.cellDimensions.width || !this.cellDimensions.height) { + return null; + } + + const col = clamp(Math.floor(x / this.cellDimensions.width), 0, this.terminal.cols - 1); + const row = Math.floor(y / this.cellDimensions.height) + this.terminal.buffer.active.viewportY; + + return { + col, + row: Math.max(0, row), + }; + } + + private terminalCoordsToPixels(coords: TerminalCoords): { x: number; y: number } | null { + const screenElement = this.getTerminalScreenElement(); + if (!screenElement) { + return null; + } + + this.updateCellDimensions(); + + const visibleRow = coords.row - this.terminal.buffer.active.viewportY; + if (visibleRow < 0 || visibleRow >= this.terminal.rows) { + return null; + } + + const screenRect = screenElement.getBoundingClientRect(); + const containerRect = this.terminalContent.getBoundingClientRect(); + + return { + x: screenRect.left - containerRect.left + coords.col * this.cellDimensions.width, + y: screenRect.top - containerRect.top + visibleRow * this.cellDimensions.height, + }; + } + + private updateCellDimensions(): void { + const renderCell = (this.terminal as TerminalWithRenderService)._core?._renderService + ?.dimensions?.css?.cell; + if (renderCell?.width && renderCell.height) { + this.cellDimensions = { + width: renderCell.width, + height: renderCell.height, + }; + return; + } + + const screenElement = this.getTerminalScreenElement(); + const rect = screenElement?.getBoundingClientRect(); + if (!rect || !this.terminal.cols || !this.terminal.rows) { + this.cellDimensions = { width: 0, height: 0 }; + return; + } + + this.cellDimensions = { + width: rect.width / this.terminal.cols, + height: rect.height / this.terminal.rows, + }; + } + + private getWordBoundsAt(coords: TerminalCoords): { + start: TerminalCoords; + end: TerminalCoords; + } | null { + const line = this.terminal.buffer.active.getLine(coords.row); + if (!line) { + return null; + } + + const lineText = line.translateToString(false); + if (!lineText || coords.col >= lineText.length || /\s/.test(lineText[coords.col])) { + return null; + } + + let startCol = coords.col; + let endCol = coords.col; + + while (startCol > 0 && !/\s/.test(lineText[startCol - 1])) { + startCol--; + } + + while (endCol < lineText.length - 1 && !/\s/.test(lineText[endCol + 1])) { + endCol++; + } + + return { + start: { row: coords.row, col: startCol }, + end: { row: coords.row, col: endCol }, + }; + } + + private blurTerminalInput(): void { + const textarea = this.terminal.element?.querySelector( + '.xterm-helper-textarea', + ); + textarea?.blur(); + } + + private getTerminalScreenElement(): HTMLElement | null { + return ( + this.terminal.element?.querySelector('.xterm-screen') ?? + this.terminal.element ?? + null + ); + } + + private toTouchCoords(touch: Touch): TouchCoords { + return { + clientX: touch.clientX, + clientY: touch.clientY, + }; + } + + private clearTapHoldTimeout(): void { + if (this.tapHoldTimeout === null) { + return; + } + + window.clearTimeout(this.tapHoldTimeout); + this.tapHoldTimeout = null; + } + + dispose(): void { + if (this.isDestroyed) { + return; + } + + this.isDestroyed = true; + this.clearTapHoldTimeout(); + this.cancelInertia(); + + this.terminal.element?.removeEventListener('touchstart', this.onTerminalTouchStart); + this.terminal.element?.removeEventListener('touchmove', this.onTerminalTouchMove); + this.terminal.element?.removeEventListener('touchend', this.onTerminalTouchEnd); + this.terminal.element?.removeEventListener('touchcancel', this.onTerminalTouchCancel); + + this.startHandle.removeEventListener('touchstart', this.onHandleTouchStart); + this.startHandle.removeEventListener('touchmove', this.onHandleTouchMove); + this.startHandle.removeEventListener('touchend', this.onHandleTouchEnd); + this.startHandle.removeEventListener('touchcancel', this.onHandleTouchEnd); + + this.endHandle.removeEventListener('touchstart', this.onHandleTouchStart); + this.endHandle.removeEventListener('touchmove', this.onHandleTouchMove); + this.endHandle.removeEventListener('touchend', this.onHandleTouchEnd); + this.endHandle.removeEventListener('touchcancel', this.onHandleTouchEnd); + + document.removeEventListener('touchstart', this.onDocumentTouchStart); + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables.length = 0; + + this.overlay.remove(); + + if (this.didSetPosition) { + this.terminalContent.style.position = this.originalPosition; + } + } +} + +export function installMobileTerminalSelection( + terminal: Terminal, + terminalContent: HTMLElement, + options: MobileTerminalSelectionOptions = {}, +): MobileTerminalSelectionManager | null { + if (!isTouchSelectionEnvironment() || !terminal.element) { + return null; + } + + return new ShellMobileSelectionCore(terminal, terminalContent, options); +} diff --git a/src/components/shell/view/Shell.tsx b/src/components/shell/view/Shell.tsx index 575b0ac3..c133378d 100644 --- a/src/components/shell/view/Shell.tsx +++ b/src/components/shell/view/Shell.tsx @@ -59,12 +59,8 @@ export default function Shell({ isConnected, isInitialized, isConnecting, - authUrl, - authUrlVersion, connectToShell, disconnectFromShell, - openAuthUrlInBrowser, - copyAuthUrlToClipboard, } = useShellRuntime({ selectedProject, selectedSession, @@ -243,15 +239,7 @@ export default function Shell({ if (minimal) { return ( <> - + ; - authUrl: string; - authUrlVersion: number; - initialCommand: string | null | undefined; - isConnected: boolean; - openAuthUrlInBrowser: (url: string) => boolean; - copyAuthUrlToClipboard: (url: string) => Promise; }; export default function ShellMinimalView({ terminalContainerRef, - authUrl, - authUrlVersion, - initialCommand, - isConnected, - openAuthUrlInBrowser, - copyAuthUrlToClipboard, }: ShellMinimalViewProps) { - const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle'); - const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false); - - const displayAuthUrl = useMemo( - () => resolveAuthUrlForDisplay(initialCommand, authUrl), - [authUrl, initialCommand], - ); - - // Keep auth panel UI state local to minimal mode and reset it when connection/url changes. - useEffect(() => { - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - }, [authUrlVersion, displayAuthUrl, isConnected]); - - const hasAuthUrl = Boolean(displayAuthUrl); - const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden; - const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden; - return (
- - {showMobileAuthPanel && ( -
-
-
-

Open or copy the login URL:

- -
- - event.currentTarget.select()} - className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" - aria-label="Authentication URL" - /> - -
- - - -
-
-
- )} - - {showMobileAuthPanelToggle && ( -
- -
- )}
); } diff --git a/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx index 94d3d491..88c1ee9e 100644 --- a/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx +++ b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx @@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [ { type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' }, { type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' }, { type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' }, + { type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' }, ]; const ARROW_ICONS = { diff --git a/src/index.css b/src/index.css index f99ce614..20ea8a55 100644 --- a/src/index.css +++ b/src/index.css @@ -139,6 +139,12 @@ height: 100%; margin: 0; padding: 0; + /* The app shell is a fixed inset-0 container (see AppContent), so the + document itself never needs to scroll. Clipping it removes the phantom + full-height page scrollbar and disables the browser pull-to-refresh + gesture that reloads the page when scrolling up on mobile. */ + overflow: hidden; + overscroll-behavior-y: contain; } /* Root element with safe area padding for PWA */